Resumen
El fallo vive en la forma en que libcurl reinicia el estado de una petición cuando se reutiliza el mismo easy handle para varias transferencias, que es precisamente el uso que recomienda la documentación.
Cuando un servidor responde con un redirect a una petición con cuerpo (por ejemplo, un POST), http_perhapsrewind() deja activada la marca req->rewind_read. Si el redirect falla antes de que Curl_http_follow() llegue a su reinicio "suave", esa marca se queda puesta. Dos formas de provocarlo, ambas con CURLOPT_FOLLOWLOCATION:
- Un
307con una cabeceraLocation: http://[::1mal formada (CURLE_URL_MALFORMAT). - Un
307con una línea de cabecera sin dos puntos (CURLE_WEIRD_SERVER_REPLY).
Al empezar la siguiente transferencia, Curl_connect() ejecuta Curl_req_hard_reset() (lib/request.c, línea 124 en curl-8_23_0). Esta función llama a Curl_client_reset() mientras rewind_read sigue activo, y en ese caso la pila de lectores del cliente se conserva para el rebobinado (lib/sendf.c, línea 82). La marca no se limpia hasta la línea 157, así que esa pila antigua ni se rebobina ni se libera: queda instalada para la transferencia nueva.
Los protocolos de subida que no instalan su propio lector (ftp, ftps, sftp, scp, file y tftp) leen a través de Curl_client_read() con esa pila heredada (file.c línea 369, ftp.c línea 561). Si entre medias la aplicación ha cambiado los postfields, libcurl libera su copia interna creada con CURLOPT_COPYPOSTFIELDS (setopt.c línea 2026), y cr_buf_read() acaba haciendo memcpy() de 65536 bytes por lectura desde memoria liberada (sendf.c línea 1334), que se envían al destino de la subida.
- Versiones afectadas: de la 8.16.0 (el orden de operaciones llegó con el commit
a93113b5b4) a la 8.23.0; según el investigador, la rama principal seguía igual al reportarlo. - Debilidad: Use After Free (CWE-416).
- CVE: CVE-2026-111954.
- Severidad asignada: baja.
- Fechas: enviado el 17-oct-2026 y divulgado el 26-oct-2026.
El investigador indica que usó herramientas de IA para ayudarse con los programas de prueba, pero que la reproducción y las cifras son suyas.
Pasos de reproducción
Entorno del investigador: curl 8.23.0 compilado desde la etiqueta curl-8_23_0 (3163ec0656), Linux x86_64, OpenSSL 3.6.4, una compilación con clang y -fsanitize=address y otra con gcc y --enable-debug.
- Levantar el servidor HTTP de prueba adjunto al reporte. Lee la cabecera de la petición y un byte del cuerpo, responde con
307,Location: http://[::1yContent-Length: 0, y cierra la conexión:
`` python3 http_307bad.py 8900 ``
Para la segunda variante (cabecera sin dos puntos) se lanza con --badheader --location /ok.
- Compilar
reader_reuse.c(adjunto) contra la libcurl con ASAN. El programa usa un único easy handle:
- Primera transferencia: POST con
CURLOPT_COPYPOSTFIELDS(deBODYbytes) yCURLOPT_FOLLOWLOCATION. - Después, sin
curl_easy_reset(): poneCURLOPT_POSTFIELDSaNULL, activaCURLOPT_UPLOAD, asigna unFILE*aCURLOPT_READDATAy cambia a una segunda URL.
- Subida a fichero local:
`` BODY=1000000 ./reader_reuse http://127.0.0.1:8900/post file:///tmp/out ``
La primera transferencia devuelve 3 y ASAN informa de un heap-use-after-free de lectura de 65536 bytes en cr_buf_read (sendf.c:1334), llamado desde file_do (file.c:369). La memoria se liberó en setopt.c:2026 y se reservó en setopt.c:1811.
- Lo mismo, pero con
ftp://127.0.0.1:PORT/xcomo segunda URL contra cualquier servidor FTP que acepteSTOR: mismo informe de ASAN, ahora desdeCurl_req_send_more(request.c:468). En la compilación de depuración sin ASAN el proceso muere con SIGSEGV dentro delmemcpydecr_buf_read, porque glibc ya ha desmapeado ese bloque de 1 MB.
- Con
BODY=100000(por debajo del umbral demmap) no hay caída: la segunda transferencia devuelve 0 como si todo fuese bien, pero el destino recibe bytes del búfer liberado en lugar del contenido deREADDATA.
- Control: con la opción
--reset(que llama acurl_easy_reset()entre las dos transferencias), la segunda subida usa el fichero deREADDATAcomo se espera.
- Con el parche
fix-pretransfer-drop-stale-readers.patchaplicado y recompilando, se repiten los pasos 3 y 4: terminan limpios y se sube el contenido real deREADDATA.
Impacto
- Divulgación de memoria: la subida envía bloques de 64 KB de memoria ya liberada al destino, que puede ser un servidor FTP, SFTP o TFTP remoto o un fichero local.
- Corrupción silenciosa: con un cuerpo de 100 KB la transferencia se da por correcta y el destino recibe bytes del búfer liberado en lugar de lo que la aplicación quería subir.
- Caída de la aplicación: con cuerpos grandes (en la prueba, 1 MB) la lectura provoca un SIGSEGV.
Condiciones: un servidor tiene que responder a una petición con cuerpo con un redirect que curl no pueda seguir, y la aplicación tiene que reutilizar el handle para una subida por uno de los protocolos citados, haber cambiado los postfields y no llamar a curl_easy_reset() entre medias. La herramienta de línea de órdenes curl crea un handle por transferencia y no está afectada; sí lo están los bindings de lenguajes que mantienen un handle por objeto.
Remediación
El investigador adjuntó un parche (fix-pretransfer-drop-stale-readers.patch) de seis líneas en Curl_pretransfer() que descarta cualquier rebobinado pendiente al iniciar una transferencia nueva. Con él la reproducción queda limpia y el conjunto de tests de curl no cambia. El reporte figura como resuelto y con CVE asignado, aunque no detalla qué cambio exacto se incorporó finalmente.
Mientras tanto, llamar a curl_easy_reset() entre transferencias en el mismo handle evita el problema.
Qué aprender de este caso
- Las marcas de estado que sobreviven a un camino de error (aquí
rewind_read, puesta antes de un redirect que falla) son un buen sitio donde buscar: revisa qué se limpia en el camino "feliz" (el reinicio suave deCurl_http_follow()) y qué se queda colgando si se aborta antes. - En funciones de reinicio, el orden importa:
Curl_req_hard_reset()consulta la marca antes de limpiarla. Si un reset decide conservar recursos según un flag, ese flag debería resetearse primero o comprobarse al empezar la siguiente operación, como hace el parche enCurl_pretransfer(). - Cuando una librería guarda un puntero a un búfer que una opción posterior puede liberar (
CURLOPT_COPYPOSTFIELDSfrente aCURLOPT_POSTFIELDS), cualquier objeto de larga vida que siga apuntando a él es candidato a use-after-free: busca quién más conserva ese puntero. - Para probar estos fallos, combina reutilización de handles, respuestas de servidor deliberadamente rotas y distintos tamaños de cuerpo: aquí solo los tamaños grandes provocaban una caída visible, mientras que los pequeños fallaban en silencio.