Reports
BAJA[CORRUPCIÓN DE MEMORIA]#4102061

CVE-2026-111480: use-after-free y doble free en libcurl al reanudar una transferencia desde un callback

Llamar a curl_easy_pause() desde un callback mientras libcurl reenvía datos en búfer libera memoria que el bucle exterior sigue usando. La aplicación se cae, pero un servidor remoto no puede provocarlo por sí solo.

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 cw_out_flush_chain(), en lib/cw-out.c, y es un problema de reentrada. Esa función recorre una cadena de búferes pendientes y entrega cada uno al callback de escritura de la aplicación. Durante esa entrega conserva en una variable local un puntero struct cw_out_buf * (el bucle de las líneas 321-344), dando por hecho que el búfer seguirá existiendo cuando el callback termine.

Esa suposición se rompe si la aplicación llama a curl_easy_pause(CURLPAUSE_CONT) desde un callback mientras se está reenviando ese búfer. La llamada provoca un cw_out_flush_chain() anidado que libera el búfer. Al volver, el bucle exterior actúa sobre memoria ya liberada:

  • lee el búfer liberado en las líneas 333 y 335;
  • instala cwbuf->next, sacado de esa memoria, en la línea 341;
  • lo libera por segunda vez en la línea 344.

ASan informa de un heap-use-after-free en lib/cw-out.c:333, con la liberación en lib/cw-out.c:93 a través de la línea 344. En una compilación normal de release el programa muere de forma determinista dentro de free(), porque b.bufr ocupa el desplazamiento donde glibc guarda la clave de tcache.

La documentación de curl_easy_pause() permite llamarla desde todos los callbacks salvo el de socket, así que es un uso admitido de la API. Aun así, el desencadenante depende de la aplicación: ningún servidor puede forzarlo.

Se reprodujo en master (commit 098d3a0d40) y en las versiones 8.20.0, 8.21.0 y 8.22.0, sobre Linux x86-64 con glibc 2.35 y clang. La debilidad registrada es Use After Free, con severidad baja. El reporte se envió el 10 de octubre de 2026 y se divulgó el 26 de octubre, ya en estado resuelto.

Pasos de reproducción

Los ficheros de prueba (body_server.py, cwout_reentrant_unpause.c, gzip_server.py, cwpause_sibling.c) iban adjuntos al reporte original.

  1. Compilar libcurl desde master con ASan:
./configure --enable-debug
make CFLAGS="-fsanitize=address -g -O1" LDFLAGS=-fsanitize=address
  1. Arrancar el servidor de prueba, que imprime un puerto:
python3 body_server.py
  1. Compilar la prueba de concepto:
clang -fsanitize=address -I include cwout_reentrant_unpause.c -o poc libcurl.a $(curl-config --static-libs)
  1. Ejecutarla contra ese puerto:
./poc http://127.0.0.1:<port>/
  1. Lo que ocurre dentro de la prueba: el callback de escritura devuelve CURL_WRITEFUNC_PAUSE. Después, el callback de depuración, activo porque la traza se habilita con curl_global_trace("write"), llama a curl_easy_pause(CURLPAUSE_RECV) y a continuación a curl_easy_pause(CURLPAUSE_CONT). Hacen falta las dos llamadas: en lib/cw-out.c:206 el límite de velocidad de descarga aún no está bloqueado, de modo que un CURLPAUSE_CONT suelto nunca llega a Curl_cwriter_unpause(). Con esto, ASan informa del heap-use-after-free en lib/cw-out.c:333, con la liberación en lib/cw-out.c:93 a través de la línea 344.
  1. Hay un segundo camino hasta las mismas funciones: cwpause_sibling.c con gzip_server.py. Entra por cw_pause_flush() (lib/cw-pause.c:145) cuando CURLOPT_ACCEPT_ENCODING está configurado, lo que permite que la respuesta llegue comprimida con gzip.
  1. Sin ASan y a -O2, la misma ejecución da un segfault dentro de free() en 20 de 20 intentos. Con la caché tcache desactivada, glibc da otro diagnóstico en 10 de 10 intentos:
GLIBC_TUNABLES=glibc.malloc.tcache_count=0
double free or corruption (fasttop)

Impacto

El efecto demostrado es la caída de la aplicación que usa libcurl, por un use-after-free seguido de un doble free.

Las condiciones limitan mucho el alcance: la aplicación tiene que pausar y reanudar desde su propio callback y con la traza de escritura activada. Un servidor remoto no puede causarlo por su cuenta.

El investigador también intentó pasar de la caída a algo más serio, colocando bytes controlados por el servidor en el bloque liberado. Probó diez formas de respuesta y tres configuraciones del asignador de memoria, sin éxito: el valor que llegaba a free() era siempre la clave de tcache del proceso. Según indica, las teorías de escalada que no pudo reproducir no se afirman en el reporte.

Remediación

El investigador adjuntó un parche junto con el test 3235. El parche añade un bit flushing a cw_out_ctx y envuelve el bucle de vaciado, de manera que una llamada anidada regresa sin tocar la cadena de búferes.

Con el parche aplicado, según el reporte:

  • las dos pruebas de concepto se ejecutan sin errores;
  • el test 3235 falla antes del cambio y pasa después;
  • la batería completa de tests muestra los mismos 16 fallos que ya tenía la versión sin parchear.

Propone además una mejora opcional, no necesaria para la corrección: mover Curl_xfer_pause_recv() antes de la llamada de traza en cw_out_cb_write(), lo que cierra esa misma ventana en su origen.

El investigador declara que el análisis y el parche se desarrollaron con ayuda de IA, y que reprodujo personalmente cada afirmación del reporte.

Qué aprender de este caso

  • Cuando revises una librería con callbacks, busca bucles que guarden un puntero a un elemento de una lista enlazada y llamen a código de la aplicación antes de usarlo otra vez. Si ese callback puede volver a entrar en la API, el elemento puede haber desaparecido a la vuelta.
  • Una función de vaciado alcanzable desde sus propios callbacks necesita una marca de reentrada como el bit flushing, o sacar el elemento de la lista antes de entregarlo, para que la llamada anidada no pueda liberarlo.
  • Los callbacks secundarios también cuentan: aquí la reentrada llega por el callback de depuración, no por el de escritura. Conviene probar llamadas a funciones de control como pausar y reanudar desde todos los callbacks que la documentación permite.
  • Revisa la secuencia completa de llamadas, no solo una: un CURLPAUSE_CONT aislado no activaba el fallo y hacía falta CURLPAUSE_RECV antes. Una prueba con una sola llamada lo habría dado por seguro.