← Writing
Web SecurityNetworking

Two Parsers, One Stream

How a reverse proxy and a backend can disagree about where a request ends, and what an attacker gets when they do.

Aug 20266 min read

Every request your browser makes to a production site almost never reaches the application directly. It goes through a reverse proxy first, nginx, HAProxy, a cloud load balancer, and only then to the actual server. That extra hop feels invisible, until you realize the proxy and the backend are two completely different programs, and the only thing they actually share is a stream of raw bytes. Nothing forces them to agree on where one request in that stream stops and the next one starts.

The setup

Client talks HTTPS to the proxy. The proxy decrypts it, that's TLS termination, and forwards the request onward as plain HTTP over the internal network. That's the setup that matters here: two separate programs, and the second one only ever sees what the first one hands it.

clientHTTPS, encryptedreverse proxydecrypts hereplain HTTPbackend Abackend Bbackend C
Figure 1. Client sees only the proxy. Past that point, everything is plain HTTP, and two independent programs now have to parse it the same way.

One pipe, many requests

Why not just open a fresh TCP connection for every request? Because the handshake cost adds up fast once you're past a handful of requests a second. So proxies reuse one connection to the backend, called keep-alive, and send many different clients' requests down it, one after another. Which means the backend has to know exactly where request one ends before it starts reading request two off the same stream.

client Aclient Bclient Cproxyone connectionreq Areq Breq Cqueued back to back, same reused connectionB
Figure 2. Three requests, one shared connection. If the proxy and the backend ever disagree about where request A ends, the next read starts partway into someone else's request, that mechanism is what the rest of this post is about.

Two ways to say “this is where the body ends”

Think of it like two people transcribing the same phone call, but using different rules for where one sentence ends and the next begins. Most of the time they land in the same place. HTTP hands you two different headers for telling a server how long a request body is, and this is the seam the whole attack lives in.

HeaderWhat it means
Content-Length: 13The body is exactly 13 bytes. Read that many and stop.
Transfer-Encoding: chunkedThe body arrives in labeled chunks. A chunk labeled size 0 means “last one, stop here.”

Nothing stops a client from sending both headers in the same request. The spec says Transfer-Encoding should win when both show up, but not every server actually implements that rule, some older or misconfigured ones still trust Content-Length regardless. That mismatch, one side trusting a header the other one ignores, is the entire vulnerability: request smuggling, sometimes called a desync attack, because the two parsers fall out of sync about where they are in the stream.

Watching it happen, byte by byte

Here's a single request that a proxy and a backend can read completely differently. Security research on this usually calls the two sides front-end (the proxy) and back-end (the actual server), so that's the naming this section uses too. This particular flavor is called CL.TE: the front-end trusts Content-Length, the back-end trusts Transfer-Encoding instead.

the raw request, exactly as sent
POST / HTTP/1.1
Host: example.com
Content-Length: 13
Transfer-Encoding: chunked

0

SMUGGLED
front-end reads

Trusts Content-Length: 13. Counts 13 bytes, decides the request is done, forwards exactly that.

back-end reads

Trusts Transfer-Encoding: chunked. Reads a chunk size of 0, which means “end of body, right here.” Stops after 5 bytes. SMUGGLED is never read as part of this request.

Written out with the actual line breaks instead of blank lines, that body is 0\r\n\r\nSMUGGLED. The 0 is the chunk size, the \r\n\r\n right after it is the terminator that means “end of body,” five bytes total, and SMUGGLED is the eight bytes sitting right after it that the back-end never asked for.

= forwarded by the proxy, but not read by the back-end as part of this requestfront-end reads all 13 bytes as the body01\r2\n3\r4\n5S6M7U8G9G10L11E12D13back-end stops here (byte 5)bytes 6-13 become the start of the next request
Figure 3. Every byte of the body, one box each. Bytes 1-5 are the chunk terminator both sides agree on. Bytes 6-13 spell SMUGGLED, forwarded by the front-end, but the back-end already stopped reading by then.

Those leftover 8 bytes don't disappear anywhere. They're still sitting on the same connection, still queued. So the back-end does the only reasonable thing it can, it treats them as the start of the next request that arrives on that connection. And the next request on that connection almost never belongs to the attacker anymore, it belongs to whoever the proxy forwards down that same pipe next.

What an attacker actually gets

Replace SMUGGLED with the start of a crafted request instead, and it gets glued onto the front of someone else's traffic. Whoever's request follows next on that connection, a real visitor, inherits the attacker's prefix without ever knowing it. From there it plays out one of two ways: the mixed-up response can go back down the victim's own connection instead of the attacker's, leaking session cookies or auth headers straight to whoever is watching, a session hijack. Or, if a shared cache sits in front of the backend, that same mixed-up response gets stored and handed to every visitor after, a cache poisoning.

attacker's unread prefix+victim's next real requestback-end sees ONE requestresponse goes back mixed upon the victim's own connectionsession / auth data exposedor the response gets cachedand served to every visitor aftercache poisoning
Figure 4. One malformed request, sent once, and neither server was ever individually compromised.

Both parsers followed a header correctly. They just didn't follow the same one.

How this gets fixed

FixWhat it does
Reject ambiguous requestsBoth headers present, respond with an error instead of guessing. Most modern proxies do this by default now.
Normalize at the edgeProxy rewrites the request into an unambiguous form before forwarding.
Don't reuse the connectionClose and reopen per request on sensitive paths. Costs performance, removes the shared stream.
HTTP/2 end-to-endExplicit binary length up front, no CL vs TE ambiguity. Risk returns if something downgrades to HTTP/1.1 mid-chain.

The one-sentence version

A proxy and a backend are two separate programs sharing one stream of bytes for more than one request. The moment they disagree about where a request ends, whatever's left over doesn't vanish, it becomes the start of whatever the next real visitor sends.