The Same Pattern at Every Layer
HTTP request smuggling was well-documented against HTTP/1.1 proxies in 2019. The canonical forms — CL.TE (front-end trusts Content-Length, back-end trusts Transfer-Encoding) and TE.CL (reversed) — have since been patched out of most major reverse proxies. The HTTP/2 literature is thinner. The HTTP/3 and WebTransport literature is almost entirely absent.
The reason the literature is thin is not that the attack class does not apply. It is that few production proxies speak HTTP/3 and WebTransport in the same service, and fewer still test all five surfaces simultaneously. When a proxy supports HTTP/2 on port 443 and WebTransport on port 4433 UDP and HTTP/1.1 upgrade paths for legacy clients, all three attack surfaces coexist. The test suite either covers all of them or it covers none of them in any meaningful sense.
The same failure mode appears in the PDF domain: six parsers reading the same file and three reporting JavaScript present while two report absent. HTTP smuggling is that failure mode at the transport layer. One parser at the boundary, another at the origin — and the attacker controls which one sees the malicious request.
Five Surfaces, One Test Script
Script 07 of the pqcrypta-proxy pentest suite covers HTTP request smuggling across seven distinct sub-tests in a single run:
# 07 - HTTP Request Smuggling # Full coverage: HTTP/1.1 (nc/port 80), HTTP/2 (TLS), HTTP/3 (QUIC), WebTransport # CL.TE / TE.CL / TE.TE obfuscation — proxy-specific immunity documented per RFC
“Proxy-specific immunity documented per RFC” is load-bearing. RFC compliance does not mean immunity. It means the RFC defines the expected behavior; the test verifies it is actually enforced. These are different claims.
Surface 1 — HTTP/1.1 Plain (Port 80, TCP)
The test delivers CL.TE via netcat directly to port 80:
printf 'POST / HTTP/1.1\r\nHost: %s\r\nContent-Length: 13\r\n Transfer-Encoding: chunked\r\n\r\n0\r\n\r\nSMUGGLED' "$HOST" | nc -w 5 "$HOST" 80
Expected result: TCP refused or 301/302/308 redirect. The proxy is HTTPS-only. Port 80 either drops the connection or redirects without parsing the body. No plain HTTP surface — no CL.TE surface at the transport layer.
Deploying an HTTP listener on port 80 to “support legacy clients” while routing real traffic through HTTPS creates a CL.TE surface that does not exist on the HTTPS path. The test documents that port 80 is dead or redirect-only, which eliminates this surface by construction.
Surface 2 — HTTP/1.1 over TLS (Port 443, Forced)
Eliminating port 80 does not eliminate HTTP/1.1 as a protocol. Many proxies accept HTTP/1.1 negotiated over TLS on port 443 for backward compatibility. If the front-end accepts HTTP/1.1-over-TLS and forwards to an HTTP/1.1 back-end, the CL.TE surface is intact — it just runs over TLS now.
The test uses --http1.1 to force HTTP/1.1 negotiation over the TLS
connection, bypassing ALPN:
curl -sk --http1.1 -X POST "$TARGET/" \ -H 'Content-Length: 13' \ -H 'Transfer-Encoding: chunked' \ --data-raw $'0\r\n\r\nSMUGGLED'
Critical detail: curl in HTTP/2 mode strips the Transfer-Encoding header
before sending. If you run this test without --http1.1, curl
silently removes TE before the request reaches the wire. You are not testing what you
think you are testing. The flag is not optional.
Surface 3 — HTTP/2 over TLS — RFC 9113 §8.2.2
RFC 9113 §8.2.2 is unambiguous:
Transfer-Encoding MUST NOT be used in HTTP/2. A server MUST treat the receipt of a request containing Transfer-Encoding as a stream error of type PROTOCOL_ERROR.
The structural prohibition exists because HTTP/2 frames each request as a length-prefixed binary unit. The concept of chunked transfer — a streaming encoding for HTTP/1.1 connections where the server does not know body length in advance — has no role in a framed protocol. Its presence is either a mistake or an attack.
The test sends CL.TE over HTTP/2. Expected responses: 400, 403, or 422. A 200 means the server silently ignored the malformed TE header — which means a more cleverly constructed header may reach the back-end in an unexpected form.
Surface 4 — TE.TE Obfuscation (Five Variants)
TE.TE obfuscation describes attacks where both the front-end and back-end parse Transfer-Encoding, but one accepts a non-canonical form that the other rejects. All five variants should produce identical rejections on a correctly implemented HTTP/2 server. Any response code difference between variants is a finding.
| Variant | Header sent | What it exploits |
|---|---|---|
| xchunked | Transfer-Encoding: xchunked |
Some parsers treat any TE value beginning with “chunked” as chunked |
| List form | Transfer-Encoding: chunked, identity |
Comma-separated TE; front-end may parse only the first token |
| JSON form | Transfer-Encoding: ["chunked"] |
Parsers expecting plain strings may skip validation of JSON-encoded values |
| Double header | Transfer-Encoding: chunked twice |
RFC requires duplicate TE to be treated as PROTOCOL_ERROR; some implementations do not |
| Tab-separated | Transfer-Encoding:<tab>chunked |
RFC permits LWS after the colon; not all validators normalize tabs to spaces |
Surface 5 — HTTP/3 / QUIC (Port 443, UDP)
RFC 9114 §4.2 repeats the HTTP/2 prohibition for HTTP/3:
HTTP/3 does not use the Transfer-Encoding header field. Endpoints MUST NOT generate HTTP/3 frames that contain Transfer-Encoding; clients MUST NOT send it.
The test uses two detection paths. If curl has HTTP/3 support (--http3),
it delivers CL.TE directly over QUIC and expects a rejection per RFC 9114. If curl
lacks HTTP/3 support, the test falls back to Alt-Svc discovery:
ALT_SVC=$(curl -sk --http2 -I -D - --max-time 10 "$TARGET/" | \ grep -i 'alt-svc:' | head -1) echo "$ALT_SVC" | grep -qi 'h3'
Alt-Svc discovery matters because a server advertising HTTP/3 support that is not being tested over QUIC is advertising an untested attack surface.
Surface 6 — WebTransport (Port 4433, UDP)
WebTransport runs over QUIC on a dedicated port. The test confirms isolation:
# TCP probe — QUIC is UDP; expect "closed"
timeout 4 bash -c "echo >/dev/tcp/$HOST/4433" 2>&1 && echo "open" || echo "closed"
# HTTPS over TCP to the QUIC port — expect 000
curl -sk --max-time 5 -o /dev/null -w '%{http_code}' "https://$HOST:4433/speedtest"
The expected curl return code is 000: no TCP connection established.
A non-zero code here means something is accepting TCP on the QUIC port — a
misconfiguration worth investigating.
A scanner that probes https://target:4433 over TCP, gets a connection
refused, and marks the port as “no HTTP service” has not tested the
WebTransport surface. It has only confirmed there is no TCP listener. Absent from the
output and not present are not the same condition.
Surface 7 — CVE-2023-44487 / HTTP/2 Rapid Reset
The Rapid Reset attack is an HTTP/2 connection-level attack, not a stream-level TE desync. An attacker opens many streams and immediately sends RST_STREAM on each, flooding the server’s connection handling without completing any request. This is categorically different from the Transfer-Encoding attacks above — different primitive, different mitigation, different test.
The test sends ten rapid HTTP/2 requests and confirms all return expected response codes (200, 403, or 429). Ten requests is not enough to trigger a real Rapid Reset condition; it is enough to detect a server that is already not handling HTTP/2 lifecycle correctly.
A Representative Smuggling Chain
The following is a constructed scenario built from the attack mechanics in Script 07. It is not a named incident. It is the simplest chain that combines the H2-to-H1.1 translation gap with the TE.TE double-header variant to produce a smuggled request that poisons a subsequent legitimate client.
Setup. A CDN terminates HTTP/2 from the public internet and forwards to an origin over HTTP/1.1, reusing a persistent keep-alive connection. The CDN does not strictly enforce the Transfer-Encoding prohibition — it passes malformed TE headers through to the origin rather than rejecting the stream. The origin interprets a chunked TE header as authoritative over Content-Length.
Step 1 — Attacker sends HTTP/2 POST with double Transfer-Encoding:
:method POST
:path /api/data
content-length: 6
transfer-encoding: chunked ← first occurrence
transfer-encoding: identity ← second (TE.TE double-header variant)
0\r\n\r\n ← zero-length chunk; CL says 6 bytes, chunked sees end
Step 2 — CDN translates H2→H1.1, keeps one TE header (implementation-defined which):
POST /api/data HTTP/1.1
Content-Length: 6
Transfer-Encoding: chunked ← forwarded; origin trusts TE over CL
0\r\n\r\n ← origin sees end of chunked body after 5 bytes
1 byte buffered on the backend keep-alive connection
Step 3 — Next client request arrives on the same backend connection:
GET /user/profile HTTP/1.1
Authorization: Bearer eyJ...
The 1 buffered byte prepends to this request, corrupting the method line.
The Authorization header from the legitimate client is now readable by the
attacker if the origin reflects it in an error response.
The critical enabler is keep-alive connection reuse. If the CDN opened a new TCP connection per client request, the smuggled suffix would have nowhere to go. The efficiency optimization is the attack primitive.
Script 09 (cache poisoning) tests a related amplifier: X-Custom-IP-Authorization:
127.0.0.1 as an unkeyed header. If the origin trusts this header for access
control and the CDN forwards it without stripping, a smuggled request can inject it into
the poisoned prefix — and the next client’s request arrives at the origin
appearing to come from localhost. Smuggling combined with an unkeyed trusted header
collapses boundary smuggling and authentication bypass into a single payload.
Why CDNs Fail Here
CDNs terminate TLS, parse HTTP/2 or HTTP/3 frames, and forward to origins over a connection pool they maintain independently of any individual client. Three structural gaps enable the smuggling attack class.
Protocol translation at the edge. A CDN that accepts HTTP/2 from clients and forwards to HTTP/1.1 origins performs protocol translation in both directions. The framing model changes entirely: H2 uses binary length-prefixed frames; H1.1 uses header-terminated chunks or content-length to delimit the body. Any ambiguity in the H2 request — a prohibited TE header forwarded instead of rejected, a malformed content-length, a trailer injection — must be resolved at translation time. How a given CDN resolves it is implementation-specific and not always documented.
Connection pooling to the origin. The CDN maintains persistent HTTP/1.1 connections to the origin. Multiple client streams are multiplexed over the H2 front-end and serialized onto these pooled backend connections. A request that leaves residue on a backend connection contaminates the next request from whatever client happens to be assigned to that connection. The attacker does not need to know which client is next — only that one will be.
Unkeyed header forwarding. Script 09 tests headers that CDNs
commonly forward and origins commonly trust: X-Forwarded-Host,
X-Forwarded-Proto, X-Original-URL,
X-Custom-IP-Authorization. If any of these are forwarded without stripping
client-supplied values, a smuggled prefix can inject them into the next
client’s request. The test checks whether the injected value is reflected in the
response body — reflection is the minimum signal; actual access-control trust
of the header is the worst-case outcome.
CDN vendors claim RFC 9113 §8.2.2 compliance universally. RFC compliance for the CDN’s own H2 parser is not the same as enforcement at the H2→H1.1 translation boundary, which is where the attack lives. The test that matters is Script 07’s TE.TE obfuscation suite against the translated origin request, not against the CDN’s H2 parser directly.
Why QUIC Middleboxes Are Blind
Traditional network security appliances — IDS, IPS, DPI, inline WAF —
operate by inspecting TCP streams. They reassemble byte sequences, apply signatures to
content, and pass or block. This model works against HTTP/1.1 smuggling because CL.TE
and TE.CL attacks are visible in the TCP byte stream. An IDS that sees
Transfer-Encoding: chunked alongside Content-Length can flag
it. The signature is trivially writable.
QUIC eliminates that inspection surface entirely. QUIC encrypts not only the payload but nearly the entire transport header. After the first byte of the packet, everything is encrypted under the QUIC packet protection key. A network device without the session key sees opaque UDP datagrams. It knows source and destination ports and that the traffic is UDP. It knows nothing about the HTTP/3 frames inside.
IDS signatures for HTTP request smuggling — including signatures for the five TE obfuscation variants in Script 07 — are byte-pattern rules applied to reassembled TCP segments. None of them function on QUIC traffic because there are no reassembled TCP segments to inspect. A WAF advertised as blocking HTTP request smuggling does not block it on QUIC. The WAF is simply not on the path.
The fallback behavior compounds this. Middleboxes that cannot inspect QUIC often block UDP/443 entirely. When a client’s QUIC handshake is blocked, the client falls back to HTTP/2 over TLS on TCP — correct per Alt-Svc specification, and exactly what Script 07’s Alt-Svc detection path documents. The attacker can force this downgrade deliberately by disrupting UDP/443, then attack the TCP path where the middlebox’s own signatures apply — but the QUIC-level prohibitions no longer do. The middlebox that blocked QUIC has moved the attack surface, not reduced it.
The correct posture is to test both paths: the QUIC path with a QUIC-capable client,
and the TCP fallback with --http2 over TLS. Script 07 tests both because
both exist on any server advertising HTTP/3 via Alt-Svc.
Why WebTransport Is the New Frontier
WebSocket (RFC 6455) has been the subject of serious security research for over
a decade. Cross-site WebSocket hijacking, WebSocket smuggling via HTTP upgrade
confusion, and token leakage via query-string negotiation are all documented and tested
in Script 13 — cross-origin upgrade from evil.com, unauthenticated
upgrade, invalid token, token in query string, upgrade against /admin/, and
the RFC 8441 HTTP/2 WebSocket variant. The research exists because WebSocket is
widely deployed and has been attackable long enough to accumulate a documented body of
cases.
WebTransport (RFC 9297, W3C Level 1) is in a different position. It is HTTP/3-native, runs over QUIC streams and datagrams, and is deployed on a separate port (4433 UDP in pqcrypta-proxy). The published security research on WebTransport-specific attack techniques is sparse. The attack surface is not sparse — WebTransport supports multiple bidirectional streams per session and unreliable datagrams, giving an attacker more degrees of freedom than a WebSocket connection — but the research has not caught up to the deployment. Most WAF vendors have no WebTransport-specific signatures. Most pentest tooling has no WebTransport-aware clients. Most security teams have not added WebTransport to their test matrix because it is not in their vulnerability scanner’s default profile.
Script 13 confirms the port isolation: https://host:4433 over TCP
returns 000. That result, which registers as a failure in a naive port
scan, is the correct behavior. But it means every scanner that probes TCP ports and
tests HTTP over TCP has silently skipped the WebTransport surface and produced a result
indistinguishable from “nothing there.” Absent from the output and not
present are not the same condition, and the difference is not visible in the scan
report.
The Adjacent Surface: Header Injection and IP Spoofing
Request smuggling is one class of parser disagreement attack at the HTTP layer. A related class is header injection — where a forwarded header is trusted by an upstream system in a way the sender did not intend.
Script 03 sends nine commonly-trusted forwarding headers with loopback or RFC 1918 addresses:
X-Forwarded-For: 127.0.0.1 X-Real-IP: 127.0.0.1 X-Originating-IP: 127.0.0.1 True-Client-IP: 127.0.0.1 CF-Connecting-IP: 127.0.0.1 X-Forwarded-For: ::1
If any of these cause the server to grant elevated access, the response code diverges
from the baseline. IPv6 variants extend the same test to formats ASCII-based filters
frequently miss: [::1], ::ffff:127.0.0.1,
0000:0000:0000:0000:0000:0000:0000:0001, and
[::ffff:127.0.0.1] in both XFF and Host header positions. A filter that
blocks 127.0.0.1 in XFF but accepts ::ffff:127.0.0.1 has a
documented bypass.
The Unifying Principle
Every attack in this article follows the same structural pattern:
- Two parsers read the same bytes. Front-end and back-end, or scanner and renderer, or proxy and origin — the roles vary, but there are always exactly two.
- The bytes are ambiguous under at least one parser’s rules. CL.TE: body length defined twice, definitions conflict. TE.TE obfuscation: the header value is non-canonical and parsers normalize it differently. IP spoofing: the header is structurally valid but semantically trusted by one side and distrusted by the other.
- The attacker controls which parser sees which interpretation. The attack is not about breaking a parser. It is about making two correct parsers disagree on the same input.
This is why the test suite covers all five protocol surfaces in a single script. A proxy that blocks HTTP/1.1 CL.TE but accepts an HTTP/2 TE.TE obfuscation has not solved the problem. It has moved it. Testing one surface while advertising support for five is not security testing — it is auditing a fraction of the attack surface and calling it done.
What pqcrypta-proxy Does About It
The design decisions that address this attack surface are structural, not configuration:
| Design decision | Surface eliminated |
|---|---|
| HTTP/2+TLS only; port 80 redirects | Plain HTTP CL.TE and TE.CL |
| TLS 1.2 minimum; TLS 1.0/1.1 rejected at handshake | Legacy cipher negotiation attacks |
| RFC 9113 §8.2.2 enforcement: TE header returns STREAM_ERROR | HTTP/2 CL.TE, TE.TE obfuscation variants |
| RFC 9114 §4.2 enforcement on QUIC endpoint | HTTP/3 Transfer-Encoding injection |
| WebTransport on isolated UDP port 4433; no TCP listener | TCP-based smuggling into WebTransport surface |
| Untrusted forwarding headers; XFF not used for access control | IP spoofing via X-Forwarded-For and related headers |
| HTTP/2 connection rate limiting; RST_STREAM handling | Rapid Reset DoS surface (CVE-2023-44487) |
The test suite exists because claiming RFC compliance is not the same as enforcing it. The proxy documents its expected behavior per RFC, then runs 32 automated attack scripts across 12 phases to verify that the documented behavior is what the server actually produces. Script 07 is one of 32. It runs on every deploy.
How to Run the Test on Your Own Proxy
If you operate a proxy that speaks more than one HTTP version, the minimum test coverage for this attack class:
- Confirm port 80 accepts no plain HTTP (nc probe, no body required).
- Force HTTP/1.1 over TLS with
--http1.1and deliver CL.TE and TE.CL payloads. Confirm the response indicates rejection, not success. - Send all five TE.TE obfuscation variants over HTTP/2. Confirm identical response codes. Divergence between variants is a finding.
- Check Alt-Svc header for
h3. If present, test HTTP/3 CL.TE with a QUIC-capable client. Expect rejection per RFC 9114. - TCP-probe your WebTransport port. Expect closed. TCP open on a UDP service port is a misconfiguration.
- Send XFF with loopback and RFC 1918 values. Confirm identical response to a baseline request. Any difference is a potential access-control bypass.
None of these require specialized tooling beyond curl, nc, and a QUIC-capable client.
The complexity is in knowing which protocol surface each test actually reaches —
particularly the --http1.1 requirement on the TLS surface, and the TCP/UDP
distinction on the WebTransport port.
Related Research
The same parser-disagreement pattern in the PDF document format is documented separately at PQ PDF — where the scanner’s parser and the viewer’s parser report different structure for the same file:
PDF Parser Disagreement: Six Parsers, Eleven Lies →
The canonical example from that study is a 772-byte PDF
with two complete document structures. Five parsers load the second document (correct per
spec: read from the last %%EOF). pdf.js loads the first (incorrect: scans
for the first /Count value in file order). Same file. Same machine.
Different page count. The HTTP/2 TE.TE double-header variant is the same attack: two
parsers, same bytes, different interpretation, attacker wins the difference.
For the full pqcrypta-proxy architecture including HTTP/3, WebTransport, and hybrid post-quantum TLS: