Client Side De-Sync and Synch0le
👯‍♀️

Client Side De-Sync and Synch0le

📅 [ Archival Date ]
Oct 24, 2022 4:31 PM
🏷️ [ Tags ]
client-side desyncSynch0le
✍️ [ Author ]
jsharpsec
💣 [ PoC / Exploit ]

Defcon just started publishing this year’s talks on YouTube and it includes an excellent talk by James Kettle on HTTP De-Sync attacks, furthering his research from last year.

I found the subject fascinating and highly recommend you check out the talk, paper and corresponding Burpsuite plugins, along with the Portswigger labs to try it for yourself.

I wanted to try and build a tool to enable me to quickly scan targets for the first indicators of a Client-Side Desync attack. After spending many hours re-reading the paper and re-watching the talk, I finally managed to create a PoC tool I’ve called Synch0le.

A De-Sync attack is where we cause a server to become confused about the boundaries of the requests we are sending it. Traditionally, this was done server-side, where a frontend host would forward traffic to the backend, and these servers would become de-synchronised with each other. Using James’ alternative method it is possible to de-sync the connection between a browser and a server, which is a big deal.

image

The malicious payload is in orange, and is transferred to the second packet via the de-sync. Credit James Kettle

This blog post, and currently, Synch0le, focuses on one technique from the talk which has four steps:

  1. Find a server that does not support HTTP/2.
  2. Try to get the server to ignore the Content-Length header on a request, and reply to that request before it has received all the data you said you would send.
  3. Ensure that the server will allow you to keep the connection alive for another request.
  4. Send two packets across one connection where the body of the first request alters the response of the second.

Let’s break this down.

For the first point, HTTP/2 has its own mechanisms for dealing with content length that separate it from this technique, so we just need to drop any hosts which support it. It is quite easy to test if a server supports HTTP/2, credit:

HOST = urlparse(domain_name).netloc
PORT = 443
ctx = ssl.create_default_context()
ctx.set_alpn_protocols(['h2', 'spdy/3', 'http/1.1'])
conn = ctx.wrap_socket(
    socket.socket(socket.AF_INET, socket.SOCK_STREAM), server_hostname=HOST)
conn.connect((HOST, PORT))
pp = conn.selected_alpn_protocol()
if pp == "h2":
    return {"http2": True}
else:
    return {"http2": False}

Second, we want the server to process the request but not validate the Content-Length. This is abnormal behaviour for the server so you need to do something abnormal to trigger it – like sending a POST request to an endpoint that only expects GET, or by triggering an error that does not stop processing of the packet. I built Synch0le to send a POST request to the “favicon.ico” at the URL it got as input. It is important to understand and visualise how to test for vulnerability here.

What we look for is a host which will accept the POST request which has, for example, a Content-Length header value of 5, but has 2 bytes of data in the body of the request, like this:

POST / HTTP/1.1
Host: target.com
Content-Length: 5

XY

The request body contains two bytes, so the “correct” behaviour is for the server to wait for the rest of the data, or to throw an error. When testing, I found servers would respond with an HTTP 499 Client Closed Request after about 60 seconds. However, if the server responds immediately, it indicates that it didn’t properly understand the body of the request, so it didn’t notice there was more data needed. Now, the connection is poisoned because those missing three bytes are going to be “stolen” from whatever data we send to the connection next.

The third step is simple – we are poisoning an HTTP/1.1 connection so it needs to stay open across requests. This is trivial to test for, just make sure that you reuse connections and throw errors if they are closed prematurely. You’ll soon find out if the server kills all its connections once you test for step four.

The fourth and most crucial step verifies the finding and removes false positives – by modifying the response to the second request with the body of the first, we prove that de-synchronisation has in fact taken place.

Here’s how those steps go in Synch0le:

  1. We send a GET to the original URL to ensure availability, and store the HTTP response code (any code will do).
  2. We POST to favicon.ico on the given URL, stating a larger CL than we actually send, with a malicious body, and see if we get a reply.
  3. We repeat the first GET request in the same connection and observe that now we get a different HTTP code than we did earlier.

Here’s how to test a server yourself: In Burpsuite, navigate to Repeater and create a tab group. You will need 2 tabs in this group. In both tabs, click the gear icon next to Send and disable “Update Content Length”, then enable “HTTP/1 connection reuse”.

The first (leftmost) tab we set up as shown in the screenshot below, POSTing to the favicon with an over-large CL. The 26 bytes of data we actually use in the body of the first packet is not as important as the lengths specified in the header, which is 29, 3 bytes too large.

image

An unexpected POST, with CL set to 3 bytes more than is sent.

The server gives is a 405 response straight away:

image

CL was not verified, now our connection has 3 bytes of data “missing”

Then, we follow-up with a standard GET to the same endpoint, naturally we get a 200:

image

This time it’s a 200. This is our baseline.

Whatever response codes you get for each, note them down. We have not yet performed the attack because we didn’t reuse the connection for both packets. Now let’s enable “Send group (single connection)” via the arrow next to “Send” and run again:

image

The same request now gets a 400 – what’s invalid about the GET on the left?

The reason we get a 400 Invalid Packet response with Synch0le is the second packet we send is supposed to look like this:

GET / HTTP/1.1
Host: target.com

But because of the first packet leaving 3 bytes missing on the connection, it looks like this:

 / HTTP/1.1
Host: target.com

Because the first 3 bytes (GET) were taken as the end of the first request, we don’t have a valid HTTP method and so the server replies with a 400, even though the actual request we sent is valid, because the connection became de-synchronised.

This is only the first stage to actually exploiting such a bug. Attacking things like client cookies through this method will take more work, but while we understand the task let’s look at the code.

async with aiohttp.ClientSession(timeout=session_timeout, connector=reusable_conn1) as session:
    # Make the first request to a server. This will be a HTTP GET, to ensure
    # the page is live, grab the banner and then check if the server supports HTTP 2 or not.
    async with session.get(target, allow_redirects=False, timeout=6) as response:
        #print(f"Response HTTP code: {response.status} on {target} to GET /")
        this_server = response.headers["Server"]
        this_version = response.version
        this_code = response.status
        h2_supported = check_http2(target)
        ...
    # Now send a POST request somewhere unexpected like /favicon.ico, with an exaggerated CL
    # If the server responds to this straight away, it might be ignoring CL
reusable_conn = aiohttp.TCPConnector(keepalive_timeout=900, ssl=False)
async with aiohttp.ClientSession(timeout=900, connector=reusable_conn) as session:
    try:
        body = f"""DELETE /%00 HTTP/1.1
X: Y"""
        con_len = len(body)
        async with session.post(target+"/favicon.ico",
        allow_redirects=False,
        timeout=10,
        data=body,
        skip_auto_headers=["Content-Length"],
        headers={"Content-Length": str(con_len+5)}) as response_mid:
            #print(f"Response HTTP code: {response_mid.status} on {target} to wrong CL")
            async with session.get(target,allow_redirects=False,timeout=6) as further_response:
                # Check if the first request affected the second response code
                #print(f"Response HTTP code: {further_response.status} on {target} to GET /")
                success = False
                if further_response.status != this_code:
                    # Success, the first response poisoned the connection
                    success = True

All we have to do is send a GET, then a POST with a CL at least 1 byte larger than the actual payload and follow it up with another GET to observe the difference. Synch0le will whittle down large lists of subdomains from all your favourite enumeration tools and give you a starting point for exploring De-Sync attacks on those endpoints.

us-22-Kettle-Browser-Powered-Desync-Attacks-wp.pdf1229.8KB
James_Kettle_Browser_Powered_Desync_Attacks_A_New_Frontier_in_HTTP.pdf1595.0KB