curl · Report #3823985
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)
How it was found
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 noCurl_is_in_callbackrejection — it only recordsrecursiveto restore the in-callback flag afterward.mev_assess()never checksmulti->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_socketline 216 dereferences the entry after theCURL_POLL_REMOVEcallback 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():
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:
/* ... 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_deletes the same
entry; control returns to the outer frame, which uses the freed entry at line 216.
Steps to reproduce (standalone, no fuzzer)
-
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-debugbuild instead tripsDEBUGASSERT(NULL)atlib/multi_ev.c:423— the same re-entrancy corrupting the pollset book-keeping.) -
Compile and run the attached
uaf_repro.cagainst that libcurl:cc -fsanitize=address -g uaf_repro.c -o uaf_repro -lcurl -lpthread ./uaf_reproExpected:
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.