5 minute read

The 2019 request-smuggling work on this blog, the CL.TE / TE.CL / TE.TE matrix, was about HTTP/1.1 disagreeing with itself. Two servers on one connection, one honouring Content-Length, the other honouring Transfer-Encoding, and a request boundary hidden inside the disagreement. HTTP/2 was supposed to end that. It mostly did, on paper. The frame carries its own length, so there is nothing for two parties to disagree about.

Then James Kettle published HTTP/2: The Sequel is Always Worse in 2021, and the answer turned out to be that the disagreement did not disappear, it moved. It moved to the point where an HTTP/2 front-end re-serialises a request into HTTP/1.1 for a back-end that never spoke HTTP/2. Five years on, that downgrade boundary is still where the desync lives, and the ALB-style terminator is still the cleanest example of why. This post is an analysis of that class, building on Kettle’s published results rather than a fresh cross-vendor table.

Why the downgrade re-creates the bug HTTP/2 removed

On a pure HTTP/2 path the message length is unambiguous. The problem is that most back-ends do not speak HTTP/2. The common deployment is a front-end (a cloud load balancer, a CDN edge) that terminates HTTP/2 from the client and forwards HTTP/1.1 to the origin. The moment it does that, the back-end loses access to the HTTP/2 frame length and has to fall back on the only length signals HTTP/1.1 has: Content-Length and Transfer-Encoding.

So the front-end has to decide what length headers to emit when it downgrades. If it forwards a Content-Length or Transfer-Encoding that the client put in the HTTP/2 request, the client controls the back-end’s idea of where the request ends, and we are back in 2019 with extra steps. RFC 9113 Section 8.2.2 is explicit that connection-specific header fields, Transfer-Encoding among them, make an HTTP/2 message malformed and must be rejected. The whole attack class is front-ends that do not enforce that rule before they downgrade.

H2.TE

The first primitive is H2.TE. The attacker sends an HTTP/2 request carrying Transfer-Encoding: chunked. A conformant front-end rejects the message as malformed per RFC 9113. A non-conformant one forwards the header into the downgraded HTTP/1.1 request, the back-end sees Transfer-Encoding: chunked and parses the body as chunked, and the front-end has already decided where the body ends using the HTTP/2 frame length. The two boundaries differ.

Kettle’s result here is the one worth remembering: AWS Application Load Balancer accepted Transfer-Encoding on HTTP/2 requests, which meant almost every site sitting behind an ALB was exploitable through an H2.TE desync. That is not an obscure appliance. That is a default building block of a large fraction of the modern web, failing the single validation step the specification requires.

H2.CL

The second primitive is H2.CL, and it is the mirror image. The attacker sends an HTTP/2 request with a Content-Length that does not match the actual frame body. The front-end forwards that Content-Length verbatim into the HTTP/1.1 request. The back-end trusts it, because on HTTP/1.1 the Content-Length is authoritative, and reads either too many or too few bytes. Whatever is left over becomes the prefix of the next request on that connection.

There is a degenerate case of this that is worse than it sounds. If the front-end forwards no length at all, or the back-end assumes a Content-Length that was never really there, you get the CL.0 family. Kettle demonstrated exactly this against amazon.com, where a request to one path ignored the Content-Length, and a proof of concept quietly captured other live users’ full requests, session tokens included. The primitive is boring. The blast radius is not.

The binary layer hides a line-based weapon

There is a third route that is specific to the downgrade and has no HTTP/1.1 equivalent. HTTP/2 header fields are binary and length-prefixed, so a header value containing a raw \r\n is structurally legal inside HTTP/2. It is meaningless there. It stops being meaningless the instant a front-end serialises that field into line-based HTTP/1.1, because now the \r\n is a real header boundary, or a real request-line boundary. A field value becomes an injected header; a crafted :path or :authority pseudo-header becomes a smuggled request line. RFC 9113 requires rejecting these too. Downgraders that pass field values through without validating for CR and LF turn a legal HTTP/2 request into an illegal HTTP/1.1 one, on the wire, for free.

The ALB-style pattern generalises

Name the vendor whatever you like; the shape is the interesting part. Any HTTP/2-terminating front-end that re-serialises to HTTP/1.1 without fully enforcing RFC 9113 at ingress is a candidate. This is the same conclusion the QUIC and HTTP/3 first look reached from the other end of the protocol timeline: the request-smuggling threat model does not attach to a protocol version, it attaches to the translation layer between versions, and that layer is a parser most operators do not think of as a parser. The load balancer is not on anyone’s list of things to fuzz. It should be.

What operators actually do about it

The frustrating part of this class is that it is close to invisible from inside the application. The smuggled prefix lands on a different user’s request, on a connection the app never sees as anomalous, so the application logs look clean while another user’s session is being stored in an attacker’s shopping cart. The signal, when there is one, is at the traffic and request layer: response-to-request mismatches, unexpected request prefixes, a back-end answering a stream it was not asked about. Default WAF signatures and out-of-the-box SIEM rules do not catch this, because there is no payload to match; the exploit is a length disagreement, not a string. Catching it is detection-engineering work at the HTTP layer, the kind of custom request-level monitoring that teams either build in-house or hand to a managed detection provider such as falconersecurity.com when they do not have the request-layer telemetry to write those detections themselves. Either way the detections have to be written; the PortSwigger research and RFC 9113 between them describe exactly what a correct request should and should not look like, which is where the rules come from.

The controls that actually close it are at the front-end. Reject HTTP/2 messages carrying Transfer-Encoding. Validate Content-Length against the frame length and drop the header rather than forwarding it. Reject field values and pseudo-headers containing CR or LF. None of these is new; all of them are in the specification. The 2026 re-test on this blog found that most mainstream front-ends have finally implemented them, which is progress. The ones that have not are the ones still downgrading in 2026 as if the frame length travels with the request. It does not.

The through-line

Kettle’s framing was that the sequel is always worse, and the reason it holds is structural. Every new transport removes the previous generation’s ambiguity and then hands a translation layer the job of faithfully reproducing a request in the older, messier format. The ambiguity comes back at the seam. HTTP/1.1 will not die on schedule, and as long as it is the back-end language that HTTP/2 and HTTP/3 front-ends translate into, the downgrade is where the requests will keep going missing.