Skip to main content

Command Palette

Search for a command to run...

HTTP Request Smuggling Part 2

A Visual Guide to the Main Variants

Updated
14 min readView as Markdown
HTTP Request Smuggling Part 2

Introduction


HTTP Request Smuggling is not a single technique but a family of desynchronization attacks. The specific variant depends on the protocols spoken by the frontend and backend and on how each side determines where a request ends.

The following table summarizes the six variants covered in this post:

Variant Frontend trusts Backend trusts Root cause
CL.TE Content-Length Transfer-Encoding Header disagreement
TE.CL Transfer-Encoding Content-Length Header disagreement
TE.TE Transfer-Encoding Transfer-Encoding Parsing disagreement
CL.0 Content-Length No body Parsing disagreement
H2.CL HTTP/2 framing Content-Length Downgrade issue
H2.TE HTTP/2 framing Transfer-Encoding Downgrade issue

Those six variants fall into three broad categories:

  • Header disagreement (CL.TE, TE.CL) — the frontend and backend trust different framing mechanisms.

  • Parsing disagreement (TE.TE, CL.0) — both sides receive the same request but interpret it differently.

  • Downgrade issue (H2.CL, H2.TE) — ambiguity is introduced when an HTTP/2 request is translated into HTTP/1.1.

One precondition underlies every variant: the frontend reuses a single keep-alive connection to the backend across multiple clients. That connection reuse is what lets attacker-controlled leftover bytes attach themselves to the next user's request.

Two rules to keep in mind before we start. In chunked encoding, every chunk-size line is written in hexadecimal, and it must match the exact byte length of the chunk data that follows. Likewise, any Content-Length the attacker sets must equal the exact number of body bytes actually sent — otherwise the frontend hangs waiting for bytes that never arrive. The examples below are byte-accurate so you can verify them yourself. Line endings are shown as \r\n for clarity.

Note: In this post, we won't cover the 0.CL variant, but keep in mind that, although it may seem paradoxical, this type does exist and will have its own dedicated post on our blog.

Header disagreement: CL.TE


The frontend and the backend disagree on where the first request's body ends (CL vs. TE), so attacker-controlled bytes are left over and parsed as the start of the next user's request on a shared connection.

Crafting the CL.TE payload

POST / HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 41\r\n
Transfer-Encoding: chunked\r\n
\r\n
0\r\n
\r\n
GET /otherPage HTTP/1.1\r\n
X-Ignore: x

Byte count:

0\r\n                       →  3
\r\n                        →  2
GET /otherPage HTTP/1.1\r\n → 25
X-Ignore: x                → 11
= 41 bytes

Smuggling the hidden request

  • Client #1: the attacker crafts a raw request carrying both Transfer-Encoding: chunked and Content-Length: 41. The body hides a terminating chunk (0\r\n\r\n) followed by a second, smuggled request (GET /otherPage…).

  • Frontend (HTTP/1.1, CL): it trusts Content-Length and forwards exactly 41 bytes as one opaque body, unaware those bytes contain a chunked structure and a smuggled request.

  • Backend: it receives the full request, including the Transfer-Encoding header. It prioritizes Transfer-Encoding, parses the body as chunks, and stops at the terminating 0\r\n\r\n, leaving the smuggled bytes unconsumed in its buffer.

  • Response to Client #1: the backend answers the first (legitimate) request normally; the response travels back through the frontend to the attacker.

  • Residual buffer: the leftover smuggled request is incomplete — its header block is never closed by a blank line — so the backend treats it as a still-open request and waits on the same reused connection for more bytes.

Next request hijacked

  • Client #2: a second user sends a normal request, which the frontend will forward over that same pooled backend connection.

  • Frontend: it forwards Client #2's legitimate request to the backend as usual.

  • Backend: Client #2's request line is appended to the dangling header from the smuggled request instead of being parsed as a new request, so the backend finishes and processes the attacker's smuggled request — stealing the second user's turn.

  • Response to Client #2: the backend's response (meant for /otherPage) is delivered by the frontend to the second user instead of their real response, enabling cache poisoning, session hijacking, and so on.

