Tech 17 min read

HTTP/2 Bomb: 5,700x Envoy, 4,000x Apache amplification via HPACK + flow control

IkesanContents

TL;DR

What’s affected nginx, Apache httpd, Envoy, IIS, and Pingora terminating HTTP/2. A single 100Mbps link can exhaust 32GB of server memory in tens of seconds

What to do nginx 1.29.8+ (max_headers added), mod_h2 v2.0.41 (CVE-2026-49975), Envoy 1.35.11 / 1.36.7 / 1.37.3 / 1.38.1 (CVE-2026-47774)

Until then Disable HTTP/2 (Protocols http/1.1 etc.), cap header field count at the front proxy, set memory limits on workers and containers


Calif published the HTTP/2 Bomb PoC and test repository.
A single client combines HPACK compression and flow control stall to hold 32GB+ of memory on Apache httpd or Envoy for tens of seconds.

The targets listed are nginx, Apache httpd, Microsoft IIS, Envoy, and Cloudflare Pingora.
As of June 4, 2026 the patch status varies. nginx added max_headers in 1.29.8. Apache’s mod_h2 v2.0.41 fixed cookie accounting under LimitRequestFields. Envoy published GHSA-22m2-hvr2-xqc8 on June 3, with fixes in 1.35.11 / 1.36.7 / 1.37.3 / 1.38.1.

nginx had a separate RCE-class CVE series in May around rewrite handling.
That was a heap corruption triggered by specific rewrite patterns, while this one targets memory retention in HTTP/2 termination itself.
Even for the same nginx, what you check shifts from rewrite directives to HTTP/2 header counts, cookie handling, and termination points.

Not about 1 byte decoding into a giant header value

HPACK uses a dynamic table to compress HTTP/2 headers.
The classic HPACK Bomb inserts a large value into the dynamic table and then references it repeatedly via a small index.
Most implementations now cap decoded header size to counter this.

HTTP/2 Bomb works in the opposite direction: the header values themselves are small.
According to Calif, what grows is not the decoded string size but the per-header management memory the server allocates around each header entry.
Because the values are nearly empty, decoded-size limits don’t trigger.

In Apache httpd and Envoy, cookie header handling becomes the bypass.
HTTP/2 allows cookies to be split across multiple small header fields.
If the server counts regular headers but doesn’t include cookie crumbs in the same count, the header-count limit is evaded.

