back

Mapping aioquic's DoS surface

2026.09

aioquic is the reference QUIC and HTTP/3 stack for Python. It backs test servers, tooling, and a fair amount of production traffic, and it implements both the transport (QUIC) and the application layer (HTTP/3) in one library. That makes it a single target where a client can reach two protocol layers worth of parsing, state machines, and buffers.

This is a single-file harness that drives eight denial-of-service probes at an aioquic server, each isolating one mechanism. It reports what it sends and leaves the impact to be read off the target's own dashboard, so a vector counts only when a bounded amount of attacker work moves a number on the other side.

The harness

One process, one thread per vector, a threading.Event to stop them all. --x N runs N copies of a vector in parallel, and each thread that dies gets restarted by a safe() wrapper so a crash on the attacker side does not end the run. The stats line accounts three quantities every second: bytes sent, packets sent, and units, where a unit is one logical piece of work handed to the server (a request, a stream, a connection ID). Bytes measure the attacker's cost; units measure the server's, and the gap between them is the amplification.

target 10.0.0.5:4433  attacks=['01', 'hs']  x4  (Ctrl-C to stop)
  +   12s  sent=    18.4 MB  pkts=    41203  units=   983120

Two connection drivers sit underneath. Most vectors use aioquic's asyncio connect() wrapper and reach into _quic for frame-level sends. Two need to forge frames the API will not emit on its own, so they use MC, a hand-rolled driver over a raw UDP socket that completes the handshake by pumping datagrams_to_send, receive_datagram, and get_timer by hand. All configs set verify_mode = 0 and ALPN h3, so no vector spends effort on certificate checking.

An unraisable-exception hook filters two aioquic internal errors, "after FIN" and "peer-initiated unidirectional." Both are the library raising on states these probes drive it into, and silencing them on the client keeps the harness output readable.

HTTP/3 application-layer vectors

01, header decompression flood. HTTP/3 compresses headers with QPACK, where a one-byte indexed field line expands into a full header field on the decoder. bomb(n) builds a HEADERS frame that opens with a valid request prefix (:method GET, :scheme https, :path /, :authority a) and then appends n copies of a single-byte indexed reference to one static-table entry:

def bomb(n):
    b = bytearray([0x00, 0x00, 0xC0 | 17, 0xC0 | 23, 0xC0 | 1, 0x50, 0x01, 0x61])
    b += bytes([0xC0 | STS]) * n          # STS = 58
    return bytes(b)

At n = 80000 the frame is about 80 KB on the wire and expands to 80,000 header fields in the decoder, on a fresh stream, twenty times a second. The attacker sends bytes and the server materializes a header list two orders of magnitude larger before any size limit it enforces gets a chance to reject it.

03, oversized frame length. An HTTP/3 frame is a type varint, a length varint, then the payload. This vector opens a HEADERS frame that declares a length of 2^40, roughly a terabyte, then feeds it 64 KB of A at a time and never ends it:

q.send_stream_data(sid, encode_uint_var(1) + encode_uint_var(2**40) + b"A" * 4096, ...)
# then, forever:
q.send_stream_data(sid, b"A" * 65536, end_stream=False)

A HEADERS frame has to be complete before QPACK can decode it, so a decoder that buffers toward the declared length accumulates stream data with no natural stopping point. The declared length is a promise the server has no way to verify and every reason to distrust.

req, request-rate flood. One connection, 60 complete minimal GET requests every 10 milliseconds, each a valid HEADERS frame with FIN. No oversized payload and no malformed frame, just 6,000 well-formed requests a second down a single connection, against whatever per-connection stream concurrency and request-handling budget the server has.

02, unidirectional stream churn. The first byte of a client unidirectional stream is its type. 0x21 is a reserved (greased) type the server must read and then discard, but not before it allocates stream state. This vector opens 150 such streams per pass, each one byte plus FIN, stepping the stream id by 4:

for _ in range(150):
    q.send_stream_data(sid, b"\x21", end_stream=True)
    sid += 4

