HexBrain

curl · Report #4102061

CVE-2026-111480: pause UAF from within callback

Validstatus
lowseverity
1upvotes
26 Oct 2026disclosed

How it was found

Summary:

cw_out_flush_chain() in lib/cw-out.c keeps a struct cw_out_buf * in a local across the application's write callback (the loop at lib/cw-out.c:321-344). If the application calls curl_easy_pause(CURLPAUSE_CONT) from a callback while that buffer is being replayed, a nested cw_out_flush_chain() frees it, and the outer loop then reads the freed buffer at :333 and :335, installs cwbuf->next out of freed memory at :341 and frees it again at :344. ASan reports heap-use-after-free at lib/cw-out.c:333, and a plain release build dies in free() deterministically because b.bufr sits at the offset where glibc keeps the tcache key. curl_easy_pause() is documented as callable from all callbacks except the socket callback, so this is documented usage, but the trigger is application-side and no server can force it. Reproduced on master (098d3a0d40) and on releases 8.20.0, 8.21.0 and 8.22.0. A patch adding a re-entrancy guard around the flush loop, plus test 3235, is attached.

Affected version

master 098d3a0d40, and releases 8.20.0, 8.21.0, 8.22.0. Linux x86-64, glibc 2.35, clang. curl -V output attached as curl-V.txt.

Steps To Reproduce:

  1. Build libcurl from master with ASan: ./configure --enable-debug then make CFLAGS="-fsanitize=address -g -O1" LDFLAGS=-fsanitize=address
  2. python3 body_server.py (attached) - it prints a port.
  3. clang -fsanitize=address -I include cwout_reentrant_unpause.c -o poc libcurl.a $(curl-config --static-libs)
  4. ./poc http://127.0.0.1:<port>/
  5. The PoC's write callback returns CURL_WRITEFUNC_PAUSE; its debug callback (tracing enabled with curl_global_trace("write")) then calls curl_easy_pause(CURLPAUSE_RECV) followed by curl_easy_pause(CURLPAUSE_CONT). Two calls are needed because at lib/cw-out.c:206 the download rate limit is not blocked yet, so a lone CURLPAUSE_CONT never reaches Curl_cwriter_unpause(). ASan then reports heap-use-after-free at lib/cw-out.c:333, freed at lib/cw-out.c:93 via :344.
  6. Second path into the same frames: cwpause_sibling.c with gzip_server.py, which arrives via cw_pause_flush() (lib/cw-pause.c:145) when CURLOPT_ACCEPT_ENCODING is set, so the response may be gzip-encoded.
  7. Without ASan, at -O2, the same run segfaults inside free() 20/20 times; with GLIBC_TUNABLES=glibc.malloc.tcache_count=0 glibc instead reports "double free or corruption (fasttop)" 10/10.

{F6953092}{F6953093}{F6953095}{F6953094}{F6953096}{F6953097}

Impact

Summary:

Use-after-free followed by a double free inside libcurl, crashing the application: a plain -O2 build segfaults in free() on every run, and with tcache disabled glibc reports "double free or corruption (fasttop)".

The trigger is application-side - the application has to pause and then unpause from inside its own callback, with write tracing enabled - so a remote server cannot cause this on its own. I tried to get server-controlled bytes into the freed slot across ten response shapes and three allocator configurations and could not; the value passed to free() was always the per-process tcache key.

The attached patch adds a flushing bit to cw_out_ctx and wraps the flush loop so a nested call returns without touching the chain. With it applied both PoCs run clean, test 3235 fails before and passes after, and the full test suite shows the same 16 pre-existing failures as the unpatched baseline. Separately and optionally, moving Curl_xfer_pause_recv() ahead of the trace call in cw_out_cb_write() closes the same window at source; it is not needed for the fix.

Analysis and the patch were developed with AI assistance. Every claim above I reproduced by running it, and the escalation theories that did not reproduce are not asserted here.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original