Reports
ALTA[CORRUPCIÓN DE MEMORIA]#4030333

Use-after-free en subidas HTTP/2 de curl cuando el servidor corta el stream

Al subir un fichero por HTTP/2, curl 8.22.0 maneja mal un RST_STREAM del servidor y acaba reutilizando un objeto Curl_easy ya liberado. El resto del cuerpo pendiente se envía como si fuera una petición nueva y deja un puntero colgante.

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 investigador onn4a reportó un use-after-free en curl 8.22.0 (CVE-2026-92392) que afecta al cliente cuando sube un fichero por HTTP/2. El problema aparece al tratar un RST_STREAM enviado por el servidor con los motivos HTTP_1_1_REQUIRED o REFUSED_STREAM en mitad de una subida.

El objeto que se acaba usando después de liberarse es un struct Curl_easy, el manejador que representa la transferencia. La raíz del fallo es que, cuando curl decide reintentar la transferencia tras el reset, el resto del cuerpo de la subida que quedaba sin enviar en los búferes internos se vuelca sobre la conexión. Como para entonces curl ya ha "olvidado" esa transferencia, interpreta esos bytes como si fueran una petición HTTP nueva: abre un stream HTTP/2 nuevo que guarda otra vez un puntero al Curl_easy. Ese segundo puntero ya no se limpia antes de que la aplicación libere el objeto, de modo que queda colgando.

Para que se den las condiciones hacen falta dos cosas en el fichero que se sube: que contenga en algún punto el patrón <palabra> <palabra> <algo>\n\n (es decir, algo que al parsearse parezca una línea de petición y sus cabeceras) y que supere unos 135 KB, de forma que parte del cuerpo quede sin enviar y parada en los búferes cuando llega el reset.

Pasos de reproducción

El investigador adjuntó un servidor malicioso (evil-server.py) que fuerza la condición: acepta la subida, no concede ventana de control de flujo (para que el cuerpo se quede parado), responde con HEADERS(200) y a continuación envía RST_STREAM con HTTP_1_1_REQUIRED.

Compilando curl 8.22.0 con AddressSanitizer se observa el crash por use-after-free:

wget 'https://curl.se/download/curl-8.22.0.tar.gz'
sha1sum curl-8.22.0.tar.gz # debe ser 284e444b67f676a281906cfc86e9672780555cd4
tar xf curl-8.22.0.tar.gz
mkdir build ; cd build
# compilar con ASAN
CC=clang CFLAGS="-O2 -g -fsanitize=address" LDFLAGS="-Wl,-rpath,/opt/curl-release/lib" \
    ../curl-8.22.0/configure --prefix=/opt/curl-release \
      --enable-static --enable-shared --disable-docs --disable-manual \
      --with-openssl --with-zlib --with-brotli --with-zstd --with-libidn2 \
      --with-nghttp2 --with-libpsl --with-libssh2 --with-gssapi \
      --enable-ldap --enable-ldaps --enable-websockets --enable-mqtt --enable-ipfs \
      --enable-ntlm --enable-smb --enable-httpsig --enable-httpsrr --enable-ssls-export \
    && make -j"$(nproc)"
