Skip to content

Instantly share code, notes, and snippets.

@st3fan
Created October 27, 2026 17:44
Show Gist options
  • Select an option

  • Save st3fan/e32e6abd5abd168c74ab867cf17b4d71 to your computer and use it in GitHub Desktop.

Select an option

Save st3fan/e32e6abd5abd168c74ab867cf17b4d71 to your computer and use it in GitHub Desktop.

Exploitability of CVE-2026-111497 in a real aiohttp deployment

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.

What a random aiohttp app gets today (reliable)

A single pre-auth POST with Content-Encoding: br gives an attacker:

  1. Worker crash (SIGSEGV) — deterministic, both backends (python3-brotli and python3-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.
  2. Remote DoS hang — bad.br plus ≥ 3 trailing bytes spins the decoder forever inside BrotliDecoderDecompressStream; the handler never returns and the event-loop thread is lost.
  3. 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.

Why RCE in a random app is not yet demonstrated

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 &system and a command pointer at the memcpy source addresses. The plain path's WrapRingBuffer memcpy copies whatever heap bytes sit at ringbuffer_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 − ring gaps varying between 0xD170 and 0x192C0 across 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.

Two facts that help a would-be exploiter

  • The plain path has a natural execution trigger: at request teardown aiohttp drops the BrotliDecompressor, and BrotliDecoderDestroyInstance ends with free_func(opaque, state) — a call through a function pointer stored inside the decoder state. No special victim code is needed; a hijacked free_func would be invoked on every request's cleanup.
  • The decoder state itself sits within reach of the LZ77 write (ringbuffer + 0xD170 … 0x192C0 in observed layouts), so overwriting the state's own free_func/alloc_func is geometrically possible.

Practical caveats for the "random app" scenario

  1. Exposure is conditional. aiohttp only auto-decompresses br when python3-brotli or python3-brotlicffi is installed; without a backend a br body is rejected (415) and no vulnerable decoder runs.

  2. 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 raised client_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.

Bottom line

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.

Next steps to settle the "random app RCE" claim

  1. 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.
  2. 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_arena pointers) can be reached and read back.
  3. Heap grooming + relocation: with a leak, compute the runtime pos for a chosen target, groom the aiohttp process heap with prior/concurrent requests, and aim the WrapRingBuffer memcpy so it relocates a real code pointer onto a function-pointer slot that the request-teardown path will call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment