Abstract glowing digital circuit blocks representing compressed HTTP/3 header data traveling over the network

QPACK Header Accordion DoS Hits HTTP/3

October 22, 2026 · 12 min read · By API User

A remote, unauthenticated client can make an HTTP/3 server allocate tens to thousands of times more memory than it sends, and one connection is enough to exhaust a default-configured process. That is the headline result of FoxIO’s “Header Accordions” research, published on 20 October 2026 by Sébastien Féry: seven of the ten HTTP/3 implementations tested were vulnerable, and several were already fixed. The pattern behind the results is not a single bug. Across the affected stacks, decoded-header limits were missing, incomplete, or applied too late, so a small HEADERS frame expanded into hundreds of thousands of header objects in memory.

Two amplification shapes produce that expansion, and each one slips past a different kind of defense. A per-field counter does not stop thousands of distinct short field names. A compressed-size cap does not stop a 4 KiB dynamic-table entry referenced thousands of times at one byte per reference. Only a cumulative budget applied to decoded size constrains both, which is why the rules in RFC 9114 Section 4.2.2 matter more than the frame limit most operators have configured.

Key Takeaways:

  • FoxIO tested ten HTTP/3 implementations in October 2026 and found seven vulnerable to QPACK header amplification; four had shipped fixes by publication.
  • Bun 1.4.3, the Rust h3 crate, and aioquic/mitmproxy had no fixed release as of 20 October 2026; Bun’s overflow and infinite loop turn memory pressure into permanent denial of service.
  • Set a decoded field-section budget, patch the stacks that have releases, and fingerprint your listeners before assuming they are affected.

What QPACK Actually Sends Over the Wire

Detecting Exposure in a Fleet

That split is exactly what QPACK needs to avoid head-of-line blocking on QUIC, and it is also what breaks the intuition that wire size predicts work. A field section is a sequence of instructions that the receiving decoder replays. Until the decoder has replayed them, nobody knows how many field objects, how many bytes of name and value, and how much container overhead the section will become.

Every amplification result below follows from that asymmetry. The attacker chooses the instruction sequence, and the receiver pays for the decoded objects.

Shape One: Many Unique Small Headers

The first shape maximizes the number of fields inside one HEADERS frame. Féry generated unique names, a through z, then aa through zz, then three- and four-character names until the encoded-size budget filled. There are 676 possible two-letter names, and he skipped te because that field has special value requirements. A 1,000,000-byte HEADERS frame holds about 160,000 unique headers this way.

Uniqueness is the point. A compression table only saves bytes when the same name or value appears again, so a field section built entirely from distinct short names offers nothing to deduplicate. Each field still carries its own entry in whatever container the server builds. On quic-go, the 1,000,000-byte block decoded into 160,075 fields and retained approximately 15 MiB after garbage collection, about 15 times the encoded size, because the http.Header map allocates keys, value slices, and internal storage beyond the encoded bytes.

This shape defeats two defenses that look reasonable on a diagram. A cap on field name length does nothing when every name is two to four characters. A per-field check that runs once at the end does nothing either, because the allocation has already happened by the time the check would pass or fail. FoxIO’s description of the general failure is precise: implementations checked compressed frame size, decompressed everything before checking, omitted the 32-byte overhead, defaulted to unlimited when a setting was unconfigured, or never enforced the setting even when it was configured.

Shape Two: One 4 KiB Entry Referenced Thousands of Times

The second shape inverts the attack. The encoder stream inserts one large entry of roughly 4 KiB, and the request stream then carries thousands of one-byte indexed references to it. Each reference forces a fresh allocation and copy of the full entry. Féry puts the ratio at 4064:1, and this is the mechanism behind the lsquic and Bun results.

lsquic compounded three problems: its dynamic-entry routine allocated and copied per reference with no cumulative decoded-size limit, its es_max_header_list_size defaulted to 0, meaning unlimited, and was never passed to the QPACK decoder, and its 64 KiB header cap reset per HEADERS block rather than per stream while the library accepted unlimited blocks before DATA arrived. The measured result was less than 10 MiB of wire traffic across 50 connections pinning resident memory at a 5 GiB cap within a second, with the cgroup memory.events file recording more than 900 limit hits.

Bun inherits lsquic but replaces its header-set callbacks, which capped at 64 KiB cumulative, with its own us_quic_hsi_prepare and us_quic_hsi_process in quic.c. Those check per-header size at 64 KiB but never the cumulative total, and es_max_header_list_size is advertised as 16384 in SETTINGS yet never enforced. On one connection with 100 streams and up to 500,000 one-byte references per stream, 60,000 references per stream, 5.8 MiB of wire traffic in total, were enough to OOM-kill a 2 GiB container.

