HTTP/2 downgrade smuggling and the desync that survived the upgrade
HTTP/2 was supposed to kill request smuggling. The whole class of attack comes from two parsers disagreeing about where one request ends and the next begins, and HTTP/2 removes the ambiguity at the source: every message carries its length in a binary frame header, counted in bytes, with no header field to lie about. A length you read off a fixed-width field cannot contradict a length you read off a different field, because there is only one field. So the story should have ended in 2015 when RFC 7540 shipped.
It did not end. The reason is mundane and it is everywhere. Most of the web speaks HTTP/2 at the edge and HTTP/1.1 behind it. The front end terminates the HTTP/2 connection, then rewrites each request into HTTP/1.1 syntax to hand to an origin that never learned the new protocol. That rewrite is the bug. The binary length field protected the message only while the message stayed in HTTP/2. The instant a proxy serializes it back into text, the length has to be re-encoded as a Content-Length or Transfer-Encoding header, and the back end is once again reading a number out of a string that an attacker controls. The upgrade got rolled back, request by request, at the worst possible boundary.
This post is about that boundary. It covers the two core downgrade desync variants, H2.CL and H2.TE, and why they are the old CL.TE and TE.CL with one parser swapped out. It covers a second family that downgrading creates from nothing: CRLF injection through HTTP/2 header values and pseudo-headers, which has no HTTP/1.1 equivalent because HTTP/1.1 cannot represent it. It covers request tunnelling, the narrower attack that survives when connections are not shared between users. Then it covers the state of play as of 2026, because the 2025 follow-up research showed the problem is wider than the edge, and the only real fix is the one almost nobody has deployed. If you have read the HTTP/1.1 smuggling reference, this is the sequel, and the title of Kettle’s talk was not an accident.
Where the length lives in each protocol
Start with the thing that makes smuggling possible at all: more than one way to state how long a body is. In HTTP/1.1 a message body’s length is declared by a header. Either Content-Length, which gives a byte count, or Transfer-Encoding: chunked, which says “read length-prefixed chunks until a zero-length chunk.” The two are mutually exclusive by spec, and a request that carries both is malformed. RFC 7230 says the receiver must reject it or strip the Content-Length. The whole HTTP/1.1 smuggling family exists because real servers do not agree on which one wins when both are present, or on how to parse a slightly malformed version of either.
HTTP/2 throws all of that out. A request is a HEADERS frame followed by zero or more DATA frames, and the body’s length is just the sum of the DATA frame payload lengths. Each frame begins with a 24-bit length field in its 9-byte prefix. There is no Content-Length to consult and no chunked encoding to parse. The spec is explicit that any message carrying the old connection-specific framing headers, Transfer-Encoding among them, is malformed and must be treated as a protocol error. The length is structural, not declarative. You cannot lie about it because there is no statement to falsify.
So far so good. If both ends speak HTTP/2, the smuggling problem is genuinely gone for length-based desync. The trouble is the assumption that both ends do.
The downgrade reintroduces a fourth way to be wrong
Front-end servers that terminate HTTP/2 and forward HTTP/1.1 have to manufacture the length header the origin expects. The front end knows the real body length from the DATA frames, so it could simply emit a correct Content-Length and discard anything the client tried to smuggle. Many do exactly that, and they are safe. The vulnerable ones do something subtler and worse: they copy the client’s headers through, including a Content-Length the client supplied inside the HTTP/2 request, and they trust the client’s number instead of the frame length they could have computed.
That is the crux. HTTP/2 does not forbid a request from carrying a content-length header. The spec allows it, on the condition that the value matches the actual DATA frame length. A conformant server validates that match and rejects the request if it fails. A non-validating server passes the mismatched header straight through the downgrade. Now the back end, reading HTTP/1.1, sees a Content-Length that disagrees with how much data actually arrived, and the front end and back end have desynchronized on exactly the byte boundary that smuggling needs.
Kettle’s point in the 2021 PortSwigger research is that downgrading is more dangerous than HTTP/1.1 end to end, because it adds a fourth way to specify a message’s length on top of the three HTTP/1.1 already had. The front end’s authoritative source is the HTTP/2 frame length. The back end’s authoritative source is whatever header survived the rewrite. When those two sources are allowed to disagree, you have a desync, and the variant is named for which parser trusts which field.
H2.CL: the front end counts frames, the back end believes Content-Length
In an H2.CL desync the smuggled request carries an explicit content-length header whose value is smaller than the real body. A common shape is content-length: 0 with an actual DATA payload that contains a complete HTTP/1.1 request prefix. The front end forwards the request, the body and all, but writes the bogus length into the HTTP/1.1 request line it hands to the origin. The origin reads zero bytes of body, considers the first request complete, and then finds the smuggled bytes still sitting in its receive buffer. It treats them as the start of the next request on that connection.
The leftover bytes are a prefix. When the next request arrives on that shared back-end connection, the origin concatenates the prefix with the victim’s request, and the victim is now sending the attacker’s smuggled method and path with their own cookies and session attached. This is the same primitive as classic CL.TE smuggling, the front end and back end simply disagree because one of them is reading a frame length the other never saw.
H2.TE: the front end ignores Transfer-Encoding, the back end honours it
H2.TE is the mirror image. The attacker includes a transfer-encoding: chunked header inside the HTTP/2 request. Per spec the front end should reject the message outright, because Transfer-Encoding is a connection-specific header that is malformed in HTTP/2. The vulnerable behaviour is to ignore the rule, neither reject nor strip the header, and forward it intact through the downgrade. The origin, now reading HTTP/1.1, sees a legitimate-looking Transfer-Encoding: chunked and switches to chunked parsing. The front end already framed the body by HTTP/2 length, so the two ends carve the byte stream at different points. The terminating zero-length chunk, or the absence of one, decides where the back end thinks the request ends, and that is rarely where the front end thought.
Worth being precise about where the carve lands, because chunked parsing is what makes H2.TE flexible. Once the back end is in chunked mode it reads a hex chunk-size line, then that many bytes, then the next size line, repeating until it hits a 0 chunk that terminates the body. The attacker controls those size lines because they are part of the body the front end forwarded by frame length without interpreting. So the attacker can declare a chunk that ends early, write a 0\r\n\r\n terminator wherever they want the back end to think the request stops, and place a smuggled request prefix immediately after it. The front end, counting only frame bytes, sees one well-formed message and forwards the whole thing. The back end, parsing chunks, stops at the planted terminator and treats everything past it as the next request. Same desync as H2.CL, reached through a different parser quirk, and often more controllable because chunk boundaries are arbitrary in a way a single content-length integer is not.
The most consequential real-world instance of H2.TE was AWS Application Load Balancer. PortSwigger’s research found that ALB accepted requests containing Transfer-Encoding rather than rejecting them as the RFC requires, which meant essentially every site fronted by ALB was exposed to an H2.TE desync against its origin. The class of obfuscation that http2smugl probes for, including swapping the ASCII k in chunked for the Unicode Kelvin sign U+212A so a naive front-end check misses it, exists precisely to slip a Transfer-Encoding past a front end that thinks it is filtering them. The check fails because the front end string-compares the value against chunked and the Kelvin sign is a different codepoint, while the back end’s HTTP/1.1 parser case-folds or normalizes more aggressively and reads it as chunked anyway. The disagreement is not about length at that point, it is about whether the header even counts, and the two ends answer differently.
CRLF injection: the attack downgrading invents
H2.CL and H2.TE are old attacks with a new parser on one side. Downgrading also creates a family with no HTTP/1.1 analogue, because it exploits something HTTP/1.1 syntax physically cannot express.
In HTTP/1.1 a header is a line. The name, a colon, the value, then a carriage-return-line-feed that ends the line. The \r\n is the delimiter, so by definition it cannot appear inside a value, there is no way to write it. In HTTP/2 a header is a name-value pair inside a HEADERS frame, length-prefixed in the HPACK encoding, with no delimiter character at all. Nothing structurally stops a header value, or even a header name, from containing a \r\n byte sequence. It is just data. The HTTP/2 spec does forbid these characters in header fields, but enforcement is the front end’s job, and a front end that skips the check will faithfully copy those bytes into the HTTP/1.1 request it generates.
That is the whole attack. A \r\n smuggled inside an HTTP/2 header value becomes a real line break the instant the front end serializes the request to HTTP/1.1 text. Everything after it is now, to the back end, a separate header or even a separate request. The attacker has written across the line boundary that HTTP/1.1 treats as inviolable, using HTTP/2 as the delivery vehicle.
The injection point does not have to be a header value. Kettle’s research and Emil Lerner’s parallel work, presented around the same time in mid-2021, both showed pseudo-headers as a vector. The :path pseudo-header maps to the HTTP/1.1 request line, so injecting a space or a newline into :path lets you rewrite the request line itself, splitting one request into two. The :method pseudo-header is similar, and the Apache mod_proxy request-line injection tracked as CVE-2021-33193, patched in httpd 2.4.49, came from spaces sneaking through in the method position. Lerner’s http2smugl automated the search across the combinatorial space of where the bad bytes go and how they are obfuscated. Akamai, analysing both researchers’ tools after Black Hat 2021, noted Lerner’s tool tested on the order of 564 different combinations, which gives a sense of how many distinct ways a front end can mishandle the rewrite.
Request tunnelling, when the connection is not shared
Classic smuggling needs a shared back-end connection. The poisoned socket carries the attacker’s prefix into the next person’s request, so the victim has to be a different user reusing the same connection. Many architectures break this on purpose. Some front ends only let a connection be reused by requests from the same client IP. Others never reuse a back-end connection at all, opening a fresh one per request. On those systems the prefix has nobody to poison. The classic attack dies.
A weaker primitive survives, and Kettle named it request tunnelling. Even on a non-shared connection, a single crafted request can be made to elicit two responses from the back end. The attacker cannot reach another user, but they can read the second response themselves, and that second response is one the front end’s access controls never inspected because the front end thought it had only sent one request. Front-end routing rules and authentication checks operate on the request as the front end parsed it. The tunnelled second request slips underneath them.
Reading the hidden response is the fiddly part, because the front end usually only forwards back the first response and discards the rest. The standard technique abuses the HEAD method. A HEAD response carries a Content-Length matching what the equivalent GET would return, but with no body. A front end that trusts that header and tries to read that many body bytes will over-read, pulling the start of the second, tunnelled response into the bytes it returns as the first response’s body. The attacker sizes the resources so the over-read lands on the data they want to exfiltrate. PortSwigger’s own Jira finding, worth a $15,000 bounty, ran through a Pulse Secure Virtual Traffic Manager front end and reached session hijacking via a smuggled Set-Cookie, on infrastructure where the cleaner response-queue-poisoning attack was not available.
What the 2021 wave actually hit
The damage was not theoretical and it was not narrow. The Netflix finding traced an H2.CL desync through their Zuul gateway down to the Netty networking library, fixed as CVE-2021-21295, and earned PortSwigger’s maximum Netflix bounty of $20,000. The exploit chained into credential theft by smuggling a prefix that caused a victim’s request to load attacker-controlled JavaScript with access to the victim’s cookies. AWS ALB’s acceptance of Transfer-Encoding exposed its entire customer base to H2.TE, with individual targets behind it including Verizon and AOL properties paying out separately. Netlify’s CDN had a header-injection variant that touched, of all things, the Firefox start page. Imperva’s Cloud WAF was reported vulnerable across customers. F5 BIG-IP carried a request-splitting issue tracked as K97045220. Atlassian, Apache httpd, Pulse Secure: the list reads like an inventory of the load balancers and CDNs that the bulk of the web sits behind.
The common thread is that none of these were origin-application bugs. They were edge bugs, in the exact tier whose job is to terminate HTTP/2 and rewrite to HTTP/1.1. The attack surface was created entirely by the translation, and it lived in software that most site operators never see, configure, or patch directly.
This is also why HTTP/2 fingerprinting and HTTP/2-level filtering matter to the defenders sitting at that edge. A WAF that only inspects the downgraded HTTP/1.1 stream is inspecting the attacker’s output, not their input, and the malformed framing that triggers the desync may already be gone or transformed by the time it looks. Akamai’s stated defence was to block the malformed requests at HTTP/2 processing, before translation, which is the only place the original ambiguity is still visible. If you care about what an edge can and cannot see at this layer, the mechanics overlap heavily with HTTP/2 frame fingerprinting and with how a WAF actually works once the request is already in HTTP/1.1 form.
Why HTTP/2 did not end smuggling, stated plainly
The protocol fixed the length-ambiguity problem completely, within its own scope. The scope is the part of the path that speaks HTTP/2. The web did not adopt HTTP/2 end to end. It adopted HTTP/2 at the front door and kept HTTP/1.1 in the back, and the safety property of the binary length field does not survive being serialized back into a header. You cannot get the guarantee for a request that spends half its life as text. The upgrade was real and the downgrade undoes it, request by request, at the proxy.
There is a sharper way to put it. The vulnerability is not in HTTP/2 and it is not really in HTTP/1.1 either. It is in the rewrite, the lossy and under-specified translation between two protocols with different framing models, performed by software that historically did not treat that translation as security-sensitive. Every one of the 2021 findings was a place where the translator trusted attacker input it should have validated or discarded, whether that was a copied content-length, a forwarded transfer-encoding, or a \r\n byte left inside a header value. The fix in every case was the same shape: validate at the HTTP/2 boundary, recompute the length yourself, and never let a client-supplied framing artifact cross the downgrade.
Detecting it, and where detection is blind
Finding a downgrade desync without breaking the target is a timing problem. The canonical test sends a request whose smuggled portion, if the desync exists, forces the back end to wait for bytes that never arrive, and measures whether the response stalls. A front end that correctly recomputes the length answers immediately. A vulnerable one hangs until a timeout, because the back end is waiting on a chunk or a content-length it will never get. PortSwigger’s HTTP Request Smuggler automates this with a timeout-based strategy for H2.CL and H2.TE, and confirms tunnelling separately through the HEAD over-read rather than the timing path. Lerner’s http2smugl takes the brute-force route across its hundreds of byte-placement and obfuscation combinations. Neither is subtle, which is why running these against production is its own risk: a true positive desync test can poison a real connection.
The harder lesson is about where a WAF can and cannot help. A signature engine bolted onto the back end inspects the request after the downgrade, in HTTP/1.1 form, which means it is looking at whatever the front end’s flawed rewrite already produced. If the front end smuggled a \r\n into a header value, the back-end WAF may simply see two clean headers and a clean second request, with no malformed bytes left to match on. The ambiguity that defines the attack only exists at the HTTP/2 boundary, in the mismatch between frame length and header, and that mismatch is gone by the time the HTTP/1.1 stream reaches an inline rule set. Effective blocking has to happen at the edge, at HTTP/2 processing, before translation. That is exactly what Akamai described doing: reject malformed framing while still in HTTP/2, never let the bad request reach the rewrite. For anyone building or reasoning about that edge layer, the relevant inspection surface is the same one covered in HTTP/2 frame fingerprinting and detection, and the limits of pattern-based filtering after the fact are the limits described in WAF evasion concepts.
The defensive recipe that closes every 2021 finding is short. Validate at the HTTP/2 boundary that any content-length matches the summed DATA frame length, and reject on mismatch instead of forwarding. Reject any HTTP/2 request carrying a connection-specific header like Transfer-Encoding outright, as the spec already requires. Forbid \r\n and other control bytes in header names, header values, and pseudo-headers before serializing. And when downgrading, recompute the length from the frames yourself rather than trusting any client-supplied number. None of this is exotic. It is the validation the spec already mandates, applied at the one tier whose job is the translation.
The state of play in 2026
If the story stopped in 2021 the conclusion would be comforting: patch your edge, validate the rewrite, done. The 2025 follow-up research, Kettle’s “HTTP/1.1 must die: the desync endgame,” argued the problem is structurally worse than that, and the part that matters here is the upstream side. Downgrade smuggling is about HTTP/2 in front of HTTP/1.1. The endgame research showed that even the connection from a front end to its origin, the upstream hop, is usually HTTP/1.1, and that hop carries its own desync surface that a perfectly behaved client-facing HTTP/2 listener does nothing to protect.
The new attack classes from that work are mostly HTTP/1.1-internal, things like 0.CL desyncs broken open with early-response gadgets and the Expect: 100-continue header used to confuse parsers, with bounties across T-Mobile, GitLab, Akamai and others, and an Akamai CL.0 issue assigned CVE-2025-32094 that paid out around $221,000 spread over 74 separate bounties. They are not downgrade bugs as such. But the prescription that closes that research is exactly the one this post has been circling. Kettle’s recommendation is upstream HTTP/2: speak HTTP/2 not only from the client to the edge but from the edge to the origin, so the binary length field protects the message for its whole journey and there is no text serialization anywhere to desync on. He is blunt that HTTP/2 downgrading, HTTP/2 in front of HTTP/1.1 origin, gives almost no security benefit and in his words actually makes sites more exposed, because of everything in this post.
The catch is deployment. As of the 2025 research, upstream HTTP/2 to origin was available on HAProxy, F5, Google Cloud, and Imperva, with Apache support experimental. It was not available on Cloudflare, nginx, Akamai, CloudFront, or Fastly, which is to say it was not available on most of the CDN capacity of the internet. So the actual 2026 posture for the typical site is: HTTP/2 to the edge, HTTP/1.1 to the origin, a downgrade somewhere in the middle, and a dependence on that one proxy validating its rewrite correctly forever. The class of bug this post describes is not closed. It is patched instance by instance, in software the site operator does not control, on a path that the protocol designers explicitly recommend abandoning and that the infrastructure cannot yet abandon.
Eleven years after HTTP/2 shipped, the most reliable way to avoid HTTP/2 downgrade smuggling is to not downgrade, and the most common deployment on the web still does. The length field that was supposed to end the argument is still being translated back into a sentence an attacker gets to finish.
Sources & further reading
- James Kettle / PortSwigger (2021), HTTP/2: The Sequel is Always Worse — the originating research on H2.CL, H2.TE, request tunnelling, CRLF injection via downgrade, and the named real-world findings.
- James Kettle / PortSwigger (2021), HTTP/2: The Sequel is Always Worse (whitepaper PDF) — full DEF CON 29 paper version with the detailed mechanism walkthroughs.
- James Kettle / PortSwigger (2025), HTTP/1.1 must die: the desync endgame — the 2025 follow-up arguing upstream HTTP/2 is the real fix and documenting which vendors do and do not support it.
- PortSwigger Web Security Academy, HTTP/2 downgrading — concise statement of the three-ways-to-specify-length problem and the pseudo-header mapping.
- PortSwigger Web Security Academy, Lab: H2.CL request smuggling — the canonical H2.CL example with content-length disagreement against frame length.
- PortSwigger Web Security Academy, HTTP request tunnelling — the tunnelling primitive, the HEAD over-read technique, and the limits when connections are not shared.
- Akamai SIRT (2021), HTTP/2 Request Smuggling — vendor analysis of the Kettle and Lerner tooling, including blocking at HTTP/2 processing before translation.
- Wallarm (2021), http2smugl: HTTP2 request smuggling security testing tool — Emil Lerner’s tool, the Unicode-obfuscated Transfer-Encoding trick, and his Cloudflare finding.
- IETF (2015), RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) — the framing layer, DATA frame length encoding, and the rule that connection-specific headers are malformed.
- IETF (2014), RFC 7230: HTTP/1.1 Message Syntax and Routing — the Content-Length versus Transfer-Encoding precedence rules that the downgraded request is parsed against.
- Outpost24 (2023), Request smuggling and HTTP/2 downgrading: exploit walkthrough — a step-by-step practitioner walkthrough of the downgrade desync variants.
Further reading
The HTTP/2 Rapid Reset attack (CVE-2023-44487) explained
Traces how a request-then-RST_STREAM loop in HTTP/2 sidestepped the concurrency limit that was supposed to bound per-connection work, set DDoS records at 398 and 201 million requests per second, and forced a round of server patches.
·23 min readRequest smuggling: how HTTP/1.1 desync attacks exploit parser disagreement
Traces how HTTP/1.1's two ways of measuring a request body let a front-end and back-end disagree on where one request ends, how CL.TE and TE.CL desync turns that into socket poisoning, and what actually fixes it.
·21 min readHow DataDome uses HTTP/2 and network fingerprints as a signal
A reference on the network-layer fingerprints DataDome reads: HTTP/2 SETTINGS frames, flow control, pseudo-header order, and how a mismatch between the claimed user agent and the wire profile flags a client.
·21 min read