Use-after-free in `mev_forget_socket` when `curl_easy_pause()` is called from a `CURL_POLL_REMOVE` socket callback (incomplete fix of CVE-2026-9080)

Disclosed: 2026-06-28 06:33:17 By stze To curl
Low
Vulnerability Details
## Summary `libcurl`'s event interface lets the application's socket callback (`CURLMOPT_SOCKETFUNCTION`) call `curl_easy_pause()`. CVE-2026-9080 was issued for a use-after-free that this triggers, and the fix added a post-callback re-fetch of the socket-hash entry in the **UPDATE** leg (`mev_sh_entry_update`, `lib/multi_ev.c`). The same re-entrancy hazard exists in the **REMOVE** leg, `mev_forget_socket()`, which was **not** fixed: after invoking the `CURL_POLL_REMOVE` callback it dereferences the hash entry (`entry->announced = FALSE`, `lib/multi_ev.c:216`) with no re-fetch. When the callback calls `curl_easy_pause()`, libcurl re-enters `mev_assess()` and frees that same entry via `Curl_hash_delete()`, so line 216 is a use-after-free. The companion path `Curl_multi_ev_socket_done()` (`lib/multi_ev.c:618` → `mev_forget_socket`) is affected identically. This reproduces with a small standalone program (no fuzzer). ## Supported operation (forecloses "you're holding it wrong") Calling `curl_easy_pause()` from within a callback is **explicitly documented and intentionally implemented** — this is not an API misuse: - `docs/libcurl/curl_easy_pause.md:30-32`: *"Unlike most other libcurl functions, curl_easy_pause(3) can be used from within callbacks."* (no socket-callback carve-out). - Unlike the functions that reject re-entrant use with `CURLE_RECURSIVE_API_CALL`, `curl_easy_pause()` (`lib/easy.c:1147-1152`) deliberately has **no `Curl_is_in_callback` rejection** — it only records `recursive` to restore the in-callback flag afterward. `mev_assess()` never checks `multi->in_callback`. So the recursion guard provides no protection on this path. - **CVE-2026-9080 itself** established pausing from the socket callback as supported and security-relevant, and shipped a fix for it — in the UPDATE leg only. ## Affected versions - Reproduced against **curl 8.21.0** (tag `curl-8_21_0`, `LIBCURL_VERSION "8.21.0-DEV"`), the tree carrying the CVE-2026-9080 UPDATE-leg re-fetch. - Independently confirmed unpatched on **curl master, HEAD `c10a7aa`** (2026-06-25): `mev_forget_socket` line 216 dereferences the entry after the `CURL_POLL_REMOVE` callback with no re-fetch — unchanged from 8.21.0, while the UPDATE leg retains the fix. ## Root cause `lib/multi_ev.c`, `mev_forget_socket()`: ```c 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); // 202 ... 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); // 213 callback mev_in_callback(multi, FALSE); entry->announced = FALSE; // 216 <-- UAF } mev_sh_entry_kill(multi, s); // 219 ... } ``` The fix author already documented this exact hazard — in the **UPDATE** leg (`mev_sh_entry_update`), `lib/multi_ev.c:293`: ```c /* ... the callback ... re-enters mev_assess() which may free this 'entry'. Re-fetch. */ entry = mev_sh_entry_get(&multi->ev.sh_entries, s); // 294 (the CVE-2026-9080 fix) ``` `mev_forget_socket()` has the identical pattern but performs no such re-fetch before using `entry` at line 216. ## Crash (AddressSanitizer) (`entry->announced` is a `BIT()` bitfield, so `entry->announced = FALSE` at line 216 is a read-modify-write of the byte — ASan reports the load half as "READ of size 1".) Use: ``` heap-use-after-free READ of size 1 #0 mev_forget_socket lib/multi_ev.c:216 #1 socket_close lib/cf-socket.c #2 cf_tcp_connect lib/cf-socket.c:1435 #3 cf_ip_attempt_connect lib/cf-ip-happy.c:252 (Happy-Eyeballs closing a losing socket) #6 cf_ip_happy_connect lib/cf-ip-happy.c:880 #14 multi_socket lib/multi.c:3302 #15 main uaf_repro.c (curl_multi_socket_action) ``` Freed by (the re-entrant pause): ``` Curl_hash_delete lib/hash.c:225 mev_sh_entry_kill lib/multi_ev.c:135 mev_forget_socket lib/multi_ev.c:219 (inner, during the pause) mev_pollset_diff lib/multi_ev.c:439 mev_assess lib/multi_ev.c:531 (curl_easy_pause re-entered here) ``` So: the outer `mev_forget_socket` (closing a Happy-Eyeballs socket during connect) calls the REMOVE callback; the callback's `curl_easy_pause()` re-enters `mev_assess` → `mev_pollset_diff` → an inner `mev_forget_socket` that `Curl_hash_delete`s the same entry; control returns to the outer frame, which uses the freed entry at line 216. ## Steps to reproduce (standalone, no fuzzer) 1. Build curl with AddressSanitizer (release config so the program reaches the UAF rather than an unrelated debug assertion): ``` ./configure --with-openssl --disable-shared --disable-debug \ CFLAGS="-fsanitize=address -g" LDFLAGS="-fsanitize=address" make ``` (Any TLS backend works; OpenSSL used here. A `--enable-debug` build instead trips `DEBUGASSERT(NULL)` at `lib/multi_ev.c:423` — the same re-entrancy corrupting the pollset book-keeping.) 2. Compile and run the attached `uaf_repro.c` against that libcurl: ``` cc -fsanitize=address -g uaf_repro.c -o uaf_repro -lcurl -lpthread ./uaf_repro ``` Expected: `AddressSanitizer: heap-use-after-free in mev_forget_socket (lib/multi_ev.c:216)`, deterministic (5/5 here). The program: starts a loopback HTTP listener (IPv4); drives one transfer through the event interface (`curl_multi_socket_action`); resolves the host to `[::1],127.0.0.1` so Happy-Eyeballs abandons the `::1` attempt and closes its socket during connect; the socket callback calls `curl_easy_pause()` on `CURL_POLL_REMOVE`. (`uaf_repro.c` is attached.) ## Suggested fix Apply the same fix as the UPDATE leg: do not use `entry` after the callback in `mev_forget_socket()` — either re-fetch it (`entry = mev_sh_entry_get(...)`) before line 216, or set `entry->announced = FALSE` *before* invoking the callback. Apply the same to `Curl_multi_ev_socket_done()` (`lib/multi_ev.c:618`). ## Impact Heap use-after-free in `libcurl`, reachable from a normal application using the event interface (the standard libevent / libuv integration) that calls `curl_easy_pause()` from its socket callback — for example for read/write backpressure — when connecting to a dual-stack host (Happy-Eyeballs is the default). It is the direct, unpatched sibling of CVE-2026-9080. **Severity: Low** (same as the CVE-2026-9080 parent, by the same reasoning). The use is a single read-modify-write that clears one bitfield bit at a fixed offset in the freed 80-byte `mev_sh_entry`; the value and offset are not attacker-controlled, no information is leaked, and there is no double-free — the subsequent `mev_sh_entry_kill` (line 219) safely no-ops because the re-entrant path already removed the key via `Curl_hash_delete`. The significance is that it is the unpatched twin of a fix you just shipped, reachable via a documented-supported operation.
Actions
View on HackerOne
Report Stats
  • Report ID: 3823985
  • State: Closed
  • Substate: informative
Share this report