Bun carries a second defect in the same function that converts memory pressure into permanent denial of service. The buffer capacity is tracked in an unsigned int and grown by doubling. Once the decoded header set passes 2 GiB, the required size exceeds 2^31, and the doubling loop drives ncap through 2^31 to 0. From zero, ncap *= 2 stays zero forever, so the loop cannot exit. A crashed worker restarts or gets replaced; an infinite loop inside the request path does not.

The 32-Byte Overhead and the max_field_section_size Arithmetic

RFC 9114 Section 4.2.2 tells receivers how to measure a field section, and the wording is worth reading at the level of the clause: “The size of a field list is calculated based on the uncompressed size of fields, including the length of the name and value in bytes plus an overhead of 32 bytes for each field.” That overhead is not decoration. It models the per-field cost of the structures a receiver builds, which is why a limit that sums only name and value bytes is measuring something smaller than the thing that consumes memory.

A 1 MiB decoded budget therefore represents at most about 32,000 fields once the 32-byte floor is counted; an implementation that sums only name and value bytes can admit far more.

The 32-Byte Overhead and the max_field_section_size Arithmetic
The 32-Byte Overhead and the max_field_section_size Arithmetic, architecture diagram

Two properties complete a working budget. It must be cumulative across the whole field section, including trailer sections, and it must cover what the application stores rather than only what the decoder emits. RFC 9114 defines SETTINGS_MAX_FIELD_SECTION_SIZE as the way an implementation advises its peer of the limit, but advisory signaling is not enforcement. ngtcp2 and nghttp3 responded to this finding by documenting that parameter as signaling-only in nghttp3 pull request 445, which is an honest description of the protocol and a reminder that the receiving application owns the check.

How Implementations Got It Wrong, and What the Fixes Look Like

The remediation paths differ by stack, and so do the failure modes. quic-go’s HTTP/3 server limited HEADERS frames to 1 MiB under DefaultMaxHeaderBytes, but that applied to the compressed QPACK payload; DecodeFull in qpack/decoder.go decompressed the entire block in one pass and returned every field. The fix shipped in v0.57.0 under GHSA-g754-hx8w-x2g6, which the maintainer published on 11 December 2025 with a moderate rating, CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L. Patched releases send SETTINGS_MAX_FIELD_SECTION_SIZE, decode incrementally with a size check after each entry, and abort with HTTP 431. Féry traced the timeline from a 9 November 2025 report to the 21 November release.

The header-expansion identifier, tracked as CVE-2025-64702, is not the only quic-go issue in this family. A later advisory, GHSA-vvgj-x9jq-8cj9 for CVE-2026-40898, covers the same compressed-versus-decoded gap reached through trailers, where crafted QPACK-encoded trailer sections decompress into an enormous http.Header on both servers and clients. That one was patched in v0.59.1 on 31 May 2026. Merge the two identifiers and you will misreport your exposure, because the fixes landed in different releases.

Cloudflare quiche had two problems under one identifier. Its size accounting omitted the 32-byte overhead, and its HTTP/3 frame parser pre-allocated buffers from the declared frame length before any payload arrived, so a peer could declare a 16 MiB SETTINGS or GOAWAY frame and never send the data. GHSA-4fgf-9xrr-88gf, published 14 July 2026 as CVE-2026-12523, is rated high with CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, and 0.29.3 fixes both. lsquic fixed its path in 4.9.0 with no CVE assigned, adding es_max_header_sets with a server default of 1 to cap the decoded header sets buffered per stream and suspend HTTP/3 parsing until the application drains them. Wireshark is a capture analyzer rather than a server, and its case is different in kind: an unbounded decode can crash the HTTP/3 dissector when a malformed capture is loaded or traffic is decrypted, tracked as CVE-2025-13945 and fixed in 4.6.2 and 4.4.12 on 3 December 2025.

Implementation Status as of 20 Oct 2026 Fixed version Advisory Effect reported by FoxIO
quic-go Patched 0.57.0 CVE-2025-64702 About 15x retained heap after GC
Cloudflare quiche Patched 0.29.3 CVE-2026-12523 About 20x, plus frame preallocation
lsquic Patched 4.9.0 No CVE assigned About 600x to 1800x
Wireshark Patched 4.6.2 / 4.4.12 CVE-2025-13945 Dissector crash
Rust h3 (hyperium) No fixed release None as of publication No CVE in the FoxIO post Thread panic at 32,768 HeaderMap entries
aioquic / mitmproxy No fixed release None as of publication No CVE in the FoxIO post About 20x to 30x
Bun No fixed release None as of publication No CVE in the FoxIO post About 4000x, plus infinite loop

The quic-go column reflects FoxIO’s header issue. A separate trailer issue, CVE-2026-40898, requires v0.59.1; patching only to 0.57.0 leaves that path open.

The h3 crate’s failure is a panic rather than an allocation explosion. It decodes every QPACK header into a vector, then inserts each field into an http::HeaderMap, whose append call ends in try_append(...).expect("size overflows MAX_SIZE"). Past the map’s capacity, reported at 32,768 entries, the worker panics with “size overflows MAX_SIZE: MaxSizeReached”. Under h3’s default configuration max_field_section_size is effectively unlimited, so an oversized decoded section reaches that path; configuring a sufficiently small limit keeps the reproducer from getting there. The builder method exists on both the server Builder and the client Builder, which is why it is the documented mitigation. aioquic defines MAX_FIELD_SECTION_SIZE, parses it from the peer, and never checks it during decoding, while its flow control doubles without a ceiling; mitmproxy suffers more than the library because it builds and retains an http.Request per stream.

FoxIO lists several stacks as not affected by this header-amplification class: IIS with http.sys enforces incrementally with a 4K QPACK table and returns H3_EXCESSIVE_LOAD, Google’s quiche enforces a 60K cap during decode and resets the stream on violation, ngtcp2 and nghttp3 default the dynamic table to zero with immediate decref, and Envoy is also listed as unaffected.

Detecting Exposure in a Fleet

Which library answers on port 443 over UDP is not visible from a package inventory, because HTTP/3 is often negotiated through Alt-Svc or terminated by a component the application team does not manage. FoxIO published JA4Scan-QUIC fingerprints for the tested builds, derived from active probing of the server handshake, to map a live listener to a library and version. The mappings include quic-go v0.56.0 (031g0hlss000_5973261134f6_6d6a17008abe_sr10sc7b63be) and Bun 1.4.3 (032g0h0ss200_756c1a5a0530_67f99a05b91e_s010s0fecef0). Fingerprinting tells you what you are running; it does not tell you whether a request succeeded in amplifying.

On the host side, the useful signals are all about divergence between inbound bytes and resident memory. Watch for resident set growth on an HTTP/3 worker that is not matched by stream bytes received, for cgroup memory.events limit counters climbing on a service that normally never approaches its ceiling, for spikes in HTTP 431 responses even though no legitimate client sends large headers, and for Rust worker panics mentioning MAX_SIZE or for HTTP/3 dissector crashes when capture files are loaded. FoxIO’s writeup documents discovery and reproduction; it does not claim observed exploitation in the wild, and Wireshark’s advisory states the project was unaware of any exploits for the dissector crash.

A Configuration Checklist and Its Limits

The remediation decision is short, but each branch has a catch. Patch where a release exists: quic-go 0.59.1 (0.57.0 fixes CVE-2025-64702; 0.59.1 covers the trailer issue CVE-2026-40898), quiche 0.29.3, lsquic 4.9.0, Wireshark 4.6.2 or 4.4.12. Where none exists, remove the exposure or impose the budget yourself.

  • Bun: do not expose HTTP/3 servers. The vulnerability is in Bun’s own header-set callbacks, so no configuration setting fixes it.
  • mitmproxy: disable HTTP/3 with --set http3=false. The proxy retains a request object per stream, which is what turns a transient decode into retained memory.
  • Rust h3: set a reasonable max_field_section_size on both client and server builders. This bounds decoded size, which is the path to the HeaderMap panic.
  • Anything else: confirm that a decoded budget exists, that it includes 32 bytes per field, that it is decremented as fields are produced, and that it covers trailer sections as well as headers.
  • Fleet scope: fingerprint UDP 443 listeners and check the components behind Alt-Svc. A Go service behind a CDN may be safe at the edge and exposed at the origin.

The limits of this defense are worth stating plainly. A field-section budget is per implementation, so RFC 9114 notes that a message below one implementation’s limit is not guaranteed to be accepted by the next hop; terminating HTTP/3 at a stack FoxIO lists as unaffected, such as IIS, Google quiche, or ngtcp2 with nghttp3, moves the enforcement boundary but does not remove the requirement to enforce it somewhere. Lowering the limit has a real cost, since requests with large cookies or authorization headers can exceed an aggressive cap. And header budgets do not address blocked-stream limits or other QPACK state exhaustion. Patch windows matter too, as the recent nginx rewrite overflow response and the dnsmasq six-CVE emergency both showed. A small HEADERS frame that becomes hundreds of thousands of fields is a cheap attack with an expensive fix, and the fix is a size check most stacks claimed to have.

More in-depth coverage from this blog on closely related topics:

Sources and References

Sources cited while researching and writing this article: