Assessment after the experiments in this repository. See
docs/03-results.md and docs/04-aiohttp-cve.md for the underlying
proofs; docs/02-harness.md for the payload set.
A single pre-auth POST with Content-Encoding: br gives an attacker:
- Worker crash (SIGSEGV) — deterministic, both backends
(
python3-brotliandpython3-brotlicffi), both directions (request body and response body). Under a multi-worker deployment (gunicorn & co.) this kills one worker that the master respawns → a crash-loop DoS rather than full server death. - Remote DoS hang —
bad.brplus ≥ 3 trailing bytes spins the decoder forever insideBrotliDecoderDecompressStream; the handler never returns and the event-loop thread is lost. - Silent heap corruption — the attacker-steered LZ77 write lands in mapped memory and the request completes normally. For a long-running process this is the nastiest state: corruption accumulates and crashes surface later, unpredictably.
The gap is exploitation engineering, not the bug:
- The write content is not freely chosen. The lab's
system()chain (/root/brotli/lab-victim) planted&systemand a command pointer at the memcpy source addresses. The plain path'sWrapRingBuffermemcpy copies whatever heap bytes sit atringbuffer_end + delta— in our single-request experiments, zeros. A random app's heap must happen to contain useful pointer bytes at the right relative offset. - No info leak yet. The one leak attempted (ring garbage echoed back through the request body) was all zeros. An OOB read — a huge distance making the LZ77 copy pull from heap before the ring into the decoder output — looks feasible but is untried. That is the missing first stage of a remote chain.
- Heap layout is app-specific. We measured
state − ringgaps varying between0xD170and0x192C0across runs, driven by output-buffer churn. An attacker can shape this remotely (concurrent or prior requests, request sizing) and probe with crash/no-crash feedback, but that is real, per-target work.
- The plain path has a natural execution trigger: at request
teardown aiohttp drops the
BrotliDecompressor, andBrotliDecoderDestroyInstanceends withfree_func(opaque, state)— a call through a function pointer stored inside the decoder state. No special victim code is needed; a hijackedfree_funcwould be invoked on every request's cleanup. - The decoder state itself sits within reach of the LZ77 write
(
ringbuffer + 0xD170 … 0x192C0in observed layouts), so overwriting the state's ownfree_func/alloc_funcis geometrically possible.
-
Exposure is conditional. aiohttp only auto-decompresses
brwhenpython3-brotliorpython3-brotlicffiis installed; without a backend a br body is rejected (415) and no vulnerable decoder runs. -
Payload size vs aiohttp's default
client_max_size(1 MiB). The current payloads need a 23 MB body (2²⁴ drain commands), which our victims accepted only because they raisedclient_max_size. A default-configured app would answer 413 before the decoder ever sees the tail. The recv direction (client fetching a response) has no size cap, so the client-victim scenario is less gated.The 2²⁴-command drain is a construction artifact of the current trigger, not necessarily fundamental: the block-length code can encode small values, so a variant whose invalid switch fires after a few commands (sub-KB payload) may exist. Whether the whole chain (tail copy → wrap arm → memcpy) survives such a short drain is an open question — if it does, the size-cap problem disappears and heap grooming gets much easier.
Today, a random aiohttp app reliably suffers a pre-auth crash/DoS
(and possibly creeping heap corruption), not RCE. RCE is plausible for a
targeted deployment where the attacker can learn the heap layout and
build a leak. The --mlen discovery (MLEN ≥ copy length keeps the
decode alive past the OOB write, letting WrapRingBuffer's memcpy fire
through the plain public API — bad-memcpy.br, glibc
double free or corruption) removed one of the two structural blockers.
- Small payload: try small block-length encodings so the invalid switch fires after a handful of commands; re-calibrate the tail and verify the chain end-to-end.
- OOB-read leak: use a huge distance so the LZ77 copy pulls heap
bytes from before the ring into the decoder output; check whether
pointer-bearing regions (other decoders' states, freed unsorted
chunks with
main_arenapointers) can be reached and read back. - Heap grooming + relocation: with a leak, compute the runtime
posfor a chosen target, groom the aiohttp process heap with prior/concurrent requests, and aim theWrapRingBuffermemcpy so it relocates a real code pointer onto a function-pointer slot that the request-teardown path will call.