An HTTP/1.1 request does not describe where it ends. Its boundary is computed — from Content-Length or the chunked coding — and every hop computes it independently. Request smuggling is what happens when two hops compute different answers from the same bytes.

RFC 9112 has since made the rule explicit: Transfer-Encoding overrides Content-Length, a server may reject a message carrying both, and either way it must close the connection. That closes the gap on paper. Real chains still contain hops that predate the rule, hops that speak HTTP/1.0 downstream, and an edge that terminates HTTP/2 and re-frames to HTTP/1.1 inward. The disagreement is where the attack lives.

Two parsers, one message boundary

If Transfer-Encoding is present and chunked is the final coding, the body runs to the terminating zero-length chunk; otherwise a valid Content-Length gives the octet count; a request with neither has no body. When both arrive, the specification says Transfer-Encoding wins — but a hop that only reads Content-Length stops early and forwards the remainder: the attack.

The fixture puts both on a real socket: the front-end frames by Content-Length and forwards the bytes verbatim, the back-end frames by Transfer-Encoding. The attacker’s POST declares Content-Length: 57 and Transfer-Encoding: chunked, with a body of 0\r\n\r\n plus a complete second request. To the front-end that is one 57-byte body; to the back-end the chunked body is zero bytes and the second request is a fresh message. One request became two, and the front-end never saw it.

The four shapes of disagreement

CL.TE is the fixture’s case: the front-end counts bytes, the back-end trusts chunked. TE.CL is the mirror: the front-end decodes chunked, the back-end counts bytes, so a request hides inside what the back-end reads as a chunk. TE.TE is two hops that both honour Transfer-Encoding but disagree about whether the header is valid: Transfer-Encoding: chunked against Transfer-Encoding: xchunked, a duplicated header, or an obs-fold continuation. One declines to decode, and the chunked bytes become a request. CL.CL is two conflicting Content-Length values where the hops pick different ones.

Every variant is the same failure in a different costume: more than one defensible interpretation, and no agreement on which. That is why “normalise the headers” is the wrong mental model: you cannot normalise a parser you do not control, only refuse to forward ambiguity. The fixture’s strict front-end does exactly that: a request carrying both headers gets a 400 and is never relayed, and the back-end stays in sync.

The next request on the connection is the payload

A smuggled request does nothing alone. It sits in the back-end’s receive buffer until the next request arrives on that connection, at which point the back-end answers the smuggled one and the victim gets that response. In the fixture the victim asks for /home and receives the response for the smuggled GET /admin. The attacker never authenticated, never passed the front-end’s routing rules, and never knew the victim.

That is what makes it worse than a parser bug. The front-end’s ACLs, rate limits, WAF rules and audit log all describe the request it parsed, and the smuggled one is not it. It also poisons whatever comes next on that connection: a user whose request lands behind the smuggled one can be answered with someone else’s response, turning a desync into a session-data leak. Fixing one hop is not enough — a desync needs two parsers, but it only needs them to differ by one byte.

When it breaks

The signature is two access logs that disagree. The front-end records two requests, the back-end three, and the extra one is the anomaly: a URL in the back-end log with no matching front-end entry, a request for a path the edge never routed, or a 400 from one layer only. Diff request counts and paths per layer over the same window, and test the framing rules directly instead of trusting vendor documentation.

The second failure is a defence that covers one hop. A header check at the edge does nothing about a hop that re-frames HTTP/2 to HTTP/1.1 downstream, because the ambiguity is created after the check. Prefer HTTP/2 end to end — it has no chunked coding and forbids the connection-specific fields — or, where a downgrade is unavoidable, reject ambiguous framing at the edge and never reuse a connection across a trust boundary. Hardening the front-end while the back-end stays tolerant only changes which hop the attacker picks.

Check it quickly

Send the ambiguous message and count the responses:

printf 'POST / HTTP/1.1\r\nHost: edge.example.com\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: edge.example.com\r\n\r\n' | nc -q1 edge.example.com 80 | tee /tmp/smuggle.out
grep -c '^HTTP/1.1' /tmp/smuggle.out
curl -s -o /dev/null -w 'h2 -> %{http_code} %{http_version}\n' https://edge.example.com/

A hardened edge answers exactly one status line — a 400 for the ambiguous message, or a 200 for the POST alone — and never a second response for /admin. Two status lines, or a body answering the smuggled path, means they disagree. The third command should report 2: if the edge downgrades to HTTP/1.1 internally, framing rules still apply whatever the client negotiated.

Sources