# fichero de ejemplo que cumple las condiciones del UAF
cat ../docs/*.md > sample_docs.md
# arrancar el servidor malicioso
python3 evil-server.py &
# --insecure solo porque el certificado es autofirmado; no influye en el bug
./src/curl --insecure -T ./sample_docs.md https://127.0.0.1:19170/x

El recorrido interno que explica el fallo, tal como lo documentó el investigador, es el siguiente:

  • Fase 1 — empieza la subida. Curl_req_send() prepara la cabecera y los primeros bytes del cuerpo; h2_submit() crea el stream 1 y nghttp2_submit_request() guarda el primer puntero al Curl_easy.
  • Fase 2 — la subida se atasca. El servidor no concede ventana de control de flujo, así que req_body_read_callback() devuelve NGHTTP2_ERR_DEFERRED y parte del cuerpo queda parada en req.sendbuf.
  • Fase 3 — llega el reset. El servidor manda HEADERS(200) y RST_STREAM(HTTP_1_1_REQUIRED). En on_stream_close() se marca el stream como cerrado y se limpia su puntero de usuario. La ruta de error sale sin pasar por Curl_req_abort_sending(), por lo que req.sendbuf no se vacía.
  • Fase 4 — reintento. multi.c convierte el error CURLE_HTTP2_STREAM en un reintento y llama a multi_done(). Ahí Curl_conn_ev_data_done() hace que el filtro HTTP/2 olvide la transferencia (http2_data_done() elimina el stream del hash). Después Curl_req_done() llama a req_flush(), que vuelca el req.sendbuf pendiente sobre la conexión. Como ya no hay stream asociado, cf_h2_send() lo trata como una petición nueva: Curl_h1_req_parse_read() parsea los bytes plantados como si fueran una línea de petición y nghttp2_submit_request() abre el stream 3, que vuelve a guardar un puntero al mismo Curl_easy.
  • Fase 5 — se libera el manejador. Al terminar el reintento, curl_easy_cleanup() → Curl_close() hace free(data). El puntero guardado en el stream 3 queda colgando.
  • Fase 6 — use-after-free. Al cerrar el proceso, el vaciado del pool de conexiones provoca una última lectura (cf_h2_shutdown() → nghttp2_session_mem_recv()). El servidor envía un HEADERS para el stream 3 y on_begin_headers() recupera el puntero de usuario ya liberado; al leer data_s->mid se produce el use-after-free.

En resumen: el primer puntero (fase 1) sí se limpia en las fases 3 y 4, pero el segundo (fase 4) se guarda después de esa limpieza y nada lo vuelve a borrar antes del free de la fase 5.

Impacto

El investigador clasifica el fallo como de severidad alta (Use After Free, CWE-416) y describe como impacto la ejecución de código en el cliente que sube un fichero de texto hacia un servidor malicioso o comprometido. La víctima es quien ejecuta curl; basta con que el servidor al que sube controle las respuestas HTTP/2 y que el fichero cumpla las condiciones de tamaño y contenido descritas. El reporte indica que para la explotación con PIE/ASLR su PoC también usaba -Z/--parallel, pero que eso solo hace falta para explotar, no para disparar la corrupción de memoria.

Remediación

El reporte incluye un parche sugerido: llamar a Curl_req_abort_sending() al reintentar la conexión, de modo que se descarte el contenido de la petición que quedó sin enviar en lugar de volcarlo sobre la conexión como si fuera una petición nueva. Así nunca se abre el segundo stream que guarda el puntero que luego queda colgando.

El reporte fue enviado el 15 de septiembre de 2026 y divulgado el 14 de octubre de 2026, con estado "Resolved".

Qué aprender de este caso

  • En clientes y proxys que hablan HTTP/2, revisa qué pasa con el cuerpo de una petición que queda a medias cuando el servidor manda RST_STREAM (sobre todo con HTTP_1_1_REQUIRED o REFUSED_STREAM): si la ruta de reintento no descarta explícitamente los bytes pendientes del sendbuf, pueden reinyectarse y parsearse como una petición nueva.
  • Vigila el ciclo de vida de los punteros que una capa de conexión guarda hacia el objeto de transferencia (aquí set_stream_user_data/get_stream_user_data): si el objeto puede volver a registrarse después de la limpieza que se supone lo desvincula, hay un camino hacia un puntero colgante. Comprueba que cada registro tenga su limpieza en todas las rutas, incluida la de error y la de cierre del pool.
  • El orden de las fases de finalización importa: aquí "olvidar" la transferencia en el filtro (Curl_conn_ev_data_done()) antes de vaciar los búferes (Curl_req_done()) es lo que permite que el vaciado se interprete como tráfico nuevo. Al auditar código de cierre, fíjate en operaciones de flush que ocurren después de haber desasociado el contexto que las origina.
  • Datos de entrada que "parecen protocolo" (una línea tipo <palabra> <palabra> <algo> seguida de línea en blanco dentro de un fichero subido) pueden activar parsers que no esperabas. Al reutilizar un búfer de envío, asegúrate de limpiarlo o marcarlo como consumido antes de que otra ruta pueda tratarlo como una petición.