{"article":{"slug":"header-accordions-qpack-amplification-attacks-against-http-3-servers","title":"Header Accordions: QPACK amplification attacks against HTTP/3 servers","subtitle":null,"summary":"FoxIO research showing a remote, unauthenticated client can make HTTP/3 servers allocate tens to thousands of times more memory than it sends via QPACK header decoding; seven of ten tested implementations were vulnerable, with CVEs and fixes listed.","content_type":"research","language":"en","canonical_url":"https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers","author":{"name":"Sébastien Féry","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"FoxIO","url":"https://foxio.io/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Networking","slug":"networking","url":"https://listedarticles.com/topics/networking"},{"name":"Research","slug":"research","url":"https://listedarticles.com/topics/research"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2595,"reading_minutes":11,"published_at":"2026-10-20T00:00:00.000Z","added_at":"2026-10-22T02:14:31.047Z","updated_at":"2026-10-22T02:14:31.047Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/header-accordions-qpack-amplification-attacks-against-http-3-servers","markdown_url":"https://listedarticles.com/articles/header-accordions-qpack-amplification-attacks-against-http-3-servers.md","example":false,"citation":"Sébastien Féry, FoxIO. \"Header Accordions: QPACK amplification attacks against HTTP/3 servers.\" 20 Oct 2026. https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers"},"body_markdown":"# Header Accordions: QPACK amplification attacks against HTTP/3 servers\n\n*Sébastien Féry, October 20, 2026*\n\n## TLDR\n\nA 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.\n\nQPACK 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.\n\n**Patched as of October 20, 2026**\n\n| **Library** | **Fixed version** | **Advisory** |\n| --- | --- | --- |\n| quic-go | 0.57.0 | CVE-2025-64702 |\n| Cloudflare quiche | 0.29.3 | CVE-2026-12523 |\n| lsquic | 4.9.0 | no CVE |\n| Wireshark | 4.6.2 / 4.4.12 | CVE-2025-13945 |\n\n**Unpatched as of October 20, 2026**\n\n| Library |\n| --- |\n| Bun 1.4.3 |\n| h3 (hyperium) |\n| aioquic / mitmproxy |\n\n**Mitigations**\n\nBun: Don't expose HTTP/3 servers\n\nmitmproxy: disable HTTP/3 (pass *--set http3=false*)\n\nh3 crate: set a reasonable *max\\_field\\_section\\_size*\n\n**Not affected**: IIS, Envoy, ngtcp2/nghttp3\n\n## Background\n\nDuring 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.\n\nThis investigation started after reading Gal Bar Nahum's [MadeYouReset](https://galbarnahum.com/made-you-reset) series on HTTP/2 request-cancellation DoS. That work got me looking at whether HTTP/3's QPACK header compression had similar amplification issues.\n\nHTTP/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.\n\nFor 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.\n\nRFC 9114 Section 4.2.2 defines how servers should limit decoded header size:\n\nThe 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.\n\nSome implementations tested got at least one of these wrong:\n\n1. They check the compressed frame size, not the decoded size\n2. They decompress everything before checking\n3. They omit the 32-byte overhead from their size calculation\n4. They default to unlimited when the setting is unconfigured\n5. They never enforce the setting at all, even when it is configured\n\n## How does it work?\n\nThe 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..\n\nA 1,000,000-byte HEADERS frame holds about 160,000 unique headers this way.\n\nWhat the server does with those 160,000+ headers is where it breaks.\n\n![](https://framerusercontent.com/images/PJKLXKYl6NoGQYS2wa5ipP2cBRk.png?width=1017&height=1193)\n\n*Example request flow on quic-go*\n\nThe 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.\n\n## Implementations\n\n|  |  |  |  |  |\n| --- | --- | --- | --- | --- |\n| Implementation | Vulnerable | CVE | Wire-to-memory | Root cause |\n| quic-go | Yes | CVE-2025-64702 | ~15x retained heap | Checks compressed size only, no decoded limit |\n| Wireshark | Yes | CVE-2025-13945 | crash | QPACK dissector unbounded decode |\n| Cloudflare quiche | Yes | CVE-2026-12523 | ~20x | Missing 32-byte overhead + frame prealloc bomb |\n| lsquic | Yes | Fixed, no CVE | ~600-1800x | No cumulative decoded limit, unlimited HEADERS blocks |\n| aioquic / mitmproxy | Yes | - | ~20-30x | MAX\\_FIELD\\_SECTION\\_SIZE parsed but never enforced |\n| Bun | Yes | - | ~4000x | Replaces lsquic's 64K cap with no cap |\n| h3 (hyperium) | Yes | Reported, not fixed | Thread panic | HeaderMap::append panics at 32,768 entries |\n| Google quiche | No | - | - | 60K cap enforced during decode, stream RESET on violation |\n| IIS (http.sys) | No | - | - | Incremental enforcement, 4K QPACK table, H3\\_EXCESSIVE\\_LOAD |\n| ngtcp2 / nghttp3 | No | Reported, documentation updated | - | Dynamic table default 0, immediate decref; max\\_field\\_section\\_size is signaling-only |\n\n## quic-go\n\nquic-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.\n\n```\n// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}\n```\n\n```\n// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}\n```\n\n```\n// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}\n```\n\nThe `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.\n\nquic-go limited the compressed HEADERS frame without enforcing a corresponding decoded-header budget.\n\n**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.\n\n## Cloudflare quiche\n\nquiche had two issues rolled into one CVE.\n\nFirst, `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:\n\n```\n// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;\n```\n\n```\n// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;\n```\n\n```\n// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;\n```\n\nWith `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.\n\nSecond, 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.\n\n**Fix**: quiche 0.29.3 adds the 32-byte overhead and fixes frame pre-allocation.\n\n## lsquic\n\nlsquic's vulnerability is the deepest I found. Three issues compound:\n\n1. *header\\_out\\_dynamic\\_entry* allocates and copies per reference with no cumulative decoded-size limit\n2. *es\\_max\\_header\\_list\\_size* defaults to 0 (unlimited) and is never passed to the QPACK decoder\n3. The 64 KiB header cap resets per HEADERS **block**, not per stream, and lsquic accepts unlimited HEADERS blocks before DATA on a single stream\n\nDynamic-table references produce greater amplification in this implementation:\n\n1. Insert one ~4 KiB entry via the encoder stream.\n2. On each request stream, send a chain of HEADERS frames, each containing 15 one-byte indexed references.\n\nLess 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. The PoC defaults (10 connections, 256 MiB container) show the same effect at a smaller scale.\n\n**Fix**: fixed in lsquic 4.9.0 (July 15, 2026), which adds `es_max_header_sets` (server default 1) to cap the decoded header sets buffered per stream and suspend HTTP/3 parsing until the application drains them.\n\nDuring testing, I also observed production servers, including `www.litespeedtech.com`, advertising `QPACK_MAX_TABLE_CAPACITY=4096` and `MAX_HEADER_LIST_SIZE=65536`.\n\n## Bun\n\nBun's HTTP/3 support uses lsquic under the hood but replaces lsquic's built-in header-set callbacks (which cap at 64 KiB cumulative) with its own `us_quic_hsi_prepare/process` in `quic.c`, which check per-header size (64 KiB) but never cumulative. The `es_max_header_list_size=16384` is advertised in SETTINGS but never enforced.\n\nSame dynamic table trick: 1 connection, 100 streams, up to 500K one-byte references per stream to a 4 KiB entry. Only 60K references per stream (5.8 MiB of wire in total) are enough to OOM-kill a 2 GiB container.\n\nBun has a second bug in the same function that turns the amplification into a permanent denial of service. `us_quic_hsi_prepare` tracks buffer capacity in an `unsigned int` and grows it by doubling:\n\n```\nunsigned int need = h->len + (unsigned int) space;\nif (need > h->cap) {\n    unsigned int ncap = h->cap ? h->cap : 512;\n    while (ncap < need) ncap *= 2;\n    char *nb = (char *) us_realloc(h->buf, ncap);\n```\n\n```\nunsigned int need = h->len + (unsigned int) space;\nif (need > h->cap) {\n    unsigned int ncap = h->cap ? h->cap : 512;\n    while (ncap < need) ncap *= 2;\n    char *nb = (char *) us_realloc(h->buf, ncap);\n```\n\n```\nunsigned int need = h->len + (unsigned int) space;\nif (need > h->cap) {\n    unsigned int ncap = h->cap ? h->cap : 512;\n    while (ncap < need) ncap *= 2;\n    char *nb = (char *) us_realloc(h->buf, ncap);\n```\n\nOnce the decoded header set passes 2 GiB, `need` exceeds 2^31 and `ncap` doubles through 2^31 to 0. From zero, `ncap *= 2` stays zero forever and `0 < need` never becomes false: an infinite loop inside `lsquic_engine_process_conns`.\n\n**A single stream carrying ~516 KiB of wire (528,500 indexed references) leaves the server pinned at 100% CPU and it never recovers, blocking out new incoming requests.**\n\nThe missing cumulative limit is what makes the overflow reachable; lsquic's stock 64 KiB cap prevents it.\n\nThe fix for both issues is the same check (`if (h->len + space > 16 * 1024) return NULL;`)\n\n## aioquic / mitmproxy\n\naioquic is the Python HTTP/3 library used by mitmproxy and hypercorn. It defines `MAX_FIELD_SECTION_SIZE` as a setting constant, parses it from the peer, stores it yet never checks it during decoding. `_decode_headers()` returns all decoded headers unconditionally.\n\naioquic's flow control also auto-doubles with no ceiling (`limit.value *= 2` when >50% consumed), so a single connection can deliver unlimited data.\n\nThe library itself only holds the decoded list transiently, so a minimal aioquic server that drops the headers after handling them survives, mitmproxy doesn't: it builds an `http.Request` per stream and keeps it while the stream is open.\n\n## h3 crate (hyperium)\n\nh3 is the Rust HTTP/3 library from the hyperium project. It can be used with quinn through the h3-quinn adapter and is also used by tokio-quiche. It decodes all QPACK headers into a `Vec<HeaderField>` and then converts them into an `http::HeaderMap` via `TryFrom<Vec<HeaderField>>` in `h3/src/proto/headers.rs`:\n\n```\n// h3/src/proto/headers.rs\nField::Header((n, v)) => {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}\n```\n\n```\n// h3/src/proto/headers.rs\nField::Header((n, v)) => {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}\n```\n\n```\n// h3/src/proto/headers.rs\nField::Header((n, v)) => {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}\n```\n\n`HeaderMap::append` calls `try_append(...).expect(\"size overflows MAX_SIZE\")`, causing a panic when the map’s capacity is exceeded. Under h3’s default configuration, *max\\_field\\_section\\_size* is effectively unlimited thus allowing an oversized decoded header set to reach this path. Configuring a sufficiently small decoded-size limit prevents this reproducer from reaching the map’s capacity limit. The resulting panic is shown below:\n\n```\nthread 'tokio-runtime-worker' panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached\n```\n\n```\nthread 'tokio-runtime-worker' panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached\n```\n\n```\nthread 'tokio-runtime-worker' panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached\n```\n\n## Wireshark\n\nFunnily enough, this one was inadvertently found while testing payloads for quic-go. The issue was lying in an uncontrolled number of allowed decoded headers and a slow `tvb_composite`, which froze and finally crashed the HTTP/3 dissector.\n\n## Why does it happen?\n\nThe harder question is who owns the limit. Decoding and storing headers can involve several components:\n\n- **QUIC** accounts for bytes received on the stream. It has no visibility into the header objects those bytes become.\n- **The QPACK decoder** reconstructs the fields. Depending on its API, it may enforce a decoded-size budget or leave that responsibility to its caller.\n- **The HTTP/3 layer and application** turn those fields into request objects and retain them. Their containers have their own memory costs and capacity limits.\n\nThe nghttp3 case makes this boundary explicit: its setting advertises a limit, but the application must enforce it. h3 illustrates a different boundary: a decoded field can pass through the decoder and later trigger a panic during insertion into HeaderMap.\n\nThe question is simple: which component actually rejects the oversized headers? If that responsibility belongs to the application, it needs to know and enforce the limit before retaining them.\n\n## Conclusion\n\nA small HEADERS frame can represent a lot of work. Across the implementations tested, that work turned into large allocations, retained header sets, or a panic when a downstream container reached its limit. The protocol already provides a way to account for decoded header size, but the receiving code has to enforce it.\n\nChecking compressed frame size is merely the starting point. The decoded budget needs to include the per-field overhead, apply while headers are being produced, and cover what the application retains. Several projects have now fixed these paths. The remaining findings show how an advertised limit can look reassuring while providing no protection against the input it was meant to bound.\n\n## Finding affected servers\n\nTo find impacted publicly facing servers in your fleet, here are the JA4Scan-QUIC fingerprints that map to the different servers:\n\n| Library | **JA4Scan-QUIC** |\n| --- | --- |\n| quic-go v0.56.0 | 031g0hlss000\\_5973261134f6\\_6d6a17008abe\\_sr10sc7b63be |\n| quiche 0.29.2 | 03100hlss200\\_8960528bd01f\\_6d6a17d04082\\_sr20ss7fb1bf |\n| OLS 1.9.1 (lsquic 4.8.2) | 032g0h0ss200\\_24e1d2569964\\_d1082305b91e\\_0r100s0b3f78 |\n| Bun 1.4.3 | 032g0h0ss200\\_756c1a5a0530\\_67f99a05b91e\\_s010s0fecef0 |\n| aioquic 1.7.0 | 0310000ss000\\_2a673bc4abf8\\_db4d1fe4882a\\_sr1s0seb0750 |\n| mitmproxy 12.2.3 | 0310000ss000\\_2a673bc4abf8\\_aee409e4882a\\_sr2s0seb0750 |\n| h3 0.0.8 | 031g0hlsr200\\_8960528bd01f\\_6d6a17129383\\_se10s0fecef0 |\n\nRead the full blog post about JA4Scan here: https://foxio.io/blog/introducing-ja4scan-active-server-fingerprinting-for-tls-and-quic\n\n## POC\n\nReproduction code is available at <https://github.com/FoxIO-LLC/blog_poc/tree/main/headeraccordions>\n\n## Timeline\n\n- Nov 9 2025 - quic-go report\n- Nov 14 2025 - Wireshark report\n- Nov 15 2025 - quiche report\n- Nov 21 2025 - quic-go 0.57.0 released\n- Dec 3 2025 - Wireshark 4.6.2 release\n- Dec 4 2025 - h3 report\n- Dec 5 2025 - h3 maintainer closes issue - alpha library + default setting to be changed\n- Dec 17 2025 - nghttp3 documents *max\\_field\\_section\\_size* as signaling-only\n- May 24 2026 - Bun report\n- July 13 2026 - aioquic report\n- July 13 2026 - lsquic report\n- July 14 2026 - quiche 0.29.3 released\n- Oct 20 2026 - blog published\n\n## References\n\n- RFC 9114 - HTTP/3, Section 4.2.2 (Header Size Constraints)\n- RFC 9204 - QPACK: Field Compression for HTTP/3\n- nghttp3 - Clarify field size limits: <https://github.com/ngtcp2/nghttp3/pull/445>\n- CVE-2025-64702 (quic-go): <https://github.com/quic-go/quic-go/security/advisories/GHSA-g754-hx8w-x2g6>\n- CVE-2025-13945 (Wireshark): <https://www.wireshark.org/security/wnpa-sec-2025-07.html>\n- CVE-2026-12523 (Cloudflare quiche): <https://github.com/cloudflare/quiche/security/advisories/GHSA-4fgf-9xrr-88gf>\n- Gal Bar Nahum - MadeYouReset HTTP/2: <https://galbarnahum.com/made-you-reset>\n","body_html":"<h1 id=\"header-accordions-qpack-amplification-attacks-against-http-3-ser\">Header Accordions: QPACK amplification attacks against HTTP/3 servers</h1>\n<p><em>Sébastien Féry, October 20, 2026</em></p>\n<h2 id=\"tldr\">TLDR</h2>\n<p>A 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.</p>\n<p>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.</p>\n<p><strong>Patched as of October 20, 2026</strong></p>\n<div class=\"table-wrap\"><table><thead><tr><th><strong>Library</strong></th><th><strong>Fixed version</strong></th><th><strong>Advisory</strong></th></tr></thead><tbody><tr><td>quic-go</td><td>0.57.0</td><td>CVE-2025-64702</td></tr><tr><td>Cloudflare quiche</td><td>0.29.3</td><td>CVE-2026-12523</td></tr><tr><td>lsquic</td><td>4.9.0</td><td>no CVE</td></tr><tr><td>Wireshark</td><td>4.6.2 / 4.4.12</td><td>CVE-2025-13945</td></tr></tbody></table></div>\n<p><strong>Unpatched as of October 20, 2026</strong></p>\n<div class=\"table-wrap\"><table><thead><tr><th>Library</th></tr></thead><tbody><tr><td>Bun 1.4.3</td></tr><tr><td>h3 (hyperium)</td></tr><tr><td>aioquic / mitmproxy</td></tr></tbody></table></div>\n<p><strong>Mitigations</strong></p>\n<p>Bun: Don&#39;t expose HTTP/3 servers</p>\n<p>mitmproxy: disable HTTP/3 (pass <em>--set http3=false</em>)</p>\n<p>h3 crate: set a reasonable <em>max_field_section_size</em></p>\n<p><strong>Not affected</strong>: IIS, Envoy, ngtcp2/nghttp3</p>\n<h2 id=\"background\">Background</h2>\n<p>During 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.</p>\n<p>This investigation started after reading Gal Bar Nahum&#39;s <a href=\"https://galbarnahum.com/made-you-reset\" rel=\"nofollow ugc noopener\">MadeYouReset</a> series on HTTP/2 request-cancellation DoS. That work got me looking at whether HTTP/3&#39;s QPACK header compression had similar amplification issues.</p>\n<p>HTTP/3 compresses headers using QPACK, so repeated fields like <code>user-agent</code> or <code>content-type</code> 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.</p>\n<p>For example, a 71-byte header like <code>strict-transport-security: max-age=31536000; includesubdomains; preload</code> becomes a single byte: <code>0xFA</code>, a static-table reference to index 58.</p>\n<p>RFC 9114 Section 4.2.2 defines how servers should limit decoded header size:</p>\n<p>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.</p>\n<p>Some implementations tested got at least one of these wrong:</p>\n<ol><li>They check the compressed frame size, not the decoded size</li><li>They decompress everything before checking</li><li>They omit the 32-byte overhead from their size calculation</li><li>They default to unlimited when the setting is unconfigured</li><li>They never enforce the setting at all, even when it is configured</li></ol>\n<h2 id=\"how-does-it-work\">How does it work?</h2>\n<p>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: <code>a</code> through <code>z</code>, <code>aa</code> through <code>zz</code>, 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 <code>te</code> as that header has special value requirements..</p>\n<p>A 1,000,000-byte HEADERS frame holds about 160,000 unique headers this way.</p>\n<p>What the server does with those 160,000+ headers is where it breaks.</p>\n<figure><img src=\"https://framerusercontent.com/images/PJKLXKYl6NoGQYS2wa5ipP2cBRk.png?width=1017&amp;height=1193\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p><em>Example request flow on quic-go</em></p>\n<p>The 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.</p>\n<h2 id=\"implementations\">Implementations</h2>\n<div class=\"table-wrap\"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody><tr><td>Implementation</td><td>Vulnerable</td><td>CVE</td><td>Wire-to-memory</td><td>Root cause</td></tr><tr><td>quic-go</td><td>Yes</td><td>CVE-2025-64702</td><td>~15x retained heap</td><td>Checks compressed size only, no decoded limit</td></tr><tr><td>Wireshark</td><td>Yes</td><td>CVE-2025-13945</td><td>crash</td><td>QPACK dissector unbounded decode</td></tr><tr><td>Cloudflare quiche</td><td>Yes</td><td>CVE-2026-12523</td><td>~20x</td><td>Missing 32-byte overhead + frame prealloc bomb</td></tr><tr><td>lsquic</td><td>Yes</td><td>Fixed, no CVE</td><td>~600-1800x</td><td>No cumulative decoded limit, unlimited HEADERS blocks</td></tr><tr><td>aioquic / mitmproxy</td><td>Yes</td><td>-</td><td>~20-30x</td><td>MAX_FIELD_SECTION_SIZE parsed but never enforced</td></tr><tr><td>Bun</td><td>Yes</td><td>-</td><td>~4000x</td><td>Replaces lsquic&#39;s 64K cap with no cap</td></tr><tr><td>h3 (hyperium)</td><td>Yes</td><td>Reported, not fixed</td><td>Thread panic</td><td>HeaderMap::append panics at 32,768 entries</td></tr><tr><td>Google quiche</td><td>No</td><td>-</td><td>-</td><td>60K cap enforced during decode, stream RESET on violation</td></tr><tr><td>IIS (http.sys)</td><td>No</td><td>-</td><td>-</td><td>Incremental enforcement, 4K QPACK table, H3_EXCESSIVE_LOAD</td></tr><tr><td>ngtcp2 / nghttp3</td><td>No</td><td>Reported, documentation updated</td><td>-</td><td>Dynamic table default 0, immediate decref; max_field_section_size is signaling-only</td></tr></tbody></table></div>\n<h2 id=\"quic-go\">quic-go</h2>\n<p>quic-go is the Go library behind Caddy, Traefik, and cloudflared. Its HTTP/3 server limits the HEADERS <em>frame</em> to 1 MiB (<code>DefaultMaxHeaderBytes</code>), but that limit applies to the compressed QPACK payload, not the decoded output. <code>DecodeFull</code> in <code>qpack/decoder.go</code> decompresses the entire block in one pass and returns a <code>[]HeaderField</code> slice, which is then converted into an <code>http.Header</code> map.</p>\n<pre><code>// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}</code></pre>\n<pre><code>// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}</code></pre>\n<pre><code>// qpack/decoder.go\nfunc (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {\n    var hf []HeaderField\n    d.emitFunc = func(f HeaderField) { hf = append(hf, f) }\n    if _, err := d.writeLocked(p); err != nil {\n        return nil, err\n    }\n    return hf, nil  // all 160K headers, no size check\n}</code></pre>\n<p>The <code>http.Header</code> map is a <code>map[string][]string</code>, 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.</p>\n<p>quic-go limited the compressed HEADERS frame without enforcing a corresponding decoded-header budget.</p>\n<p><strong>Fix</strong>: quic-go now sends <code>SETTINGS_MAX_FIELD_SECTION_SIZE</code>, uses incremental QPACK decoding that checks size after each entry, and aborts with HTTP 431 on the server side.</p>\n<h2 id=\"cloudflare-quiche\">Cloudflare quiche</h2>\n<p>quiche had two issues rolled into one CVE.</p>\n<p>First, <code>decode()</code> in <code>qpack/decoder.rs</code> uses <code>name.len() + value.len()</code> 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:</p>\n<pre><code>// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;</code></pre>\n<pre><code>// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;</code></pre>\n<pre><code>// quiche/src/h3/qpack/decoder.rs\nleft = left\n    .checked_sub((name.len() + value.len()) as u64)\n    .ok_or(Error::HeaderListTooLarge)?;</code></pre>\n<p>With <code>max_field_section_size = 1 MiB</code>, the RFC allows ~32,000 headers (each costing at least 33 bytes). quiche&#39;s calculation allows ~1,000,000 one-byte headers to pass.</p>\n<p>Second, quiche&#39;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.</p>\n<p><strong>Fix</strong>: quiche 0.29.3 adds the 32-byte overhead and fixes frame pre-allocation.</p>\n<h2 id=\"lsquic\">lsquic</h2>\n<p>lsquic&#39;s vulnerability is the deepest I found. Three issues compound:</p>\n<ol><li><em>header_out_dynamic_entry</em> allocates and copies per reference with no cumulative decoded-size limit</li><li><em>es_max_header_list_size</em> defaults to 0 (unlimited) and is never passed to the QPACK decoder</li><li>The 64 KiB header cap resets per HEADERS <strong>block</strong>, not per stream, and lsquic accepts unlimited HEADERS blocks before DATA on a single stream</li></ol>\n<p>Dynamic-table references produce greater amplification in this implementation:</p>\n<ol><li>Insert one ~4 KiB entry via the encoder stream.</li><li>On each request stream, send a chain of HEADERS frames, each containing 15 one-byte indexed references.</li></ol>\n<p>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 <code>memory.events</code> recording 900+ limit hits. The PoC defaults (10 connections, 256 MiB container) show the same effect at a smaller scale.</p>\n<p><strong>Fix</strong>: fixed in lsquic 4.9.0 (July 15, 2026), which adds <code>es_max_header_sets</code> (server default 1) to cap the decoded header sets buffered per stream and suspend HTTP/3 parsing until the application drains them.</p>\n<p>During testing, I also observed production servers, including <code>www.litespeedtech.com</code>, advertising <code>QPACK_MAX_TABLE_CAPACITY=4096</code> and <code>MAX_HEADER_LIST_SIZE=65536</code>.</p>\n<h2 id=\"bun\">Bun</h2>\n<p>Bun&#39;s HTTP/3 support uses lsquic under the hood but replaces lsquic&#39;s built-in header-set callbacks (which cap at 64 KiB cumulative) with its own <code>us_quic_hsi_prepare/process</code> in <code>quic.c</code>, which check per-header size (64 KiB) but never cumulative. The <code>es_max_header_list_size=16384</code> is advertised in SETTINGS but never enforced.</p>\n<p>Same dynamic table trick: 1 connection, 100 streams, up to 500K one-byte references per stream to a 4 KiB entry. Only 60K references per stream (5.8 MiB of wire in total) are enough to OOM-kill a 2 GiB container.</p>\n<p>Bun has a second bug in the same function that turns the amplification into a permanent denial of service. <code>us_quic_hsi_prepare</code> tracks buffer capacity in an <code>unsigned int</code> and grows it by doubling:</p>\n<pre><code>unsigned int need = h-&gt;len + (unsigned int) space;\nif (need &gt; h-&gt;cap) {\n    unsigned int ncap = h-&gt;cap ? h-&gt;cap : 512;\n    while (ncap &lt; need) ncap *= 2;\n    char *nb = (char *) us_realloc(h-&gt;buf, ncap);</code></pre>\n<pre><code>unsigned int need = h-&gt;len + (unsigned int) space;\nif (need &gt; h-&gt;cap) {\n    unsigned int ncap = h-&gt;cap ? h-&gt;cap : 512;\n    while (ncap &lt; need) ncap *= 2;\n    char *nb = (char *) us_realloc(h-&gt;buf, ncap);</code></pre>\n<pre><code>unsigned int need = h-&gt;len + (unsigned int) space;\nif (need &gt; h-&gt;cap) {\n    unsigned int ncap = h-&gt;cap ? h-&gt;cap : 512;\n    while (ncap &lt; need) ncap *= 2;\n    char *nb = (char *) us_realloc(h-&gt;buf, ncap);</code></pre>\n<p>Once the decoded header set passes 2 GiB, <code>need</code> exceeds 2^31 and <code>ncap</code> doubles through 2^31 to 0. From zero, <code>ncap *= 2</code> stays zero forever and <code>0 &lt; need</code> never becomes false: an infinite loop inside <code>lsquic_engine_process_conns</code>.</p>\n<p><strong>A single stream carrying ~516 KiB of wire (528,500 indexed references) leaves the server pinned at 100% CPU and it never recovers, blocking out new incoming requests.</strong></p>\n<p>The missing cumulative limit is what makes the overflow reachable; lsquic&#39;s stock 64 KiB cap prevents it.</p>\n<p>The fix for both issues is the same check (<code>if (h-&gt;len + space &gt; 16 * 1024) return NULL;</code>)</p>\n<h2 id=\"aioquic-mitmproxy\">aioquic / mitmproxy</h2>\n<p>aioquic is the Python HTTP/3 library used by mitmproxy and hypercorn. It defines <code>MAX_FIELD_SECTION_SIZE</code> as a setting constant, parses it from the peer, stores it yet never checks it during decoding. <code>_decode_headers()</code> returns all decoded headers unconditionally.</p>\n<p>aioquic&#39;s flow control also auto-doubles with no ceiling (<code>limit.value *= 2</code> when &gt;50% consumed), so a single connection can deliver unlimited data.</p>\n<p>The library itself only holds the decoded list transiently, so a minimal aioquic server that drops the headers after handling them survives, mitmproxy doesn&#39;t: it builds an <code>http.Request</code> per stream and keeps it while the stream is open.</p>\n<h2 id=\"h3-crate-hyperium\">h3 crate (hyperium)</h2>\n<p>h3 is the Rust HTTP/3 library from the hyperium project. It can be used with quinn through the h3-quinn adapter and is also used by tokio-quiche. It decodes all QPACK headers into a <code>Vec&lt;HeaderField&gt;</code> and then converts them into an <code>http::HeaderMap</code> via <code>TryFrom&lt;Vec&lt;HeaderField&gt;&gt;</code> in <code>h3/src/proto/headers.rs</code>:</p>\n<pre><code>// h3/src/proto/headers.rs\nField::Header((n, v)) =&gt; {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}</code></pre>\n<pre><code>// h3/src/proto/headers.rs\nField::Header((n, v)) =&gt; {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}</code></pre>\n<pre><code>// h3/src/proto/headers.rs\nField::Header((n, v)) =&gt; {\n    fields.append(n, v);  // Panics when HeaderMap exceeds MAX_SIZE\n}</code></pre>\n<p><code>HeaderMap::append</code> calls <code>try_append(...).expect(&quot;size overflows MAX_SIZE&quot;)</code>, causing a panic when the map’s capacity is exceeded. Under h3’s default configuration, <em>max_field_section_size</em> is effectively unlimited thus allowing an oversized decoded header set to reach this path. Configuring a sufficiently small decoded-size limit prevents this reproducer from reaching the map’s capacity limit. The resulting panic is shown below:</p>\n<pre><code>thread &#39;tokio-runtime-worker&#39; panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached</code></pre>\n<pre><code>thread &#39;tokio-runtime-worker&#39; panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached</code></pre>\n<pre><code>thread &#39;tokio-runtime-worker&#39; panicked at .../http-1.4.0/src/header/map.rs:526:43:\nsize overflows MAX_SIZE: MaxSizeReached</code></pre>\n<h2 id=\"wireshark\">Wireshark</h2>\n<p>Funnily enough, this one was inadvertently found while testing payloads for quic-go. The issue was lying in an uncontrolled number of allowed decoded headers and a slow <code>tvb_composite</code>, which froze and finally crashed the HTTP/3 dissector.</p>\n<h2 id=\"why-does-it-happen\">Why does it happen?</h2>\n<p>The harder question is who owns the limit. Decoding and storing headers can involve several components:</p>\n<ul><li><strong>QUIC</strong> accounts for bytes received on the stream. It has no visibility into the header objects those bytes become.</li><li><strong>The QPACK decoder</strong> reconstructs the fields. Depending on its API, it may enforce a decoded-size budget or leave that responsibility to its caller.</li><li><strong>The HTTP/3 layer and application</strong> turn those fields into request objects and retain them. Their containers have their own memory costs and capacity limits.</li></ul>\n<p>The nghttp3 case makes this boundary explicit: its setting advertises a limit, but the application must enforce it. h3 illustrates a different boundary: a decoded field can pass through the decoder and later trigger a panic during insertion into HeaderMap.</p>\n<p>The question is simple: which component actually rejects the oversized headers? If that responsibility belongs to the application, it needs to know and enforce the limit before retaining them.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>A small HEADERS frame can represent a lot of work. Across the implementations tested, that work turned into large allocations, retained header sets, or a panic when a downstream container reached its limit. The protocol already provides a way to account for decoded header size, but the receiving code has to enforce it.</p>\n<p>Checking compressed frame size is merely the starting point. The decoded budget needs to include the per-field overhead, apply while headers are being produced, and cover what the application retains. Several projects have now fixed these paths. The remaining findings show how an advertised limit can look reassuring while providing no protection against the input it was meant to bound.</p>\n<h2 id=\"finding-affected-servers\">Finding affected servers</h2>\n<p>To find impacted publicly facing servers in your fleet, here are the JA4Scan-QUIC fingerprints that map to the different servers:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Library</th><th><strong>JA4Scan-QUIC</strong></th></tr></thead><tbody><tr><td>quic-go v0.56.0</td><td>031g0hlss000_5973261134f6_6d6a17008abe_sr10sc7b63be</td></tr><tr><td>quiche 0.29.2</td><td>03100hlss200_8960528bd01f_6d6a17d04082_sr20ss7fb1bf</td></tr><tr><td>OLS 1.9.1 (lsquic 4.8.2)</td><td>032g0h0ss200_24e1d2569964_d1082305b91e_0r100s0b3f78</td></tr><tr><td>Bun 1.4.3</td><td>032g0h0ss200_756c1a5a0530_67f99a05b91e_s010s0fecef0</td></tr><tr><td>aioquic 1.7.0</td><td>0310000ss000_2a673bc4abf8_db4d1fe4882a_sr1s0seb0750</td></tr><tr><td>mitmproxy 12.2.3</td><td>0310000ss000_2a673bc4abf8_aee409e4882a_sr2s0seb0750</td></tr><tr><td>h3 0.0.8</td><td>031g0hlsr200_8960528bd01f_6d6a17129383_se10s0fecef0</td></tr></tbody></table></div>\n<p>Read the full blog post about JA4Scan here: <a href=\"https://foxio.io/blog/introducing-ja4scan-active-server-fingerprinting-for-tls-and-quic\" rel=\"nofollow ugc noopener\">https://foxio.io/blog/introducing-ja4scan-active-server-fingerprinting-for-tls-and-quic</a></p>\n<h2 id=\"poc\">POC</h2>\n<p>Reproduction code is available at <a href=\"https://github.com/FoxIO-LLC/blog_poc/tree/main/headeraccordions\" rel=\"nofollow ugc noopener\">https://github.com/FoxIO-LLC/blog_poc/tree/main/headeraccordions</a></p>\n<h2 id=\"timeline\">Timeline</h2>\n<ul><li>Nov 9 2025 - quic-go report</li><li>Nov 14 2025 - Wireshark report</li><li>Nov 15 2025 - quiche report</li><li>Nov 21 2025 - quic-go 0.57.0 released</li><li>Dec 3 2025 - Wireshark 4.6.2 release</li><li>Dec 4 2025 - h3 report</li><li>Dec 5 2025 - h3 maintainer closes issue - alpha library + default setting to be changed</li><li>Dec 17 2025 - nghttp3 documents <em>max_field_section_size</em> as signaling-only</li><li>May 24 2026 - Bun report</li><li>July 13 2026 - aioquic report</li><li>July 13 2026 - lsquic report</li><li>July 14 2026 - quiche 0.29.3 released</li><li>Oct 20 2026 - blog published</li></ul>\n<h2 id=\"references\">References</h2>\n<ul><li>RFC 9114 - HTTP/3, Section 4.2.2 (Header Size Constraints)</li><li>RFC 9204 - QPACK: Field Compression for HTTP/3</li><li>nghttp3 - Clarify field size limits: <a href=\"https://github.com/ngtcp2/nghttp3/pull/445\" rel=\"nofollow ugc noopener\">https://github.com/ngtcp2/nghttp3/pull/445</a></li><li>CVE-2025-64702 (quic-go): <a href=\"https://github.com/quic-go/quic-go/security/advisories/GHSA-g754-hx8w-x2g6\" rel=\"nofollow ugc noopener\">https://github.com/quic-go/quic-go/security/advisories/GHSA-g754-hx8w-x2g6</a></li><li>CVE-2025-13945 (Wireshark): <a href=\"https://www.wireshark.org/security/wnpa-sec-2025-07.html\" rel=\"nofollow ugc noopener\">https://www.wireshark.org/security/wnpa-sec-2025-07.html</a></li><li>CVE-2026-12523 (Cloudflare quiche): <a href=\"https://github.com/cloudflare/quiche/security/advisories/GHSA-4fgf-9xrr-88gf\" rel=\"nofollow ugc noopener\">https://github.com/cloudflare/quiche/security/advisories/GHSA-4fgf-9xrr-88gf</a></li><li>Gal Bar Nahum - MadeYouReset HTTP/2: <a href=\"https://galbarnahum.com/made-you-reset\" rel=\"nofollow ugc noopener\">https://galbarnahum.com/made-you-reset</a></li></ul>","headings":[{"level":1,"text":"Header Accordions: QPACK amplification attacks against HTTP/3 servers","id":"header-accordions-qpack-amplification-attacks-against-http-3-ser"},{"level":2,"text":"TLDR","id":"tldr"},{"level":2,"text":"Background","id":"background"},{"level":2,"text":"How does it work?","id":"how-does-it-work"},{"level":2,"text":"Implementations","id":"implementations"},{"level":2,"text":"quic-go","id":"quic-go"},{"level":2,"text":"Cloudflare quiche","id":"cloudflare-quiche"},{"level":2,"text":"lsquic","id":"lsquic"},{"level":2,"text":"Bun","id":"bun"},{"level":2,"text":"aioquic / mitmproxy","id":"aioquic-mitmproxy"},{"level":2,"text":"h3 crate (hyperium)","id":"h3-crate-hyperium"},{"level":2,"text":"Wireshark","id":"wireshark"},{"level":2,"text":"Why does it happen?","id":"why-does-it-happen"},{"level":2,"text":"Conclusion","id":"conclusion"},{"level":2,"text":"Finding affected servers","id":"finding-affected-servers"},{"level":2,"text":"POC","id":"poc"},{"level":2,"text":"Timeline","id":"timeline"},{"level":2,"text":"References","id":"references"}]}}