HTTP request smuggling, also called HTTP desync, exploits a disagreement about where one request ends and the next begins. Much production traffic crosses two HTTP parsers: a CDN, load balancer or reverse proxy at the front, and an application server behind it. The front end usually sends requests from many users down the same back-end connection. If the two tiers measure a request differently, the bytes one of them treats as leftover become the start of the next request, which may belong to someone else.
The flaw lives in message framing, not in any parameter, so code review rarely sees it. OWASP Top 10:2025 maps its weakness, CWE-444, to A06 Insecure Design.
The mental model: one byte stream, two length rules
HTTP/1.1 has two ways to state a body's length. Content-Length gives a byte count. Transfer-Encoding: chunked sends size-prefixed chunks ending with a zero-size chunk. RFC 9112 lets a server reject a request that carries both or frame it by Transfer-Encoding alone, and either way requires closing the connection afterward. The classic desync classes are pairs of servers that did not both follow that rule; CL.0 and the HTTP/2 classes break the same agreement in other ways.
| Class |
Front end frames the body by |
Back end frames the body by |
| CL.TE |
Content-Length |
Transfer-Encoding |
| TE.CL |
Transfer-Encoding |
Content-Length |
| TE.TE |
An obfuscated Transfer-Encoding it accepts |
Content-Length, because it ignores that header (or the reverse) |
| CL.0 |
Content-Length |
Nothing: the route ignores the body |
| H2.CL / H2.TE |
HTTP/2 frame lengths |
A content-length or transfer-encoding header copied during downgrade |
How it happens
Smuggling appears when independently chosen components are chained, each with its own parser and tolerance for malformed input.
Conflicting headers. One tier picks Content-Length, the other Transfer-Encoding: CL.TE or TE.CL.
Obfuscated Transfer-Encoding. When both tiers support chunking, the attacker makes the header slightly wrong:
Transfer-Encoding : chunked
Transfer-Encoding: xchunked
Transfer-Encoding: chunked
Transfer-Encoding: identity
If one parser ignores the odd header and the other honors it, TE.TE collapses into CL.TE or TE.CL. RFC 9112 requires a server to answer whitespace between a field name and its colon with 400 Bad Request; lenient parsers are the real problem.
Ignored bodies (CL.0). Some back ends ignore Content-Length on routes that never expect a body, such as static files or redirects. The front end forwards the body anyway, and the back end parses it as the next request.
HTTP/2 downgrades. HTTP/2 takes length from DATA frames, and RFC 9113 makes a transfer-encoding header, or a content-length that disagrees with the frames, a malformed message an intermediary must not forward. The risk returns when an edge rewrites HTTP/2 to HTTP/1.1 without those checks: a copied content-length (H2.CL), a copied transfer-encoding (H2.TE) or a line break carried inside a header value hands the client control of the back end's framing. The last case is CRLF injection on the request side.
A concrete test: timing a desync safely
An authorized tester proves the disagreement on their own connection without poisoning anyone else's. The safest method, published by James Kettle, uses time. For CL.TE:
POST / HTTP/1.1
Host: app.example
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
Content-Length: 4
Connection: close
1
A
0
A front end that honors Content-Length forwards four bytes: 1, CR, LF and A. A back end that honors Transfer-Encoding reads a one-byte chunk, then waits for a chunk size that never arrives, so the response hangs. If both tiers chunk, the body is a complete message; if both count, the back end reads four bytes. Either way the answer is immediate. Only the disagreement hangs.
TE.CL is the mirror image: Content-Length: 6 with a body of 0, CR, LF, CR, LF and X. A chunked front end forwards five bytes, and a back end counting to six waits. Test CL.TE first: on a CL.TE pair this probe forwards all six bytes, and the stray X can prefix the next request on the back-end connection.
A hang alone proves nothing; slow endpoints and WAF tarpits hang too. Require a well-formed control to the same origin, such as a POST with Content-Length: 0, to answer quickly, and repeat the ambiguous request. Only a reproducible, large gap counts.
CL.0 has no timing tell, so the test smuggles a harmless request and looks for its answer:
POST /search HTTP/1.1
Host: app.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 76
Connection: keep-alive
GET /ssec7f31c0de9a2b4e61 HTTP/1.1
Host: app.example
Connection: close
A back end that honors the length sees an odd form value. One that ignores it answers the POST, then parses the body as a GET, and a second status line, typically a 404 naming the random path, comes back on the connection. A body of 76 filler bytes must not produce it, and the result must repeat. Unlike the timing probe, this one can leave a stray response on a shared back-end connection, so run it against a test environment or a quiet window. The smuggled request asks for a path that does not exist and closes the connection, which keeps the likely collateral to a single stray 404.
The attacker does not need a bug in the application. They only need two servers to count the same bytes differently.
What an attacker gains
The attacker writes the start of a request the back end attributes to whoever is next on the connection:
- Front-end control bypass. Path rules, authentication checks and WAF inspection run on the request the front end saw, so a smuggled
GET /admin skips them; see access control bypass.
- Capture of other users' requests. A prefix ending in an open body field absorbs the next user's headers and session cookie into a value the application stores.
- Response queue poisoning. An extra response shifts the queue, and users receive responses meant for others.
- Cache poisoning. A mismatched response can be cached under a popular URL; Host header cache poisoning covers that half of the chain.
- Upgraded client-side bugs. A reflected XSS reaches the next visitor without a click.
The fix: one framing rule on every hop
The root cause is two parsers sharing one connection. Remove the ambiguity or remove the sharing.
Speak HTTP/2 to the back end. HTTP/2 frames carry their own lengths, so there is nothing to disagree on. nginx 1.29.4 and later can proxy over HTTP/2:
upstream app_backend {
server 10.0.0.11:8443;
}
server {
listen 443 ssl;
http2 on;
server_name app.example;
ssl_certificate /etc/nginx/tls/app.example.crt;
ssl_certificate_key /etc/nginx/tls/app.example.key;
location / {
proxy_pass https://app_backend;
proxy_http_version 2;
proxy_ssl_verify on;
proxy_ssl_name app-backend.internal;
proxy_ssl_trusted_certificate /etc/nginx/tls/backend-ca.pem;
}
}
In HAProxy, alpn h2 on the server line negotiates HTTP/2 to the back end, and the default strict parser rejects malformed HTTP/1 requests at the edge:
frontend web
mode http
bind :443 ssl crt /etc/haproxy/certs/app.example.pem alpn h2,http/1.1
default_backend app
backend app
mode http
server app1 10.0.0.11:8443 ssl verify required ca-file /etc/haproxy/backend-ca.pem verifyhost app-backend.internal alpn h2
Where HTTP/1.1 stays, reject ambiguity. Every hop should refuse both length headers together, whitespace before a header colon and unknown transfer codings instead of guessing. nginx has rejected the first two since 1.21.1, Apache httpd applies HttpProtocolOptions Strict by default, and Node.js leaves insecureHTTPParser off. Patch every tier promptly; desync fixes ship as parser changes.
Know when connections are shared. nginx enables upstream keepalive by default since 1.29.7, so an upgrade can turn reuse on silently. For a legacy back end you cannot fix, disable it: keepalive 0; in the nginx upstream block, http-reuse never in HAProxy, or disablereuse=On on Apache's ProxyPass.
Make every route honor Content-Length. A handler that does not read a body must still consume or reject it, or close the connection.
Fixes that do not hold
- Checking headers in application code: the server framed the request before the handler ran.
- Blocking
chunked at a WAF: obfuscation, HTTP/2 downgrades and CL.0 do not need the word.
- HTTP/2 only at the edge: downgrading to HTTP/1.1 adds the H2.CL and H2.TE classes.
- Patching one tier: the vulnerability is the disagreement, and every CDN adds another parser.
- Lenient parsing for an old client:
option accept-unsafe-violations-in-http-request (formerly accept-invalid-http-request) in HAProxy, HttpProtocolOptions Unsafe in Apache httpd and --insecure-http-parser in Node.js reopen the tolerance smuggling needs.
A developer review checklist
- Map every hop, from CDN to application server, and the protocol version each speaks.
- Prefer HTTP/2 to the back end; where an edge downgrades, confirm it checks
content-length against the frames and rejects transfer-encoding and line breaks in header values.
- Verify each HTTP/1.1 parser rejects conflicting length headers, whitespace before a colon and unknown codings, with no lenient switch set.
- Confirm body-less routes such as static files and redirects still honor
Content-Length.
- Know whether back-end connections are reused, and disable reuse to back ends you cannot patch.
- Re-test after proxy, CDN or server upgrades, and stop each test at the hang or the 404 marker.
References