// HackTricks · Web Pentesting

Request Smuggling in HTTP/2 Downgrades

Request Smuggling in HTTP/2 Downgrades

HTTP/2 is generally considered immune to classic request-smuggling because the length of each DATA frame is explicit. That protection disappears as soon as a front-end proxy “downgrades” the request to HTTP/1.x before forwarding it to a back-end. The moment two different parsers (the HTTP/2 front-end and the HTTP/1 back-end) try to agree on where one request ends and the next begins, all the old desync tricks come back – plus a few HTTP/2-only injection gadgets.[1]

Recent desync research reached the same conclusion from the defensive side: HTTP/2 at the edge does not save you if the proxy still speaks HTTP/1.1 upstream. The downgrade boundary is the attack surface.[2]


Why downgrades happen

  1. Browsers already speak HTTP/2, but much legacy origin infrastructure still only understands HTTP/1.1.
  2. Reverse-proxies (CDNs, WAFs, load-balancers) therefore terminate TLS + HTTP/2 at the edge and rewrite every request as HTTP/1.1 for the origin.
  3. That translation layer has to emit a single, valid HTTP/1.1 body delimiter for the origin.
  4. RFC 9113 is stricter than many downgrade implementations:
    • connection-specific headers such as transfer-encoding, upgrade, keep-alive, or proxy-connection make the HTTP/2 message malformed;
    • te is only valid as te: trailers;
    • if content-length is present, it must match the sum of the DATA frame payload lengths.

Whenever the front-end trusts the HTTP/2 frame length but the back-end trusts CL or TE, an attacker can force them to disagree.


Two dominant primitive classes

VariantFront-end lengthBack-end lengthTypical payload
H2.TEHTTP/2 frameTransfer-Encoding: chunkedEmbed an extra chunked message body whose final 0\r\n\r\n is not sent, so the back-end waits for the attacker-supplied “next” request.
H2.CLHTTP/2 frameContent-LengthSend a smaller CL than the real body, or inject content-length: 0 during downgrade, so the back-end reads past the boundary into the following request.

These are identical in spirit to classic TE.CL / CL.TE, just with HTTP/2 replacing one of the parsers.


Identifying a downgrade chain

  1. Use ALPN in a TLS handshake (openssl s_client -alpn h2 -connect host:443) or curl:
    curl -v --http2 https://target
    If * Using HTTP2 appears, the edge speaks H2.
  2. If the site looks HTTP/1.1-only, probe for hidden HTTP/2 anyway:
    curl --http2-prior-knowledge https://target
    Some stacks support H2 but forget to advertise it via ALPN, which means you would otherwise miss downgrade-only bugs.
  3. Send a deliberately malformed CL/TE request over HTTP/2 (Burp Repeater can force HTTP/2). If the response is an HTTP/1.1-style error such as 400 Bad chunk or a back-end-specific parse failure, you have strong evidence that the edge converted the traffic for an HTTP/1 parser downstream.

Exploitation workflow (H2.TE example)

:method: POST
:path: /login
:scheme: https
:authority: example.com
content-length: 13      # ignored by the edge
transfer-encoding: chunked

5;ext=1\r\nHELLO\r\n
0\r\n\r\nGET /admin HTTP/1.1\r\nHost: internal\r\nX: X
  1. The front-end reads exactly 13 bytes (HELLO\r\n0\r\n\r\nGE), thinks the request is finished and forwards that much to the origin.
  2. The back-end trusts the TE header, keeps reading until it sees the second 0\r\n\r\n, thereby consuming the prefix of the attacker’s second request (GET /admin …).
  3. The remainder (GET /admin …) is treated as a new request queued behind the victim’s.

Replace the smuggled request with:

  • POST /api/logout to force session fixation
  • GET /users/1234 to steal a victim-specific resource

If you can only poison requests on your own client-mapped upstream connection, the bug can still be useful for cache poisoning, internal-header leaks, or front-end control bypass. For those reuse-locked cases, pivot to HTTP Connection Request Smuggling.


Modern downgrade-only injection gadgets

Many modern front-ends already strip a literal transfer-encoding: chunked header. Recent findings often work by manufacturing dangerous HTTP/1.1 bytes during the downgrade itself rather than sending them as a normal header.[1]

CRLF / LF injection in header values

HTTP/2 field values are binary. If the downgrade code fails to sanitize \r\n — or even a bare \n — you can often synthesize a backend-only header:

:method: POST
:path: /
:authority: example.com
foo: bar\r\ntransfer-encoding: chunked