Header disagreement: TE.CL


The reverse case: the frontend honors Transfer-Encoding and validates the chunks, but the backend ignores TE and relies on Content-Length.

Crafting the TE.CL payload

POST / HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 4\r\n
Transfer-Encoding: chunked\r\n
\r\n
35\r\n
GET /otherPage HTTP/1.1\r\n
Content-Length: 20\r\n
\r\n
test=x\r\n
0\r\n
\r\n

Byte count:

GET /otherPage HTTP/1.1\r\n → 25
Content-Length: 20\r\n      → 20
\r\n                        →  2
test=x                     →  6
= 53 bytes = 0x35

And Content-Length: 4 matches the four bytes of the chunk-size line itself (3, 5, \r, \n).

Smuggling the hidden request

Client #1: the attacker crafts a request carrying both Transfer-Encoding: chunked and Content-Length: 4. Inside the chunked body, they hide a second request (GET /otherPage...).

Frontend (HTTP/1.1, TE): it honors Transfer-Encoding and parses the body as a chunked message. The chunk size (35) tells it that 53 bytes of chunk data follow, so it treats everything up to the terminating 0\r\n\r\n as part of a single request body and forwards it to the backend.

Backend (HTTP/1.1, CL): it ignores Transfer-Encoding and instead relies on Content-Length: 4. After reading four body bytes (35\r\n), it considers the request complete and generates a response.

Response to Client #1: the backend's response to the first request is returned through the frontend to the attacker as expected.

Residual buffer: The leftover smuggled request is itself incomplete. It declares Content-Length: 20 but provides only test=x (6 bytes) in its body. When the backend eventually parses that request, it still expects 14 additional body bytes and therefore keeps reading from the connection.

Next request hijacked

Client #2: a second user sends a normal request, which the frontend forwards over the same pooled backend connection.

Frontend: it forwards Client #2's request normally, unaware that unread bytes are already sitting in the backend's receive buffer.

Backend: parsing resumes from the leftover bytes first. The buffered GET /otherPage request is processed before Client #2's request, causing the attacker's smuggled request to steal the next position in the request queue.

Response to Client #2: the frontend receives responses in an order it does not expect. Depending on how the desynchronization unfolds, the victim may receive a response intended for the smuggled request, enabling cache poisoning, session confusion, and other downstream attacks.

The CL.TE and TE.CL variants establish the core desynchronization mechanics. With that foundation in place, the remaining variants can be understood as different ways of creating the same leftover-byte condition.

Parsing disagreement: TE.TE


Unlike CL.TE and TE.CL, the disagreement is not caused by the presence of two competing framing headers. Both servers receive the same Transfer-Encoding header; the desynchronization appears because they parse that header differently.

The trick is to obfuscate the Transfer-Encoding header so that exactly one of the two servers fails to recognize it and silently falls back to Content-Length. Once only one side is still doing chunked parsing, you are back in familiar territory:

  • Frontend honors TE, backend falls back to CL → a TE.CL desync.

  • Frontend falls back to CL, backend honors TE → a CL.TE desync.

Which side you fool (and therefore which desync you get) is target-specific and has to be found by testing. Per RFC 9112 §6.3, a message that carries both Content-Length and Transfer-Encoding ought to be handled as an error; an intermediary that chooses to forward it MUST first strip the Content-Length and process the Transfer-Encoding. But real deployments disagree on how they parse a malformed TE header, and that disagreement is exactly what TE.TE weaponizes.

Common obfuscations. Each of these keeps the header valid for a lenient parser while tripping a stricter one (or vice versa):

Obfuscation Why parsers disagree
Duplicate TE headers Different precedence rules
Invalid whitespace Different syntax tolerance
Bare LF Different line-ending handling
Non-standard values Different transfer-coding validation

Suppose we test the duplicate-header technique. We find that the frontend uses the first Transfer-Encoding header (chunked), while the backend uses the second (cow). The frontend therefore treats the request as chunked, but the backend rejects the unknown transfer coding and falls back to Content-Length. That produces a TE.CL desynchronization:

