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)
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 HackerOneReport Stats
- Report ID: 3823985
- State: Closed
- Substate: informative