Header Accordions: QPACK amplification attacks against HTTP/3 servers - FoxIO | Cyber Innovation
Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 977 words · 12 segments analyzed
TLDRA remote, unauthenticated client can force an HTTP/3 server to allocate tens to thousands of times more memory than it sends.
One connection is enough to OOM or pin a default-configured server.
Seven of the ten implementations tested are vulnerable; several are already fixed.QPACK compresses HTTP/3 headers to minimize traffic sent over the wire.
Across the affected implementations, decoded-header limits are missing, incomplete, or applied too late. A small HEADERS frame can therefore expand into hundreds of thousands of header objects in memory.
Preventing that expansion from exhausting resources requires enforcing a cumulative budget as fields are decoded and retained.Patched as of October 20, 2026 LibraryFixed versionAdvisoryquic-go0.57.0CVE-2025-64702Cloudflare quiche0.29.3CVE-2026-12523lsquic4.9.0no CVEWireshark4.6.2 / 4.4.12CVE-2025-13945Unpatched as of October 20, 2026LibraryBun 1.4.3h3 (hyperium)aioquic / mitmproxyMitigationsBun: Don't expose HTTP/3 serversmitmproxy: disable HTTP/3 (pass --set http3=false)h3 crate: set a reasonable max_field_section_sizeNot affected: IIS, Envoy, ngtcp2/nghttp3BackgroundDuring recent research into the different QUIC stacks for our active TLS scanner, JA4Scan, I found the same class of vulnerability across multiple HTTP/3 implementations. A remote, unauthenticated client can send HTTP/3 traffic that forces the server to allocate far more memory than the encoded request occupies on the wire.This investigation started after reading Gal Bar Nahum's MadeYouReset series on HTTP/2 request-cancellation DoS.
That work got me looking at whether HTTP/3's QPACK header compression had similar amplification issues.HTTP/3 compresses headers using QPACK, so repeated fields like user-agent or content-type can be sent as a small index instead of the full string.
It uses two lookup tables: a static table, a hardcoded set of common fields defined by the specification, and a dynamic table, built up at runtime between a client and server as new headers are seen.For example, a 71-byte header like strict-transport-security: max-age=31536000; includesubdomains; preload becomes a single byte: 0xFA, a static-table reference to index 58.RFC 9114 Section 4.2.2 defines how servers should limit decoded header size: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.Some implementations tested got at least one of these wrong:They check the compressed frame size, not the decoded sizeThey decompress everything before checkingThey omit the 32-byte overhead from their size calculationThey default to unlimited when the setting is unconfiguredThey never enforce the setting at all, even when it is configuredHow does it work?The goal is to maximize the number of headers inside a single HEADERS frame. For this variant, I use unique names to create distinct entries in the receiving header map: a through z, aa through zz, then three and four char names until the encoded-size budget is filled. There are 676 possible two-letter names but we need to skip te as that header has special value requirements..A 1,000,000-byte HEADERS frame holds about 160,000 unique headers this way.What the server does with those 160,000+ headers is where it breaks.Example request flow on quic-goThe accordion also works in the other direction.
Instead of many small headers, the dynamic table variant inserts one large entry (~4 KiB) via the encoder stream, then references it thousands of times with 1-byte indexed references.
Each reference forces a fresh allocation and copy of the full entry.
This is how the lsquic and Bun attacks work: each 1-byte reference materializes the full entry, a ratio of 4064:1.ImplementationsImplementationVulnerableCVEWire-to-memoryRoot causequic-goYesCVE-2025-64702~15x retained heapChecks compressed size only, no decoded limitWiresharkYesCVE-2025-13945crashQPACK dissector unbounded decodeCloudflare quicheYesCVE-2026-12523~20xMissing 32-byte overhead + frame prealloc bomblsquicYesFixed, no CVE~600-1800xNo cumulative decoded limit, unlimited HEADERS blocksaioquic / mitmproxyYes-~20-30xMAX_FIELD_SECTION_SIZE parsed but never enforcedBunYes-~4000xReplaces lsquic's 64K cap with no caph3 (hyperium)YesReported, not fixedThread panicHeaderMap::append panics at 32,768 entriesGoogle quicheNo--60K cap enforced during decode, stream RESET on violationIIS (http.sys)No--Incremental enforcement, 4K QPACK table, H3_EXCESSIVE_LOADngtcp2 / nghttp3NoReported, documentation updated-Dynamic table default 0, immediate decref; max_field_section_size is signaling-onlyquic-goquic-go is the Go library behind Caddy, Traefik, and cloudflared.
Its HTTP/3 server limits the HEADERS frame to 1 MiB (DefaultMaxHeaderBytes), but that limit applies to the compressed QPACK payload, not the decoded output.
DecodeFull in qpack/decoder.go decompresses the entire block in one pass and returns a []HeaderField slice, which is then converted into an http.Header map.The http.Header map is a map[string][]string, whose keys, value slices and internal storage add overhead beyond the encoded headers. The 1,000,000-byte QPACK block decodes into 160,075 fields and retained approximately 15 MiB in the resulting request after gc, about 15 times the encoded size.quic-go limited the compressed HEADERS frame without enforcing a corresponding decoded-header budget.Fix: quic-go now sends SETTINGS_MAX_FIELD_SECTION_SIZE, uses incremental QPACK decoding that checks size after each entry, and aborts with HTTP 431 on the server side.Cloudflare quichequiche had two issues rolled into one CVE.First, decode() in qpack/decoder.rs uses name.len() + value.len() for size accounting, omitting the RFC-mandated 32-byte overhead per field. This lets an attacker fit up to 33x more headers than the limit intends:With max_field_section_size = 1 MiB, the RFC allows ~32,000 headers (each costing at least 33 bytes). quiche's calculation allows ~1,000,000 one-byte headers to pass.Second, quiche's HTTP/3 frame parser pre-allocates buffers based on the declared frame length before receiving the payload. An attacker declares a 16 MiB SETTINGS or GOAWAY frame, triggering immediate memory allocation, then never sends the data.Fix: quiche 0.29.3 adds the 32-byte overhead and fixes frame pre-allocation.lsquiclsquic's vulnerability is the deepest I found. Three issues compound:header_out_dynamic_entry allocates and copies per reference with no cumulative decoded-size limites_max_header_list_size defaults to 0 (unlimited) and is never passed to the QPACK decoderThe 64 KiB header cap resets per HEADERS block, not per stream, and lsquic accepts unlimited HEADERS blocks before DATA on a single streamDynamic-table references produce greater amplification in this implementation:Insert one ~4 KiB entry via the encoder stream.On each request stream, send a chain of HEADERS frames, each containing 15 one-byte indexed references.Less than 10 MiB of wire traffic and 50 connections manage to pin the resident memory at the 5 GiB cap within a second, with cgroup memory.events recording 900+ limit hits.