Every stream is complete and tiny, so the server spends its time on creation and teardown rather than on any payload. At 150 per 20 milliseconds the churn is the load.

QUIC transport vectors

04, connection-ID retirement amplification. QUIC lets an endpoint issue new connection IDs and retire old ones. A NEW_CONNECTION_ID frame carries a sequence number and a retire_prior_to field, and the invariant is retire_prior_to <= sequence_number. This vector monkeypatches the frame writer to set them equal, so every frame demands the peer retire everything below its own sequence number:

buf.push_uint_var(cid.sequence_number)   # sequence_number
buf.push_uint_var(cid.sequence_number)   # retire_prior_to, normally lower

It then appends 12 forged host CIDs per pass with climbing sequence numbers and flushes. Each frame obliges the server to retire prior CIDs and answer with RETIRE_CONNECTION_ID frames, so a steady trickle of NEW_CONNECTION_ID drives a larger stream of retirement work and state churn on the receiver. This is the vector that needs the manual driver, because the aioquic API will not emit a frame that violates the invariant.

05, path-validation flood. QUIC identifies a connection by its connection ID, not its 4-tuple, so a packet that arrives from a new source address on a known connection reads as connection migration and triggers path validation. This vector sends a PING and then transmits each resulting datagram from a freshly opened UDP socket, so every packet appears to arrive on a new path:

for data, addr in dg:
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.sendto(data, addr)
    ...
    s.close()

Each apparent migration asks the server to start path validation and reset its anti-amplification budget for the new path, so per-packet transport work replaces per-connection work. A cwnd stall guard bails out if the congestion controller stops handing back datagrams, so the probe never starts measuring itself.

hs, handshake flood. The expensive half of QUIC is the handshake: parse the ClientHello, derive keys, assemble a ServerHello and certificate chain. This vector creates 30 fresh connections per pass, sends each one's Initial packets, and throws the connection away:

q = QuicConnection(configuration=_cfg())
q.connect(dst, now=time.time())
for data, addr in q.datagrams_to_send(now=time.time()):
    s.sendto(data, addr)

Every connection carries a new destination CID, so the server treats each as new, does the crypto, and answers with a response several times larger than the Initial that prompted it. The handshake is where a small client packet buys the most server work, and this vector does nothing else.

udp, volumetric baseline. 1200-byte random datagrams, 400 at a time, straight at the QUIC port. Not a protocol attack. It is the control that says how much of any observed impact is just packets arriving and being parsed as malformed QUIC before anything smarter runs.

The surface, in one view

vectorlayermechanismserver resource
01HTTP/3QPACK indexed-line expansiondecoder CPU and memory
03HTTP/3oversized declared frame lengthstream buffer memory
reqHTTP/3valid request ratestream and request budget
02HTTP/3unidirectional stream churnstream state
04QUICretire_prior_to amplificationCID state, retirement frames
05QUICforced path validationpath state, crypto
hsQUICInitial and handshake floodhandshake crypto, amplification
udpnonevolumetricsocket and parse

The split matters for a defender: the HTTP/3 vectors reach a server only after a handshake, so they are bounded by whatever admits a connection, while the transport vectors hit before or around the handshake and are reachable by any source that can send a packet.

What holds each one off

None of these is exotic, and each maps to a defense the QUIC and HTTP/3 specifications already name. A header-list size limit enforced during decode, not after, caps 01. A ceiling on HEADERS frame length, checked against the declared length before buffering, caps 03. Per-connection stream and request rate limits cap req and 02. Validating retire_prior_to <= sequence_number and bounding the rate of connection-ID changes caps 04. Rate-limiting path validation and holding new paths to the anti-amplification limit caps 05. A Retry token and Initial rate limiting cap hs. The volumetric baseline is the one the application cannot fix, and it belongs upstream.

The harness is a probe, not a proof of any single finding. It says which mechanisms are reachable and what a bounded amount of attacker work costs the server, measured on the target rather than asserted. That is enough to rank the surface and to check, after a fix lands, that the number it moved stops moving.