HTTP Request Smuggling Part 1
Request Framing, Desynchronization, and Connection Reuse

Introduction
HTTP Request Smuggling is one of the most fascinating web vulnerabilities because it exploits disagreements between web servers rather than flaws in application code.
Modern applications are usually deployed behind reverse proxies, load balancers, CDNs, and WAFs, but these components do not always interpret HTTP requests the same way. By exploiting these parsing inconsistencies, an attacker can "smuggle" a hidden request through the frontend server, causing the backend server to process unexpected data.
This article explains how HTTP Request Smuggling works, why it happens, and how a request comes to be smuggled.
Why does Request Smuggling exist?
Before a user sees a response, their request typically passes through two distinct entities:
The frontend server (NGINX, HAProxy, Cloudflare, Varnish, etc.) is the entry point. It receives requests and may perform routing, caching, rate limiting, WAF inspection, authentication checks, or other processing before forwarding traffic onward.
The backend server (Node.js, Django, Tomcat, Flask) is where the real work happens: business logic, database calls, and generation of the final response.
For this pipeline to work safely, both servers must agree on exactly where one HTTP request ends and the next begins. Unfortunately, HTTP offers more than one way to express that boundary — and that's the root of the problem.
How do HTTP/1.1 and HTTP/2 determine the size of a request body?
HTTP/1.1: the size is declared in the headers
In HTTP/1.1, request bodies are framed using either Content-Length or Transfer-Encoding: chunked.
Content-Length specifies a fixed number of bytes that make up the body:
Content-Length: 10\r\n
\r\n
0123456789
Transfer-Encoding: chunked sends the body as a series of size-prefixed fragments, ending in a zero-length chunk:
Transfer-Encoding: chunked\r\n
\r\n
7\r\n
1234567\r\n
0\r\n
\r\n
RFC 9112 §6.3 ("Message Body Length") is explicit here: if a message arrives with both headers, Transfer-Encoding takes precedence, and the combination should be treated as a possible smuggling or response-splitting attempt. An intermediary that still chooses to forward such a message must strip Content-Length first and frame the body using Transfer-Encoding alone.
In practice, this rule isn't followed consistently. Proxies, load balancers, and backend frameworks vary: some prioritize Content-Length, some prioritize Transfer-Encoding, and some behave differently depending on version or configuration. It's precisely this inconsistency between implementations — not a flaw in the spec — that opens the door to Request Smuggling.
HTTP/2: no length header, just frames
HTTP/2 removes this ambiguity by design. The connection is binary and framed: every frame carries its own explicit length field, so the receiver always knows exactly how many bytes belong to that frame — no guessing. A stream's body ends when a DATA frame arrives with the END_STREAM flag set, not because a counted byte value happened to match.
The classic CL.TE / TE.CL ambiguity simply doesn't exist inside a pure, end-to-end HTTP/2 connection, because message boundaries are defined by frames rather than by competing headers. Most HTTP/2-related smuggling instead arises during translation between HTTP/2 and HTTP/1.1.
This translation is extremely common in real deployments: the frontend terminates the client's HTTP/2 connection, then downgrades to HTTP/1.1 to talk to a backend that doesn't speak HTTP/2 natively. That means HTTP/2's binary framing only protects the client ↔ frontend leg. The moment the frontend rewrites the request into HTTP/1.1 syntax to forward it, all of HTTP/1.1's classic length ambiguity comes back — just relocated to the frontend-to-backend hop instead of the client-to-frontend one.
Note: Downgrading from HTTP/2 to HTTP/1.1 does not automatically create a vulnerability. Desynchronization arises only when the translation process or request parsing logic causes the frontend and backend to disagree on request boundaries.
Where do the leftover bytes go?
Once a desynchronization occurs, the outcome depends on how the smuggled request is framed.
If the smuggled request is complete and self-contained, the backend has no reason to wait. It parses the smuggled request as a genuine second request and generates a second response. That extra response is what pushes the connection out of alignment, creating the response queue poisoning path discussed later.
If the smuggled request is incomplete — missing its final \r\n\r\n, or declaring more body bytes than it actually sends — the backend parser stalls mid-read, still waiting for data the attacker never provided. When the next request arrives on the same connection, the backend treats it as the missing data and merges the two requests together. This is the request queue poisoning path, and it's the one we'll focus on first.
Both outcomes begin the same way: attacker-controlled bytes are left stranded on a reused connection. The difference is what remains unresolved. In the complete case, the backend generates an extra response that has nowhere to go. In the incomplete case, the backend waits for more request data and eventually consumes part of the next user's request.
That raises an obvious question: once those leftover bytes exist, where do they physically sit?
On that reused connection, the leftover bytes may reside in one of two places — or be split across both.
The first is the kernel socket receive buffer — the TCP receive queue for that file descriptor (fd). The frontend has already written those bytes onto the wire, so the kernel holds them as unread payload, visible as recv-q on the socket.
The second is the backend's userspace parser buffer. If the backend's recv() pulled in more bytes than it needed to finish the current request, the surplus sits in the application's own read buffer while the parser pauses, waiting for the rest of what it still believes is an unfinished request.
Which buffer holds the tail depends on how greedily the backend reads, but the point is the same either way: this is per-connection state, not a global queue. The bytes are pinned to that one TCP connection and stay there until the next read() on that same socket — precisely when the next legitimate request arrives and gets appended right behind them.
That is why keep-alive and connection reuse are mandatory: close the connection after the first request and the buffered tail is simply discarded, with no follow-up request for it to attach to.
And for that tail to capture the victim rather than just sit there, the smuggled request has to leave the parser expecting more of the same kind of data the victim will supply — more header text, or more body bytes. Which of the two it waits for is exactly what separates the two techniques we turn to next.
Request Queue Poisoning: Swallowing the next user's request
Request Queue Poisoning Mechanism
There are two common ways to leave the parser expecting more data. Both work by convincing the backend that the smuggled request isn't finished, so it keeps reading from the shared connection — and what it reads next is the victim's request.
Content-Length mismatch
The smuggled request declares a Content-Length larger than the body it actually sends. The parser enters "read N bytes of body" mode and keeps pulling bytes until it reaches that count — swallowing the next client's request as if it were body content.
Smuggled request, waiting to be completed:
POST /otherPage HTTP/1.1\r\n
Content-Length: 41\r\n
\r\n
test=x
The same request once the next client's traffic arrives on the connection:
POST /otherPage HTTP/1.1\r\n
Content-Length: 41\r\n
\r\n
test=xGET / HTTP/1.1\r\n
Host: example.com\r\n
The declared length is deliberately larger than the body actually sent (test=x). That gap is the mechanism: the parser keeps reading until it hits the declared byte count, and the surplus gets filled by the next request's bytes. In practice the length is tuned precisely to capture the portion of the victim's request the attacker wants. A POST is generally more reliable than a GET for the smuggled request, because some implementations ignore or discard GET request bodies entirely.
Dangling header (X-Ignore)
The smuggled request is cut off mid-header, missing the final \r\n\r\n that normally closes the header section. The parser believes it's still reading a header value, so the next client's request line gets absorbed as part of it instead of being parsed as a new request.
Smuggled request, waiting to be completed:
GET /otherPage HTTP/1.1\r\n
X-Ignore: x
Once the next request arrives:
GET /otherPage HTTP/1.1\r\n
X-Ignore: xGET / HTTP/1.1\r\n
Host: example.com\r\n
Why connection reuse is required
Persistent, reused connections mean multiple clients' requests travel over the same TCP stream, and request boundaries are just parsing rules — CRLF-terminated headers, or byte-counted bodies — that can be manipulated independently. Leaving either the header or the body "open" achieves the same result.
Once the victim's request is swallowed, the backend treats attacker headers + victim data as a single logical request and produces a single response for it.
Response Queue Poisoning: how to steal another user's response
Response Queue Poisoning Mechanism
Request Smuggling doesn't only desynchronize the request queue. Once the frontend and backend disagree on how many requests share a connection, they also disagree on which response belongs to which request. This is Response Queue Poisoning (RQP), also known as response desynchronization.
RQP is the mirror image of swallowing. There, the smuggled request was left incomplete so it would absorb the victim's request — two requests on the wire collapse into one at the backend, producing a single response. Here, the smuggled request is complete and self-contained, so the backend parses it as a genuine second request and emits a second response. Swallowing removes a response from the queue; RQP adds a surplus one — and that surplus is what pushes the queue out of alignment.
How it happens
Start from a backend connection that's already desynchronized. The attacker sends what the frontend counts as a single request, but the backend parses as two:
Request 1
└── Smuggled Request (self-contained)
The frontend expects one response. The backend produces two:
Response 1 ← returned to the attacker, as expected
Response 2 ← left queued on the connection, orphaned
The frontend forwards Response 1 and considers the exchange complete. Response 2 has no request waiting for it — from this point on, every response delivered over that connection is one position ahead of the request the frontend thinks it belongs to.
The shifted queue
When the next client sends a request, the frontend pairs it with whatever comes next off the connection — the orphaned response, not its own:
| Request on the wire | Response the frontend hands back |
|---|---|
| Victim's request | Response 2 — from the attacker's smuggled request |
| Attacker's next request | Response 3 — the victim's actual, authenticated response |
| Next client's request | Response 4 — meant for the attacker's request above |
The frontend can't detect the mismatch; it simply forwards responses in arrival order, and the offset persists until the connection resets.
The critical row is the second one: the attacker's own request returns the victim's response — potentially authenticated content from a session that should never have left the victim's account.
Impact
Depending on what the smuggled request targets, RQP can lead to:
Disclosure of another user's data and authenticated content
Session confusion
Delivery of attacker-controlled content to arbitrary users
Cache poisoning
Account takeover, where responses expose sensitive tokens or session state
The exact outcome depends on the frontend's connection-management strategy: some frontends detect the unexpected response, some close the connection immediately, and some recycle it and let the desync propagate.
Why connection reuse is required
Like request queue poisoning, RQP depends on persistent backend connections. The orphaned response is bound to one specific connection, so the attack only works if later requests keep landing on that same connection. Without reuse, the backend closes the connection after the first request, the extra response is discarded, and the desync never propagates.
Bypassing frontend and WAF protections
As Request Smuggling became better understood, frontends and WAFs added defenses: rejecting requests carrying both Content-Length and Transfer-Encoding, normalizing headers, and blocking malformed requests.
But these protections are themselves implemented by HTTP parsers, and a WAF can only enforce rules based on how it interprets a request. If the backend reads the same bytes differently, the two can still desynchronize. A header that looks harmless to one parser may mean something else to another; a request the frontend considers complete may look incomplete to the backend.
Security devices aren't immune to the parser disagreements that make Request Smuggling possible — they're just another component that can be confused. The specific bypass techniques vary by implementation and protocol, but the underlying principle holds: wherever two HTTP components disagree about how to interpret a request, desynchronization becomes possible.
Summary
Everything in Request Smuggling ultimately comes down to one fact: HTTP is a stream protocol layered on top of TCP. The frontend and backend do not exchange "requests"; they exchange bytes. A request only exists once a parser decides where it begins and ends. Whenever two components draw those boundaries differently, desynchronization becomes possible.
| Concept | Key Idea |
|---|---|
| Request framing | Defines where a request ends |
| Desynchronization | Frontend and backend disagree on framing |
| Connection reuse | Required for smuggling to work |
| Request queue poisoning | Victim request is absorbed |
| Response queue poisoning | Victim receives the wrong response |
| HTTP/2 | Safe end-to-end, but downgrading can reintroduce ambiguity |
Now that we've covered why servers can disagree on request boundaries, Part 2 will break down the classic classification of attacks — CL.TE, TE.CL, TE.TE, CL.0, and the HTTP/2 variants — and show how each one exploits a different trust mismatch between frontend and backend.