Crafting the TE.TE (→ TE.CL) payload

POST / HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 4\r\n
Transfer-Encoding: chunked\r\n
Transfer-Encoding: cow\r\n
\r\n
35\r\n
GET /otherPage HTTP/1.1\r\n
Content-Length: 20\r\n
\r\n
test=x\r\n
0\r\n
\r\n

Byte check. The obfuscation lives entirely in the header block, so the body math is identical to the TE.CL section.

Smuggling the hidden request

In this example, the duplicate Transfer-Encoding headers act as the obfuscation primitive. The frontend resolves the ambiguity by using the first value (chunked) and therefore treats the request as chunked. The backend resolves the duplicate headers differently, uses the second value (cow), rejects it as an unknown transfer coding, and falls back to Content-Length.

Once that disagreement has been established, the situation effectively degenerates into a TE.CL desynchronization: the frontend consumes the request as chunked, while the backend determines message boundaries using Content-Length. The subsequent buffering and request-splicing behavior is therefore identical to the TE.CL variant discussed earlier.

Next request hijacked

Parsing disagreement: CL.0


CL.0 is not a protocol-level ambiguity. It relies on implementation-specific behavior where the backend incorrectly assumes a request has no body and therefore ignores the Content-Length header.

Here, the frontend honors Content-Length and forwards the request body normally, but the backend incorrectly treats the request as having a zero-length body. This can occur when:

  • request bodies are ignored for certain HTTP methods;

  • a static-file handler never attempts to read a request body;

  • application logic assumes a particular endpoint cannot receive one.

As a result, the backend stops processing immediately after the headers and leaves the body unread in the connection buffer. Those unread bytes become the same kind of leftover tail described in Part 1: attacker-controlled data stranded on a reused backend connection.

Crafting the CL.0 payload

POST /static.js HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 53\r\n
\r\n
GET /otherPage HTTP/1.1\r\n
Content-Length: 20\r\n
\r\n
test=x

Byte count:

GET /otherPage HTTP/1.1\r\n → 25
Content-Length: 20\r\n      → 20
\r\n                        →  2
test=x                     →  6
= 53 bytes

Smuggling the hidden request

In this example, the frontend honors Content-Length: 53 and forwards the entire 53-byte body. The backend, however, treats the request as bodyless, considers it complete immediately after the headers, and returns a response without consuming the body. The unread bytes are left queued on the persistent backend connection, where they become the beginning of the next request and are parsed as the smuggled GET /otherPage request.

Next request hijacked

Downgrade issue: H2.CL


From here on, the frontend speaks HTTP/2 and downgrades to HTTP/1.1 to reach the backend. This matters because, in HTTP/2, a message's length is defined by its framing (the payload of the DATA frames), not by a header. Content-Length in HTTP/2 is only a consistency check: if it disagrees with the actual DATA-frame length, the message is malformed, and a conformant endpoint MUST treat it as a stream error (PROTOCOL_ERROR) rather than process it (RFC 9113 §8.1.1). H2.CL occurs when a frontend accepts an HTTP/2 request whose Content-Length does not match the actual DATA-frame payload, then copies that incorrect value into the downgraded HTTP/1.1 request instead of rejecting or correcting it.

This is downgrade-related misbehavior: a compliant HTTP/2 frontend would reject the request as malformed instead of forwarding it.

Crafting the H2.CL payload

POST / HTTP/2\r\n
Host: example.com\r\n
Content-Length: 5\r\n
\r\n
x=1\r\n
GET /otherPage HTTP/1.1\r\n
Content-Length: 20\r\n
\r\n
test=x

Byte check. This is a single HTTP/2 request. The smuggled request is not a separate message — it is placed inside the body (the DATA frames) of this one request. The frontend sees only one stream; the split into two requests happens on the backend after the downgrade. (x=1\r\n = 5 bytes, matching Content-Length.)

