HTTP/2 HPACK Bomb
June 5, 2026 · View on GitHub
CVE-2025-XXXX · Nginx HTTP/2 HPACK Memory Amplification Attack
A proof-of-concept exploit for a remotely exploitable Denial of Service vulnerability in Nginx HTTP/2 HPACK implementation. Three compounding weaknesses allow an unauthenticated attacker to exhaust an Nginx worker's memory against any HTTP/2 endpoint — including one that returns only 404 errors.
⚠️ FOR AUTHORIZED SECURITY TESTING ONLY
Vulnerability Overview
| Item | Detail |
|---|---|
| Affected | Nginx ≤ 1.29.7 with HTTP/2 enabled |
| Fixed | Nginx 1.29.8 (max_headers directive) |
| Impact | Denial of Service (memory exhaustion) |
| CVSS | High |
| Authentication | None required |
| Downloadable content | Not required — any endpoint works |
Root Cause
Three weaknesses compound into a devastating attack:
-
HPACK Indexed Reference Bomb (~70:1 amplification) — A single dynamic table entry referenced 32,000 times per request costs 1 byte per reference on the wire but allocates ~59 bytes of server pool memory. Each stream consumes ~2.2 MB from ~33 KB of wire data.
-
HTTP/2 Window Stall — By sending
SETTINGSwithINITIAL_WINDOW_SIZE=0, the attacker prevents the server from sending response DATA frames. Request pools are held while the response body remains unsent. Periodic 1-byteWINDOW_UPDATEdrips reset thesend_timeouttimer, holding memory indefinitely. -
Flood Detection Blindness — The HPACK bomb traffic consists entirely of valid HPACK-encoded request headers. The overhead ratio is 1.001:1, far below the 8:1 flood detection threshold. The attack is architecturally invisible to Nginx's built-in flood check.
Impact
| Connections | Upload (burst) | Server Memory | Sustained BW |
|---|---|---|---|
| 1 | 4 MB | 285 MB | 34 B/s |
| 5 | 20 MB | 1.4 GB | 170 B/s |
| 15 | 60 MB | 4.2 GB | 510 B/s |
| 50 | 196 MB | 14.0 GB | 1.7 KB/s |
50 connections sending 196 MB of HPACK bomb headers consumed ~14 GB of server memory in under 7 seconds.
Features
- Zero dependencies — Pure Python stdlib (
ssl,socket,struct) - Full HPACK bomb — 32,000 indexed references per stream (1 byte → 59 bytes)
- Window stall —
INITIAL_WINDOW_SIZE=0blocks response DATA frames - Memory hold — Periodic
WINDOW_UPDATEdrips keep memory parked indefinitely - Parallel connections — Multi-threaded TLS+H2 connection establishment
- Configurable — Streams, headers, hold time, drip interval all tunable
Quick Start
Prerequisites
- Python 3.8+
- Target with Nginx ≤ 1.29.7 and HTTP/2 enabled
Run
# Single connection demo (~280 MB server memory)
python3 hpack_bomb.py --host target.com --port 443 -n 1
# OOM a 4 GB worker
python3 hpack_bomb.py --host target.com --port 443 -n 15
# Extended hold (1 hour)
python3 hpack_bomb.py --host target.com --port 443 -n 15 --hold 3600
# With HTTP proxy
python3 hpack_bomb.py --host target.com --port 443 -n 15 --proxy http://127.0.0.1:2080
Docker Test Environment
Spin up a vulnerable Nginx container for testing:
# Build image (uses nginx:1.29.7)
./run.sh build
# Start container (8 GB memory limit, port 8443)
./run.sh start
# Run attack
./run.sh attack15
# Monitor memory usage
./run.sh monitor
# Stop container
./run.sh stop
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT | 8443 | Host port mapping |
MEMORY | 8g | Container memory limit |
Usage
usage: hpack_bomb.py [-h] [--host HOST] [--port PORT] [--proxy PROXY]
[-n CONNECTIONS] [--streams STREAMS] [--headers HEADERS]
[--hold HOLD] [--drip-interval DRIP_INTERVAL] [-v]
HPACK Bomb + HTTP/2 Window Stall — Memory Exhaustion PoC
options:
--host HOST Target host (default: 127.0.0.1)
--port PORT Target port (default: 443)
--proxy PROXY HTTP proxy (e.g. http://127.0.0.1:2080)
-n, --connections N Number of concurrent connections (default: 1)
--streams N Streams per connection (default: 128)
--headers N Headers per stream (default: 32000)
--hold N Hold time in seconds (default: 120)
--drip-interval N Seconds between WINDOW_UPDATE drips (default: 50)
-v, --verbose Verbose per-connection output
Parameters
| Parameter | Default | Description |
|---|---|---|
--host | 127.0.0.1 | Target hostname or IP |
--port | 443 | Target port |
--proxy | None | HTTP proxy URL (e.g. http://127.0.0.1:2080) |
-n | 1 | Number of concurrent TLS connections |
--streams | 128 | HTTP/2 streams per connection (max 128) |
--headers | 32000 | HPACK indexed references per stream |
--hold | 120 | Seconds to hold memory after attack |
--drip-interval | 50 | Seconds between WINDOW_UPDATE drips |
-v | false | Enable verbose output |
Memory Estimation
| Connections | Streams | Headers | Est. Memory | Upload | Ratio |
|---|---|---|---|---|---|
| 1 | 128 | 32000 | ~284 MB | ~4 MB | 70:1 |
| 5 | 128 | 32000 | ~1.4 GB | ~20 MB | 70:1 |
| 15 | 128 | 32000 | ~4.2 GB | ~60 MB | 70:1 |
| 50 | 128 | 32000 | ~14 GB | ~196 MB | 70:1 |
How It Works
Attack Flow
┌─────────────┐ ┌─────────────┐
│ Attacker │ │ Nginx │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. TLS + ALPN h2 │
│─────────────────────────────────────────────────>│
│ │
│ 2. SETTINGS: INITIAL_WINDOW_SIZE=0 │
│─────────────────────────────────────────────────>│
│ │
│ 3. HEADERS (×128 streams × 32000 refs each) │
│─────────────────────────────────────────────────>│
│ │ Allocates ~2.2 MB
│ │ per stream
│ │
│ 4. Server sends HEADERS (not flow-controlled) │
│<─────────────────────────────────────────────────│
│ │
│ DATA blocked (send_window = 0) │
│ │ Memory held!
│ │
│ 5. WINDOW_UPDATE +1 (every 50s) │
│─────────────────────────────────────────────────>│
│ │ Resets timeout
│ Server sends 1 byte of DATA │
│<─────────────────────────────────────────────────│
│ │
│ ... repeat indefinitely ... │
HPACK Bomb Mechanism
Each HTTP/2 request contains:
- 4 pseudo-headers via static table indexed references (4 bytes)
- 1 literal-with-indexing insert: name
"a", value""(4 bytes) - ~32,000 indexed references to dynamic entry 62 (`0xBE$ \times 32{,}000)
\text{Each} \text{indexed} \text{reference} \text{costs}:
- 1 \text{byte} \text{on} \text{the} \text{wire}
- ~59 \text{bytes} \text{of} \text{server} \text{memory} (3 \text{bytes} $state.pool
+ 56 bytesngx_table_elt_t`)
This yields a ~70:1 memory amplification ratio.
Window Stall Mechanism
- Client sends
SETTINGS_INITIAL_WINDOW_SIZE=0 - Server generates response → HEADERS sent immediately (not flow-controlled)
- DATA frame blocked →
send_window = 0 - Body buffered →
send_timeouttimer starts (default 60s) - Client sends 1-byte
WINDOW_UPDATEevery ~50s - Server sends 1 byte of response → timer resets
- Repeat → memory held indefinitely
Detection
Suricata / Snort Rules
# Detect HPACK Bomb - High HEADERS count
alert http2 any any -> any any (
msg:"HTTP/2 HPACK Bomb Detected";
http2.type == 1;
threshold:type both, track by_src, count 100, seconds 10;
sid:1000001; rev:1;
)
# Detect INITIAL_WINDOW_SIZE=0
alert http2 any any -> any any (
msg:"HTTP/2 Window Stall - INITIAL_WINDOW_SIZE=0";
http2.type == 4;
http2.settings.initial_window_size == 0;
sid:1000002; rev:1;
)
Traffic Analysis
# Capture traffic
tcpdump -i eth0 -w capture.pcap "tcp port 443"
# Analyze with tshark
tshark -r capture.pcap -Y "http2.type == 1" # HEADERS frames
tshark -r capture.pcap -Y "http2.type == 4" # SETTINGS frames
tshark -r capture.pcap -Y "http2.type == 8" # WINDOW_UPDATE frames
Mitigation
Upgrade
# Upgrade to nginx 1.29.8 or later
# which adds the max_headers directive
Nginx Configuration
# Limit headers per request (nginx 1.29.8+)
http2_max_headers 100;
# Or disable HTTP/2 entirely if not needed
# listen 443 ssl; # Remove http2 flag
Recommended Code Fixes
- Count-based header limit — Add explicit limit on header count per request
- Decoded-to-wire ratio limit — Reject requests where HPACK expansion exceeds threshold
- Minimum INITIAL_WINDOW_SIZE — Enforce minimum window size (e.g., 1024 bytes)
File Structure
.
├── hpack_bomb.py # Main attack PoC
├── run.sh # Docker test environment manager
├── Dockerfile # Vulnerable nginx:1.29.7 image
├── nginx.conf # Default nginx config (no hardening)
├── monitor_rss.py # RSS memory monitor (Python)
├── monitor_rss.sh # RSS memory monitor (Bash)
├── analyze_pcap.sh # PCAP analysis script
├── suricata_rules.rules # IDS detection rules
└── README.md # This file
Technical Details
Affected Code Path
// ngx_http_v2_table.c — allocates fresh copy per reference
p = ngx_pnalloc(h2c->state.pool, entry->name.len + 1);
h2c->state.header.name.data = p;
p = ngx_pnalloc(h2c->state.pool, entry->value.len + 1);
h2c->state.header.value.data = p;
// ngx_http_v2.c — no count limit, only byte limit
h = ngx_list_push(&r->headers_in.headers); // 56 bytes per header
Flood Detection Bypass
// Nginx flood check
if (h2c->total_bytes / 8 > h2c->payload_bytes + 1048576) {
// "http2 flood detected"
}
// Attack: total_bytes ≈ payload_bytes (ratio 1.001:1)
// Check: 4,200,000 / 8 > 4,194,304 + 1,048,576?
// 525,000 > 5,242,880? → NO → passes
glibc Allocator Retention
When nginx destroys request pools via free(), glibc's ptmalloc retains the memory in arena free lists rather than returning it to the OS. This means:
- A single burst permanently degrades the worker even without maintaining connections
- Only worker restart reclaims the memory
nginx -s reloaddoes NOT help (same worker PIDs)
References
- RFC 7541 — HPACK: Header Compression for HTTP/2
- RFC 7540 §6.5.2 — SETTINGS_INITIAL_WINDOW_SIZE
- RFC 7540 §6.9 — Flow Control
- Nginx Security Advisory — nginx 1.29.8 release
Disclaimer
This tool is provided for authorized security testing and educational purposes only. Unauthorized use of this tool against systems you do not own or have explicit permission to test is illegal and unethical. The authors are not responsible for any misuse or damage caused by this tool.
Always obtain proper authorization before conducting security testing.
License
This project is provided as-is for security research purposes.