HexBrain

curl · Report #3911968

Heap use-after-free (write) in mev_forget_socket() via reentrant curl_easy_pause() — incomplete fix for CVE-2026-9080

N/Astatus
mediumseverity
10upvotes
4 Aug 2026disclosed

How it was found

lib/multi_ev.c keeps a hash table (multi->ev.sh_entries) of per-socket bookkeeping structs (struct mev_sh_entry) used to decide when to notify the application's socket callback (CURLMOPT_SOCKETFUNCTION) about interest changes on a socket.

CVE-2026-9080 ("multi: handle pause in multi socket callback", commit 5ab34cba42e4ee4282fe8bab43f311d51b9bf9bd, reported by Joshua Rogers, closes #21748) fixed a heap-use-after-free in mev_sh_entry_update(): that function calls multi->socket_cb(...) and then writes to the entry it fetched before the call. curl_easy_pause() is documented as callable from any callback, and calling it reenters Curl_multi_ev_assess_xfer() -> mev_pollset_diff(), which can delete (free) that very entry if the reentrant assessment concludes the socket has no remaining users. The fix re-fetches entry from the hash table after the callback returns, before touching it again, and adds a debug magic field specifically to help catch this class of bug.

The sibling function mev_forget_socket(), in the same file, has the identical hazard and was not touched by this patch:

static CURLMcode mev_forget_socket(struct Curl_multi *multi,
                                   struct Curl_easy *data,
                                   curl_socket_t s,
                                   const char *cause)
{
  struct mev_sh_entry *entry = mev_sh_entry_get(&multi->ev.sh_entries, s);
  int rc = 0;

  if(!entry) return CURLM_OK;

  if(entry->announced && multi->socket_cb) {
    mev_in_callback(multi, TRUE);
    rc = multi->socket_cb(data, s, CURL_POLL_REMOVE,
                          multi->socket_userp, entry->user_data);
    mev_in_callback(multi, FALSE);
    entry->announced = FALSE;   /* <-- UAF: entry may already be freed */
  }

  mev_sh_entry_kill(multi, s);
  ...
}

If the application's socket callback, invoked at the CURL_POLL_REMOVE notification, calls curl_easy_pause() on any transfer sharing the same socket (realistic with HTTP/2 multiplexing, where multiple transfers share one connection/socket), that reentrant call can free entry via mev_sh_entry_kill() before control returns to the outer frame. The subsequent entry->announced = FALSE; is a heap-use-after-free WRITE.

STEPS TO REPRODUCE / PROOF OF CONCEPT

Two independent proofs of concept are provided.

Method 1: whitebox harness (deterministic, targets the exact write)

The actual, unmodified lib/multi_ev.c (not a reimplementation) was compiled as part of a real ASan-instrumented libcurl build:

CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O0'
LDFLAGS='-fsanitize=address'
./configure --disable-shared --enable-debug --without-ssl make -C lib

A small harness (attached: curl_uaf_harness.c) #includes the real multi_ev.c and calls the real exported Curl_multi_ev_socket_done() (the thin public wrapper around mev_forget_socket()), with a socket_cb that deletes the socket's hash entry from inside the callback, reproducing what a reentrant curl_easy_pause() call does:

gcc -DHAVE_CONFIG_H -I../include -I../lib -I. -DDEBUGBUILD -DCURLDEBUG
-DBUILDING_LIBCURL -D_GNU_SOURCE -fsanitize=address
-fno-omit-frame-pointer -g -O0 -c curl_uaf_harness.c -o curl_uaf_harness.o gcc -fsanitize=address -g -o curl_uaf_poc curl_uaf_harness.o .libs/libcurl.a
-lnghttp2 -lidn2 -lrtmp -lldap -llber -lpsl -lzstd -lbrotlidec -lz -lpthread ./curl_uaf_poc

AddressSanitizer reports a heap-use-after-free WRITE of size 1 at multi_ev.c:213 (entry->announced = FALSE;), with the free happening at multi_ev.c:133 (mev_sh_entry_kill) inside the callback invoked from mev_forget_socket at multi_ev.c:210 — exactly the predicted reentrancy window. Full ASan output attached (poc_output.txt).

Scope note: this harness triggers the reentrant free directly inside the socket callback rather than through a live two-easy-handle HTTP/2 connection with a genuine curl_easy_pause() call racing real socket I/O. It targets the exact vulnerable write with real, unmodified function bodies, the real exported entry point, and the real heap allocator via an actual ASan-built libcurl.

Method 2: live, organic reproduction (real network I/O, no internal headers)

live_h2_client.c (attached) uses only curl's public API to drive two easy handles through a real curl_multi_socket_action() event loop against a real local nginx HTTP/2 (cleartext, prior-knowledge) server, injecting curl_easy_pause() calls on both handles from inside CURLMOPT_SOCKETFUNCTION whenever the shared socket is removed — the same reentrancy pattern CVE-2026-9080 addressed in mev_sh_entry_update().

This reproduces deterministically (5/5 runs): handle A's IPv6 connect attempt is refused (nginx listens IPv4-only), and while curl tears down that failed connection's socket, the injected curl_easy_pause() calls reentrantly corrupt multi_ev.c's bookkeeping, tripping DEBUGASSERT(NULL) in mev_pollset_diff() at multi_ev.c:420 ("transfer lost interest but is not registered"). This is a different specific invariant than the Method 1 write at line 213, but the same root cause (curl_easy_pause() reentrancy from inside the socket callback). In a non-DEBUGBUILD (production) binary this particular assertion is a no-op, so this exact manifestation's real-world consequence there is silently-inconsistent internal state rather than a guaranteed abort — Method 1's line-213 write remains the clearer evidence of a consequence that matters in production (non-debug) builds. Full log attached (live_repro_output.txt), along with a screen recording of a live run for easier triage (live_h2_repro.mp4; the source asciinema terminal recording, live_h2_repro.cast, is also included).

Impact

A heap-use-after-free write in a library embedded in an enormous number of network-facing daemons, tools, and language bindings. Concretely demonstrated impact is a deterministic crash (SIGABRT under ASan); without instrumentation, the practical effect depends on what the allocator has placed in the freed slot by the time the write occurs, which was not established beyond "reasoned as potentially exploitable, not verified against a specific target". Reachable by any application using curl's multi-socket interface (CURLMOPT_SOCKETFUNCTION) that calls curl_easy_pause() from its socket callback while multiple transfers share a socket — most realistically via HTTP/2 multiplexing. A remote server has some influence over the timing of connection-teardown/pause-worthy events that lead into this code path.

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