The POST / HTTP/2 request line and the \r\n-delimited headers above are only a human-readable representation. On the wire, HTTP/2 is binary-framed and uses pseudo-headers (:method, :path, :authority) with no request-line and no CRLFs — the framing is what defines message length here.

Smuggling the hidden request

The desync is introduced during protocol translation. The frontend receives a framed HTTP/2 request, but the backend ultimately processes a downgraded HTTP/1.1 byte stream.

In this example, the frontend converts the HTTP/2 request into HTTP/1.1 while preserving the attacker-supplied Content-Length: 5. The backend trusts that length and consumes only the first five bytes of the body, treating the request as complete. The remaining bytes are left queued on the persistent backend connection, where they become the start of a new request and are parsed as the smuggled GET /otherPage request.

Next request hijacked

Downgrade issue: H2.TE


The same downgrade setup, but the backend honors Transfer-Encoding. The key detail is that Transfer-Encoding is prohibited in HTTP/2: it is a connection-specific header that must not appear in an HTTP/2 message (RFC 9113 §8.2.2; the only related token allowed is TE: trailers). H2.TE exists precisely because the frontend fails to strip or reject this illegal header before downgrading, and the HTTP/1.1 backend then obeys it.

Crafting the H2.TE payload

POST / HTTP/2\r\n
Host: example.com\r\n
transfer-encoding: chunked\r\n
\r\n
0\r\n
\r\n
GET /otherPage HTTP/1.1\r\n
Content-Length: 20\r\n
\r\n
test=x

Byte check. As in H2.CL, this is one HTTP/2 request: the smuggled request lives inside its DATA frames. The split occurs only after the downgrade to HTTP/1.1.

Smuggling the hidden request

The key distinction in this variant is that the desynchronization stems from an HTTP/2-to-HTTP/1.1 downgrade that incorrectly preserves a header forbidden by HTTP/2. A compliant frontend should reject or remove Transfer-Encoding, but the vulnerable frontend forwards it to the backend during translation.

In this example, the backend receives Transfer-Encoding: chunked and therefore determines request boundaries using chunked encoding. By placing a terminating chunk (0\r\n\r\n) at the start of the body, the attacker controls where the backend believes the request ends. Any remaining bytes stay queued on the persistent backend connection, where they become the beginning of a new request and are parsed as the smuggled GET /otherPage request.

Next request hijacked

H2.TE — CRLF injection variant

The H2.TE variant we've covered works when Transfer-Encoding survives the downgrade intact. But a more subtle form exists when the frontend attempts to strip Transfer-Encoding entirely.

HTTP/2 header values are length-prefixed byte sequences—they can contain any octet, including literal CR (0x0D) and LF (0x0A) characters. When a lenient downgrade concatenates an HTTP/2 header name and value without validating or escaping control characters, those CR/LF bytes become line delimiters in the HTTP/1.1 output.

An attacker can embed a new header inside a header value:

HTTP/2 request (single header):

foo: bar\r\ntransfer-encoding: chunked

The frontend sees only a harmless foo header. The backend, after downgrade, sees both foo and transfer-encoding: chunked—the latter appearing from nowhere. The backend then processes the body using chunked framing, triggering the same CL.TE-style desynchronization.

This variant bypasses a frontend that explicitly strips Transfer-Encoding headers before downgrading. The injection happens during the downgrade when control characters in header values aren't escaped.

Summary: the six types of request smuggling


To wrap up, here is a visual summary of the different request smuggling techniques covered in this article. Each diagram illustrates the full workflow for each type.

CL.TE

TE.CL

TE.TE ( →TE.CL)

CL.0

H2.CL

H2.TE


We've seen that HTTP Request Smuggling is a family of desync attacks built on one shared flaw: a frontend and backend reusing a connection while disagreeing on where a request ends. That disagreement takes three forms — header disagreement (CL.TE, TE.CL), parsing disagreement (TE.TE, CL.0), and downgrade issues (H2.CL, H2.TE) — but the payoff is always the same: one user's request gets stitched onto an attacker's leftover bytes.

In the next post, we'll take a closer look at how to spot request smuggling vulnerabilities.

8 views