0

GET /admin HTTP/1.1
Host: example.com

The edge sees a legal-ish HTTP/2 header block; the back-end receives a fresh Transfer-Encoding header and starts parsing the body as chunked. The same trick can be used to inject content-length: 0, terminate the header section early, or split one downgraded request into two.

Header-name injection

Some vendors fixed newline injection in values but forgot to validate names. If the downgrade code accepts spaces, colons, or other non-HTTP/1.1-safe bytes in a header name, the generated HTTP/1.1 request can contain multiple backend-visible header lines. This is a common way to re-introduce transfer-encoding after a partial hotfix.

Pseudo-header / request-line injection

A buggy mapper that copies :method, :path, :authority, or :scheme into the HTTP/1.1 request line without strict validation can let you:

  • inject a full secondary request line,
  • supply an ambiguous host or path,
  • prepend a URL prefix that changes routing, cache keys, or SSRF targets.

If a literal TE header is blocked but pseudo-headers are not normalized before downgrade, this is the next place to look.


This is not a CL/TE downgrade bug, but during the same assessment you should also test whether the edge forwards the HTTP/1.1 Upgrade: h2c header to a back-end that supports clear-text HTTP/2. If it does, you may be able to tunnel raw HTTP/2 frames through an edge that only validated the initial HTTP/1.1 request.

Key requirements:

  • Edge forwards both Connection: Upgrade and Upgrade: h2c unchanged.
  • Origin upgrades to HTTP/2 and keeps the connection-reuse semantics that enable request queueing or direct internal access.

For proxy-specific quirks and tunnel-focused payloads, see Upgrade Header Smuggling.


Notable real-world examples

  • 2025 desync research – large shared edge providers were still exploitable because the dangerous trust boundary remained upstream HTTP/1.1, not the client-facing HTTP/2 session.[2]
  • CVE-2023-25690 – Apache HTTP Server mod_proxy rewrite rules could be chained into request splitting and smuggling when rewritten bytes were forwarded downstream. (fixed in 2.4.56)
  • CVE-2023-25950 – HAProxy 2.7.0 and 2.6.1-2.6.7 had an HTTP request/response smuggling issue in HTX handling that could alter a legitimate user’s request.
  • CVE-2022-41721 – Go MaxBytesHandler left unread body bytes that could later be interpreted as HTTP/2 frames, showing how “leftover bytes become a new protocol message” is not limited to classic H1 desync.

Tooling

  • Burp Request Smuggler – since v3.0 (2025) it includes parser-discrepancy detection plus HTTP/2 tunnelling / header-injection probes. Enable HTTP/2 probing and ALPN override when hunting hidden H2 support.
  • http2smugl – purpose-built H2→H1 detector and raw requester. The request subcommand accepts escaped bytes such as \r, \n, and \x3a, which is handy when normal clients refuse malformed headers:
    go install github.com/neex/http2smugl@latest
    http2smugl detect https://target
  • SmuggleFuzz – fast downgrade scanner with customizable gadget lists and an explicit confirm mode:
    go install github.com/moopinger/smugglefuzz@latest
    smugglefuzz scan -u https://target --confirm
  • h2cSmuggler – Python PoC by Bishop Fox to automate the clear-text upgrade attack:
    python3 h2csmuggler.py -u https://target -x 'GET /admin HTTP/1.1\r\nHost: target\r\n\r\n'
  • curl / hyper – useful for quick ALPN checks and for replaying handcrafted HTTP/2 payloads: curl --http2-prior-knowledge -X POST --data-binary @payload.raw https://target

Defensive measures

  1. Use upstream HTTP/2 end-to-end whenever possible – removing the H2→H1 translation step is the cleanest fix.
  2. Enforce RFC 9113 on ingress – reject HTTP/2 requests carrying connection-specific headers; only allow te: trailers; reject mismatched content-length values.
  3. Strip and regenerate body-length metadata on downgrade – never forward attacker-supplied Content-Length / Transfer-Encoding into the downgraded request.
  4. Normalize before mapping to HTTP/1.1 – reject or canonicalize CR, LF, colon, obs-fold, and non-ASCII bytes in header names, header values, and pseudo-headers before routing / rewrite logic.
  5. Reduce or isolate upstream connection reuse – if you are stuck on upstream HTTP/1.1, limiting shared back-end connections sharply reduces queue-poisoning impact.
  6. Strip Upgrade unless it is explicitly required for WebSocket – prevents h2c tunnelling.

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.