Header Accordions: QPACK-amplificatieaanvallen tegen HTTP/3-servers
TL;DR
Een onbevoegde client op afstand kan een HTTP/3-server dwingen om tientallen tot duizenden keren meer geheugen toe te wijzen dan er daadwerkelijk verzonden wordt. Eén enkele verbinding is voldoende om een server met standaardconfiguratie te laten crashen door een Out of Memory (OOM) fout of om de resources volledig te bezetten. Zeven van de tien geteste implementaties zijn kwetsbaar; verschillende zijn inmiddels hersteld.
QPACK comprimeert HTTP/3-headers om het netwerkverkeer te minimaliseren. In de getroffen implementaties ontbreken limieten voor gedecodeerde headers, zijn deze onvolledig of worden ze te laat toegepast. Een klein HEADERS-frame kan zo uitbreiden tot honderdduizenden header-objecten in het geheugen. Om te voorkomen dat deze expansie de resources uitput, is het noodzakelijk om een cumulatief budget af te dwingen terwijl velden worden gedecodeerd en bewaard.
Status van patches (per 20 oktober 2026)
Herstelde bibliotheken
| Bibliotheek | Gefixte versie | Advisory |
|---|---|---|
| quic-go | 0.57.0 | CVE-2025-64702 |
| Cloudflare quiche | 0.29.3 | CVE-2026-12523 |
| lsquic | 4.9.0 | Geen CVE |
| Wireshark | 4.6.2 / 4.4.12 | CVE-2025-13945 |
Niet herstelde bibliotheken
- Bun 1.4.3
- h3 (hyperium)
- aioquic / mitmproxy
Mitigaties
- Bun: Stel HTTP/3-servers niet bloot aan het internet.
- mitmproxy: Schakel HTTP/3 uit (gebruik
--set http3=false). - h3 crate: Stel een redelijke
maxfieldsection_sizein. - Niet getroffen: IIS, Envoy, ngtcp2/nghttp3.
---
Achtergrond
Tijdens recent onderzoek naar verschillende QUIC-stacks voor onze actieve TLS-scanner, JA4Scan, stuitte ik op dezelfde klasse kwetsbaarheid in meerdere HTTP/3-implementaties. Een onbevoegde client kan HTTP/3-verkeer verzenden dat de server dwingt om aanzienlijk meer geheugen toe te wijzen dan de gecodeerde aanvraag op het netwerk inneemt.
Dit onderzoek begon na het lezen van de MadeYouReset-serie van Gal Bar Nahum over HTTP/2 request-cancellation DoS. Dat bracht me ertoe te onderzoeken of de QPACK-headercompressie van HTTP/3 soortgelijke amplificatieproblemen vertoonde.
HTTP/3 comprimeert headers met behulp van QPACK, waardoor herhaalde velden zoals user-agent of content-type kunnen worden verzonden als een kleine index in plaats van de volledige string. Hierbij worden twee opzoektabelen gebruikt:
- Statische tabel: Een hardcoded set van veelvoorkomende velden, gedefinieerd door de specificatie.
- Dynamische tabel: Deze wordt tijdens runtime opgebouwd tussen client en server wanneer nieuwe headers worden waargenomen.
Voorbeeld: een header van 71 bytes, zoals strict-transport-security: max-age=31536000; includesubdomains; preload, wordt een enkele byte: 0xFA (een referentie naar index 58 in de statische tabel).
RFC 9114 Sectie 4.2.2 definieert hoe servers de grootte van gedecodeerde headers moeten beperken:
De grootte van een veldenlijst wordt berekend op basis van de ongecomprimeerde grootte van de velden, inclusief de lengte van de naam en waarde in bytes, plus een overhead van 32 bytes voor elk veld.
Sommige geteste implementaties maakten één of meer van de volgende fouten:
- Ze controleren de grootte van het gecomprimeerde frame in plaats van de gedecodeerde grootte.
- Ze decompresseren alles voordat ze de controle uitvoeren.
- Ze laten de overhead van 32 bytes weg in hun grootteberekening.
- Ze staan standaard "onbeperkt" toe wanneer de instelling niet is geconfigureerd.
- Ze dwingen de instelling helemaal niet af, zelfs niet wanneer deze is geconfigureerd.
---
Hoe werkt het?
Het doel is om het aantal headers binnen één enkel HEADERS-frame te maximaliseren. Voor deze variant gebruik ik unieke namen om verschillende vermeldingen in de header-map van de ontvanger te creëren: a tot z, aa tot zz, en vervolgens namen van drie en vier tekens totdat het budget voor de gecodeerde grootte is gevuld. (Er zijn 676 mogelijke namen van twee letters, maar te moet worden overgeslagen omdat deze header speciale waarde-vereisten heeft).
Een HEADERS-frame van 1.000.000 bytes bevat op deze manier ongeveer 160.000 unieke headers. De manier waarop de server met deze 160.000+ headers omgaat, is waar het misgaat.
De "accordeon" werkt ook in de andere richting. In plaats van veel kleine headers, voegt de variant met de dynamische tabel één grote vermelding (~4 KiB) in via de encoder-stream, om deze vervolgens duizenden keren te refereren met 1-byte index-referenties. Elke referentie dwingt een nieuwe allocatie en kopie van de volledige vermelding af. Dit is hoe de aanvallen op lsquic en Bun werken: elke referentie van 1 byte materialiseert de volledige vermelding, wat resulteert in een ratio van 4064:1.
---
Implementaties
Overzicht kwetsbaarheden
| Implementatie | Kwetsbaar | CVE | Wire-to-memory | Oorzaak |
|---|---|---|---|---|
| quic-go | Ja | CVE-2025-64702 | ~15x heap | Controleert alleen gecomprimeerde grootte |
| Wireshark | Ja | CVE-2025-13945 | crash | QPACK dissector onbegrensde decode |
| Cloudflare quiche | Ja | CVE-2026-12523 | ~20x | Mist 32-byte overhead + prealloc bomb |
| lsquic | Ja | Gefixed (geen CVE) | ~600-1800x | Geen cumulatieve limiet, onbeperkte blocks |
| aioquic / mitmproxy | Ja | - | ~20-30x | MAXFIELDSECTION_SIZE niet afgedwongen |
| Bun | Ja | - | ~4000x | Vervangt 64K cap van lsquic door geen cap |
| h3 (hyperium) | Ja | Gemeld, niet gefixt | Thread panic | HeaderMap::append panic bij 32.768 entries |
| Google quiche | Nee | - | - | 60K cap afgedwongen tijdens decode |
| IIS (http.sys) | Nee | - | - | Incrementele handhaving, 4K tabel |
| ngtcp2 / nghttp3 | Nee | - | - | Dynamische tabel default 0 |
Gedetailleerde analyse
quic-go
quic-go is de Go-bibliotheek achter Caddy, Traefik en cloudflared. De HTTP/3-server beperkt het HEADERS-frame tot 1 MiB (DefaultMaxHeaderBytes), maar deze limiet geldt voor de gecomprimeerde QPACK-payload, niet voor de gedecodeerde output. DecodeFull in qpack/decoder.go decompressere het gehele blok in één keer.
// qpack/decoder.go
func (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {
var hf []HeaderField
d.emitFunc = func(f HeaderField) { hf = append(hf, f) }
if _, err := d.writeLocked(p); err != nil {
return nil, err
}
return hf, nil // alle 160K headers, geen groottecontrole
}
De http.Header map (map[string][]string) voegt extra overhead toe aan de opslag. Een QPACK-blok van 1.000.000 bytes decodeert naar 160.075 velden en nam na garbage collection ongeveer 15 MiB in beslag—ongeveer 15 keer de gecodeerde grootte.
Fix: quic-go verzendt nu SETTINGSMAXFIELDSECTIONSIZE, gebruikt incrementele QPACK-decodering die de grootte na elk item controleert, en breekt af met HTTP 431 aan de serverzijde.
Cloudflare quiche
quiche had twee problemen in één CVE. Ten eerste gebruikt decode() in qpack/decoder.rs name.len() + value.len() voor de grootteberekening, waarbij de RFC-verplichte overhead van 32 bytes per veld wordt weggelaten.
// quiche/src/h3/qpack/decoder.rs
left = left
.checked_sub((name.len() + value.len()) as u64)
.ok_or(Error::HeaderListTooLarge)?;
Bij een maxfieldsection_size van 1 MiB staat de RFC ongeveer 32.000 headers toe. De berekening van quiche laat echter ongeveer 1.000.000 headers van één byte door.
Ten tweede reserveert de HTTP/3-frame parser van quiche buffers op basis van de opgegeven framelengte voordat de payload is ontvangen. Een aanvaller kan een SETTINGS of GOAWAY-frame van 16 MiB declareren, wat onmiddellijke geheugenallocatie triggert, om vervolgens nooit de data te verzenden.
Fix: quiche 0.29.3 voegt de 32-byte overhead toe en herstelt de frame pre-allocatie.
lsquic
De kwetsbaarheid in lsquic is zeer diepgaand door drie samenlopende problemen:
headeroutdynamic_entryalloceert en kopieert per referentie zonder cumulatieve limiet voor de gedecodeerde grootte.esmaxheaderlistsizestaat standaard op 0 (onbeperkt) en wordt nooit doorgegeven aan de QPACK-decoder.- De limiet van 64 KiB voor headers wordt per
HEADERS-blok gereset in plaats van per stream, enlsquicaccepteert onbeperkteHEADERS-blokken vóór deDATAop een enkele stream.
Minder dan 10 MiB aan netwerkverkeer over 50 verbindingen was voldoende om het resident geheugen binnen een seconde op de limiet van 5 GiB te pinnen.
Fix: Hersteld in lsquic 4.9.0 (15 juli 2026), waarbij esmaxheader_sets (server default 1) is toegevoegd om het aantal gedecodeerde header-sets per stream te beperken.
Bun
Bun gebruikt lsquic onder de motorkap, maar vervangt de ingebouwde callbacks voor header-sets (die beperkt zijn tot 64 KiB) door eigen functies in quic.c. Deze controleren de grootte per header (64 KiB), maar nooit de cumulatieve grootte.
Daarnaast bevat Bun een bug die de amplificatie verandert in een permanente Denial of Service (DoS). De functie usquichsi_prepare volgt de buffercapaciteit in een unsigned int en verdubbelt deze:
unsigned int need = h->len + (unsigned int) space;
if (need > h->cap) {
unsigned int ncap = h->cap ? h->cap : 512;
while (ncap < need) ncap *= 2;
char *nb = (char *) us_realloc(h->buf, ncap);
}
Zodra de gedecodeerde header-set 2 GiB overschrijdt, overschrijdt need $2^{31}$ en verdubbelt ncap via $2^{31}$ naar 0. Vanaf nul blijft ncap *= 2 altijd nul, waardoor er een oneindige lus ontstaat in lsquicengineprocess_conns. Dit laat de server hangen op 100% CPU-gebruik.
Fix: De fix voor beide problemen is dezelfde controle: if (h->len + space > 16 * 1024) return NULL;.
aioquic / mitmproxy
aioquic is de Python HTTP/3-bibliotheek gebruikt door mitmproxy en hypercorn. Het definieert MAXFIELDSECTION_SIZE als een constante en parseert deze van de peer, maar controleert deze nooit tijdens het decoderen.
Omdat de flow-control van aioquic ook automatisch verdubbelt zonder plafond, kan één verbinding onbeperkte data leveren. mitmproxy is hierdoor kwetsbaar omdat het een http.Request per stream opbouwt en bewaart zolang de stream open is.
h3 crate (hyperium)
h3 is de Rust HTTP/3-bibliotheek van het Hyperium-project. Het decodeert alle QPACK-headers naar een Vec<HeaderField> en converteert deze vervolgens naar een http::HeaderMap.
// h3/src/proto/headers.rs
Field::Header((n, v)) => {
fields.append(n, v); // Panics wanneer HeaderMap MAX_SIZE overschrijdt
}
HeaderMap::append roept tryappend(...).expect("size overflows MAXSIZE") aan, wat leidt tot een panic wanneer de capaciteit van de map is bereikt. Onder de standaardconfiguratie is maxfieldsection_size in feite onbeperkt.
Wireshark
Dit probleem werd per toeval ontdekt. De oorzaak lag in een ongecontroleerd aantal toegestane gedecodeerde headers en een trage tvb_composite, waardoor de HTTP/3-dissector bevroor en uiteindelijk crashte.
---
Waarom gebeurt dit?
De kernvraag is: wie is verantwoordelijk voor de limiet? Het decoderen en opslaan van headers omvat verschillende componenten:
- QUIC: Houdt rekening met de bytes die op de stream zijn ontvangen, maar heeft geen zicht op de header-objecten die deze bytes worden.
- QPACK-decoder: Reconstrueert de velden. Afhankelijk van de API kan deze een budget voor gedecodeerde grootte afdwingen of die verantwoordelijkheid overlaten aan de aanroeper.
- HTTP/3-laag en applicatie: Zet velden om in request-objecten en bewaart deze. Hun containers hebben eigen geheugenkosten en capaciteitslimieten.
Het voorbeeld van nghttp3 maakt deze grens expliciet: de instelling kondigt een limiet aan, maar de applicatie moet deze afdwingen. h3 illustreert een andere grens: een gedecodeerd veld kan door de decoder komen en later een panic triggeren bij invoeging in de HeaderMap.
De vraag is simpel: welk component wijst de oversized headers daadwerkelijk af? Als die verantwoordelijkheid bij de applicatie ligt, moet deze de limiet kennen en afdwingen vóórdat de headers worden bewaard.
---
Conclusie
Een klein HEADERS-frame kan veel werk veroorzaken. In de geteste implementaties vertaalde dit werk zich in grote allocaties, bewaarde header-sets of een panic wanneer een downstream-container zijn limiet bereikte. Het protocol biedt al een manier om de grootte van gedecodeerde headers bij te houden, maar de ontvangende code moet dit ook afdwingen.
Het controleren van de grootte van het gecomprimeerde frame is slechts het startpunt. Het budget voor gedecodeerde data moet de overhead per veld bevatten, worden toegepast terwijl headers worden geproduceerd, en alles dekken wat de applicatie bewaart.
---
Getroffen servers vinden
Om publiek toegankelijke servers in uw netwerk te vinden die hier gevoelig voor zijn, kunnen de volgende JA4Scan-QUIC fingerprints worden gebruikt:
| Bibliotheek | JA4Scan-QUIC |
|---|---|
| quic-go v0.56.0 | 031g0hlss0005973261134f66d6a17008abe_sr10sc7b63be |
| quiche 0.29.2 | 03100hlss2008960528bd01f6d6a17d04082_sr20ss7fb1bf |
| OLS 1.9.1 (lsquic 4.8.2) | 032g0h0ss20024e1d2569964d1082305b91e_0r100s0b3f78 |
| Bun 1.4.3 | 032g0h0ss200756c1a5a053067f99a05b91e_s010s0fecef0 |
| aioquic 1.7.0 | 0310000ss0002a673bc4abf8db4d1fe4882a_sr1s0seb0750 |
| mitmproxy 12.2.3 | 0310000ss0002a673bc4abf8aee409e4882a_sr2s0seb0750 |
| h3 0.0.8 | 031g0hlsr2008960528bd01f6d6a17129383_se10s0fecef0 |
---
PoC en Tijdlijn
Reproductiecode: Beschikbaar op GitHub FoxIO.
Tijdlijn
- 9 nov 2025: Rapportage quic-go
- 14 nov 2025: Rapportage Wireshark
- 15 nov 2025: Rapportage quiche
- 21 nov 2025: quic-go 0.57.0 uitgebracht
- 3 dec 2025: Wireshark 4.6.2 release
- 4 dec 2025: Rapportage h3
- 5 dec 2025: h3 maintainer sluit issue (alpha library + standaardinstelling wordt gewijzigd)
- 17 dec 2025: nghttp3 documenteert
maxfieldsection_sizeals "signaling-only" - 24 mei 2026: Rapportage Bun
- 13 jul 2026: Rapportage aioquic en lsquic
- 14 jul 2026: quiche 0.29.3 uitgebracht
- 20 okt 2026: Publicatie blog
Referenties
- RFC 9114 - HTTP/3, Section 4.2.2 (Header Size Constraints)
- RFC 9204 - QPACK: Field Compression for HTTP/3
- nghttp3 - Clarify field size limits: GitHub PR
- CVE-2025-64702 (quic-go): GitHub Advisory
- CVE-2025-13945 (Wireshark): Wireshark Security
- CVE-2026-12523 (Cloudflare quiche): GitHub Advisory
- Gal Bar Nahum - MadeYouReset HTTP/2: galbarnahum.com
Groetjes,