flowchart TD
    A[Attacker's HTTP/2 connection] --> B[Send mass short HPACK index references]
    B --> C[Large number of cookie crumbs<br/>or tiny headers decoded]
    C --> D[Server allocates per-header<br/>management memory]
    D --> E[Set flow control window to 0<br/>to stall responses]
    E --> F[Streams never complete,<br/>memory never freed]

The second half of the attack uses flow control.
When the client advertises a 0-byte receive window, the server cannot flush responses.
Streams don’t complete, so memory allocated during request processing stays pinned.
Holding a server at near-OOM or in heavy swap is worse for co-hosted traffic than crashing it outright.

Patch status varies by product

Calif’s primary article was last updated June 3, noting the Envoy fix.
SecurityOnline’s article lists IIS, Envoy, and Pingora as unpatched, but Envoy’s GitHub advisory appeared afterward.
Relying solely on secondary sources leads to stale conclusions here.

ProductStatus as of June 4, 2026
nginx1.29.8 added max_headers. Default 1000
Apache httpdmod_h2 v2.0.41 fixed cookie accounting. CVE-2026-49975
EnvoyCVE-2026-47774 / GHSA-22m2-hvr2-xqc8 (CVSS 7.5). Fixes in 1.35.11 / 1.36.7 / 1.37.3 / 1.38.1
Microsoft IISNo fix confirmed per Calif. Disable HTTP/2 or limit headers at the front proxy
Cloudflare PingoraNo fix confirmed per Calif. Limit or disable HTTP/2 at the front layer

nginx’s max_headers caps the number of request header fields directly.
The existing client_header_buffer_size and large_client_header_buffers cap total size, which is a different axis from masses of nearly-empty headers.

Apache’s v2.0.41 ensures cookie merge operations can’t dodge LimitRequestFields.
However, Calif notes that lowering LimitRequestFieldSize alone doesn’t fully block the attack—an attacker can scale up via more streams and connections.
If you can’t update yet, Protocols http/1.1 removes the HTTP/2 attack surface entirely.

Envoy’s advisory describes the downstream HTTP/2 request path allowing unauthenticated remote clients to trigger excessive memory consumption.
Root causes: cookie fragment size accounting, HPACK header block size limits, and flow-control-extended stream lifetimes.
The advisory states no complete workaround is known other than applying the fix—upgrade if you’re on an affected branch.

Check termination points behind CDNs and reverse proxies

If a public site sits behind a CDN, the HTTP/2 termination point facing the attacker is the CDN or front proxy.
In that case, even if the origin nginx or Apache speaks HTTP/2, the port isn’t directly reachable, and direct exploitation is harder.

Internal load balancers, Kubernetes Ingress controllers, service meshes, and API gateways are a different story.
Checking only “internet-facing web servers” can miss Envoy, Pingora, or IIS endpoints terminating HTTP/2 internally.
Trace the full path: CDN-to-origin, LB-to-Pod, gateway-to-upstream—find every point that accepts HTTP/2.

For nginx, verify 1.29.8+ or that max_headers is set in server blocks serving HTTP/2.
For Apache, check the bundled mod_http2 version or whether standalone mod_h2 v2.0.41 equivalent patches are applied.
For Envoy, confirm 1.35.11 / 1.36.7 / 1.37.3 / 1.38.1 or later.

Where updates take time, disable HTTP/2 temporarily, hard-cap header field count at the front layer, or set tight memory limits on workers and containers.
Memory limits don’t fix the root cause, but they let a worker die and restart before the entire host enters swap thrashing.

Amplification ratio varies by orders of magnitude depending on memory management

Calif’s measurements show a single 100Mbps residential link can push server memory consumption past 32GB.
Amplification ratios depend on internal data structures and diverge widely by product.

ImplementationAmplificationMemory consumedTime
Envoy 1.37.2~5,700x~32GB~10s
Apache httpd 2.4.67~4,000x~32GB~18s
nginx 1.29.7~70x~32GB~45s
Microsoft IIS (Windows Server 2025)~68x~64GB~45s
Cloudflare Pingora 0.8.0~62x

Envoy and Apache are two orders of magnitude higher because of how they handle cookie reassembly.

RFC 9113 Section 8.2.3 explicitly allows HTTP/2 clients to split the cookie header into multiple small fields.
The receiver is expected to merge them back into a single HTTP/1.1-compatible cookie header.

In Apache’s mod_h2, this merge runs iteratively.
Each arriving cookie crumb triggers a new pool string allocation concatenating the previous result, and the old strings survive until stream cleanup.
With N cookie crumbs, memory consumption grows quadratically as i=1N(2i+1)\sum_{i=1}^{N}(2i+1).
4,091 cookie crumbs produce ~16MB; at 100 streams per connection, that exceeds 1.5GB.

In Envoy, a 4,058-byte cookie is inserted into the dynamic table, then referenced 32,768 times via the 1-byte index 0xBE.
Wire size is 36,844 bytes, but the merged cookie on the server side grows to 126.9MiB.
Measured RSS delta including allocator overhead gives the 5,700:1 ratio.

Both share the same root cause: byte-count limits didn’t properly account for cookies.
Envoy’s max_request_headers_kb excluded cookie bytes. Apache’s LimitRequestFields didn’t count cookie crumbs individually.

Trickling 1 byte at a time holds memory for hours

The flow control portion of the attack is independent of HPACK amplification and works on its own.

The attacker sends SETTINGS with INITIAL_WINDOW_SIZE=0.
The server can send HEADERS frames but cannot send DATA frames.
Response bodies stay buffered and streams don’t complete.

nginx has send_timeout (default 60 seconds).
Without intervention, streams expire in 60 seconds and memory is freed.
The attacker sends WINDOW_UPDATE(increment=1) every 50 seconds, letting the server write 1 byte and reset the timeout.
Bandwidth required to keep the connection alive: ~34 bytes/second.

flowchart TD
    A["SETTINGS:<br/>INITIAL_WINDOW_SIZE=0"] --> B["Server cannot send<br/>DATA frames"]
    B --> C["Response stays<br/>in buffer"]
    C --> D{"send_timeout<br/>60s remaining"}
    D -->|"After 50s"| E["WINDOW_UPDATE<br/>increment=1"]
    E --> F["1 byte sent,<br/>timer reset"]
    F --> D

nginx’s default 404 page (~150 bytes) can be held in memory for ~2.3 hours per connection.
A 1KB custom error page stretches that to ~15 hours; a 4KB static file to ~62 hours.

nginx doesn’t return memory until workers restart

After the attack ends and streams are closed, nginx’s process RSS doesn’t drop.

glibc’s ptmalloc extends the heap top via brk() to allocate memory.
Freed blocks go into the arena free list, but blocks below the heap top can’t be returned to the OS.
A single live allocation at the top pins all free blocks below it.

Calif measured RSS peaking at 1.41GB across 5 connections; after all connections closed, 1.13GB remained.
nginx -s reload reuses the same worker PID, so it doesn’t reclaim.
The state persists until the worker process is fully restarted.

nginx’s built-in flood detection uses total_bytes / 8 > payload_bytes + 1,048,576, but the HPACK Bomb overhead ratio is 1.001:1, well below the 8:1 threshold.
Flood detection does not catch this attack.

IIS uses kernel memory, requiring a full reboot to reclaim

IIS processes HTTP/2 in kernel mode via http.sys, which changes the nature of the problem.

On a Windows Server 2025 box with 64GB, peak reached 65,319MB after 95 seconds.
After the attack stopped, 12.2GB remained unreclaimed in kernel space.
A 96GB server hit 92,920MB in 45 seconds with 16GB left unreturned.

IIS’s Timer_MinBytesPerSecond (baseline ~15 seconds) is bypassed by sending WINDOW_UPDATE(increment=1) every 5 seconds.
The frame-rate DoS limiter has its registry value defaulting to 0 (disabled).
Full memory reclamation requires a system reboot.

Differences from the 2019 HTTP/2 DoS set and Rapid Reset

HTTP/2 DoS vulnerabilities were bulk-reported in 2019 as CVE-2019-9511 through CVE-2019-9518.

CVE-2019-9517 (Internal Data Buffering) is closest to HTTP/2 Bomb.
It kept the HTTP/2 window open while closing the TCP window, forcing the server to buffer responses.
HTTP/2 Bomb adds “increase per-request memory cost via HPACK amplification” on top of that.

CVE-2023-44487 (HTTP/2 Rapid Reset) has a different objective.
It opens streams and immediately cancels them with RST_STREAM, burning CPU on request processing—a throughput attack that peaked at 398 million requests/second.
Rapid Reset requires high bandwidth; HTTP/2 Bomb works from a single 100Mbps link.

CVE-2016-6581 is Cory Benfield’s original HPACK Bomb, which inserted a large value into the dynamic table and expanded it via index references.
Many implementations added decoded-size caps in response, but HTTP/2 Bomb bypasses this by keeping header values small.

AttackTargetBandwidthWhy defenses fail
HTTP/2 Bomb (this one)Memory exhaustion + retention100Mbps single linkHeader values are small; decoded-size limits pass
Rapid Reset (CVE-2023-44487)CPU saturationHigh bandwidthRST_STREAM processing cost was unexpected
Internal Data Buffering (CVE-2019-9517)Memory bufferingModerateTCP/HTTP/2 window mismatch
Original HPACK Bomb (CVE-2016-6581)Memory inflationLow bandwidthMitigated by decoded-size caps

HPACK spec underestimates per-header cost

RFC 7541 (HPACK) Section 4.1 defines dynamic table entry size as name_length + value_length + 32 bytes.
The 32 bytes is an estimated overhead for pointers and reference counters.

That 32-byte estimate is scoped to the HPACK dynamic table’s own size accounting.
It doesn’t cover the management memory the server allocates per header for request processing.
In nginx, each header takes an ngx_table_elt_t struct (56 bytes), a name copy (2 bytes), and a value copy (1 byte)—about 59 bytes total.
A 1-byte index reference on the wire becomes a 59-byte allocation on the server.

RFC 7541 Section 7.3 only says memory consumption “can be bounded by limiting the size of the dynamic table.”
It doesn’t address per-decoded-header management cost.
Calif writes “the defect is in the spec.”

nginx’s max_headers directive is the implementation-side additional defense for this spec-level gap.
The commit adds a headers_in.count check across all three paths: HTTP/1.x, HTTP/2, and HTTP/3 (QUIC).

Codex synthesized two known-public techniques into a new attack

Calif discovered this vulnerability using OpenAI’s Codex agent.
HPACK dynamic table amplification and flow control stall are both techniques publicly known for over a decade.
Codex read the nginx, Apache, and Envoy codebases and recognized that combining them produces a viable attack.

It then analyzed the nginx and Apache fix commits to infer that IIS, Envoy, and Pingora had the same class of issue.
All PoCs are written in Python using only the standard library, with no external dependencies.

Thai Duong, the principal figure at Calif, co-discovered the CRIME attack (TLS compression-based session hijacking) with Juliano Rizzo in 2012 and participated in the HPACK RFC review while at Google.
The claim “the defect is in the spec” comes from firsthand experience in the spec process.
A presentation is scheduled at the Real World AI Security conference at Stanford University in June 2026.

The 0xBE 1-byte index reference sent by the PoC

Breaking down the wire bytes from the nginx/Pingora PoC:

0x82  :method GET (HPACK static index 2)
0x84  :path / (static index 4)
0x86  :scheme https (static index 6)
0x41  :authority literal insertion (name index 1)
  0x01  value length 1 byte
  0x78  "x"
0x40  literal insertion (new name)
  0x01  name length 1 byte
  0x61  "a"
  0x00  value length 0 (empty)
0xBE × N  reference dynamic table index 62

The pseudo-headers :method, :path, :scheme, and :authority are pulled from the static table.
Then a header with name a and no value is inserted via literal insertion into dynamic table index 62.
After that, repeating 0xBE decodes one header (name a, no value) per byte.

nginx’s large_client_header_buffers defaults to 4×8192 = 32,768 bytes. With 1-byte name + 0-byte value headers, roughly 32,000 headers fit within the size cap.
Each 1-byte reference becomes a 59-byte server-side allocation: 32,000 × 59 = ~1.9MB per stream.
128 streams × 1.9MB = ~243MB per connection.

The Envoy PoC uses cookies.
HPACK static table index 32 is cookie. A 4,058-byte cookie value is inserted into the dynamic table via literal insertion.
HPACK entry size: name 6 bytes + value 4,058 bytes + overhead 32 bytes = 4,096 bytes, exactly fitting Envoy’s default dynamic table size (4,096 bytes) as one entry.
Then 0xBE is repeated 32,768 times, expanding each 1-byte reference into a 4,058-byte cookie crumb.

The IIS PoC uses the a: header approach but caps at 900 headers per stream.
http.sys’s DecompressionOverflow detection threshold sits at ~921 headers; staying at 900 evades it.

Envoy’s 5,700:1 amplification has two root causes.

First, cookie fragment size accounting was missing.
Envoy validates request header size, then merges cookie fragments afterward.
The merged cookie total doesn’t go through the max_request_headers_kb check.
32,768 cookie crumbs merge into a 126.9MiB cookie and sail past the size limit.

Second, HPACK block size limits applied only to encoded wire bytes.
Envoy’s internal oghttp2/quiche applied the HPACK header block size limit to encoded wire bytes only, not to decoded header totals.
A 36,844-byte header block on the wire decodes to 133MB without tripping any limit.

The Envoy advisory assigns CWE-405 (Asymmetric Resource Consumption / Amplification) and CWE-770 (Allocation of Resources Without Limits).
CVSS vector: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H—unauthenticated, network-reachable, high availability impact.
Fixes ship in four branches: 1.35.11, 1.36.7, 1.37.3, 1.38.1. No complete workaround other than applying the patch.

Pingora’s h2 crate defaults to effectively unlimited concurrent streams

The amplification table has blank entries for Pingora’s memory consumption and time because the numbers depend heavily on stream count.

Pingora uses Rust’s h2 crate.
The default maximum concurrent streams on the receiver side is usize::MAX—effectively unlimited.
The decoded header list cap defaults to 16MiB per stream, so per-stream RSS stays around 1.9MiB.
But opening 2,048 streams from one connection reaches 1.9MiB × 2,048 = ~3.8GiB.
Wire cost is ~62.5MiB.

Pingora’s 62:1 ratio is two orders of magnitude below Envoy’s 5,700:1, but that’s per-stream amplification only. The lack of a concurrent stream cap means total consumption scales with connection count even without high per-stream amplification.

mod_h2’s fix is a single line: *pwas_added = 1

The Apache httpd fix touches just two places in h2_util.c.

First, ignoring duplicate empty cookies:

if (!nv->valuelen)
    return APR_SUCCESS;

Second—and this is the core fix—counting cookie merges toward LimitRequestFields:

*pwas_added = 1;

Before the fix, the cookie merge path returned APR_SUCCESS before setting *pwas_added = 1.
With *pwas_added stuck at 0, no matter how many cookie crumbs were merged, the LimitRequestFields counter never incremented.

Apache’s cookie merge in h2_req_add_header() repeatedly calls apr_psprintf(pool, "%s; %.*s", existing, ...).
Each incoming crumb allocates a new APR pool string concatenating the previous result; old strings survive until stream cleanup.
With 4,091 empty cookie crumbs, the final cookie value stays within LimitRequestFieldSize (default 8190 bytes) at 4,091 × 2 = 8,182 bytes, but intermediate pool strings all persist, producing quadratic memory growth.

IIS http.sys kernel processing path

The path an HTTP/2 request takes through IIS runs inside the http.sys kernel-mode driver.

HPACK payload is decompressed by HkDecode, and a kernel pool buffer is allocated for each decoded header pair.
The processing chain flows through UxDuoProcessCompleteCatalogUxDuoRunStreamReceivePumpUlHttpReceiveHeadersEvent.

WINDOW_UPDATE(increment=1) is accepted by UxDuoUpdateStreamSendWindow with no minimum-value check.
A 1-byte send resets Timer_MinBytesPerSecond via UxDuoDispatchWindowParcel.
Since this runs in kernel space, IIS application pool settings and Web.config can’t control it.

The frame-rate DoS limiter (Http2MaxWindowUpdatesPerSend) and other HTTP/2 DoS-related limiters ship with registry values defaulting to 0 (disabled).
Enabling them requires manual registry edits, but DecompressionOverflow’s threshold sits at ~921 headers, and the PoC stays at 900 to evade it—so threshold tuning alone won’t block the attack.

Over 880,000 HTTP/2 termination points exposed on Shodan

According to SecurityOnline, Shodan shows over 880,000 active systems terminating HTTP/2.
With IIS and Pingora unpatched at PoC publication time, a significant portion is vulnerable.

CDN-fronted origin servers are harder to reach directly, but Envoy sidecars in service meshes, Pingora-based Kubernetes Ingress controllers, and internal IIS HTTP/2 endpoints don’t show up on Shodan.
The actual number of vulnerable HTTP/2 termination points, including those on internal networks, is substantially higher than 880,000.

Disclosure and patch timeline

DateEvent
April 2026Disclosed to nginx development team. 1.29.8 released next day (max_headers added)
May 27, 2026Disclosed to Apache httpd mod_h2 maintainer Stefan Eissing. v2.0.41 released same day. CVE-2026-49975 assigned
June 3, 2026Envoy releases fixes across 4 branches. GHSA-22m2-hvr2-xqc8 / CVE-2026-47774 published
June 3, 2026Calif publishes PoC and analysis article
As of June 4, 2026IIS and Pingora: no fix confirmed

nginx’s fast response shows in the commit provenance.
The max_headers commit was imported from freenginx (https://freenginx.org/hg/nginx/rev/199dc0d6b05be814b5c811876c20af58cd361fea), authored by Maxim Dounin.
The same headers_in.count counter check was added across all three paths: HTTP/1.x, HTTP/2, and HTTP/3.

cscf = ngx_http_get_module_srv_conf(r, ngx_http_core_module);
if (r->headers_in.count++ >= cscf->max_headers) {
    ngx_log_error(NGX_LOG_INFO, r->connection->log, 0,
                  "client sent too many header lines");
    /* HTTP/1.x: NGX_HTTP_REQUEST_HEADER_TOO_LARGE + break
       HTTP/2:   ngx_http_finalize_request() + goto error
       HTTP/3:   return NGX_ERROR */
}

Error handling diverges into three paths—HTTP/1.x does lingering_close with break, HTTP/2 routes through ngx_http_finalize_request to goto error, HTTP/3 does return NGX_ERROR—but the counter and log message are shared.

References