Reports
BAJA[OTROS]#3916059

curl: use-after-free en el pool de conexiones compartido al recibir un HTTP/2 server push (CVE-2026-18924)

Si una aplicación comparte conexiones con CURLSH y acepta un server push de HTTP/2, libcurl libera una conexión que sigue enlazada en el pool compartido y después vuelve a leerla.

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 cómo libcurl gestiona los streams que el servidor empuja con HTTP/2 (server push) cuando la aplicación comparte el pool de conexiones mediante un objeto CURLSH configurado con CURL_LOCK_DATA_CONNECT. Es un use-after-free (CWE-416) registrado como CVE-2026-18924, con severidad baja según el programa.

La cadena de causas tiene tres piezas:

  • Al llegar un PUSH_PROMISE, libcurl crea internamente un handle nuevo para el stream empujado a través de h2_duphandle() (lib/http2.c), que llama a curl_easy_duphandle().
  • curl_easy_duphandle() no copia el campo ->share; su propia documentación dice que el clon se crea "como si CURLOPT_SHARE fuera NULL". Por tanto, el handle del push queda con share == NULL.
  • Aun así, Curl_multi_add_perform() lo engancha a la conexión del handle padre, que vive en share->cpool.

Cuando termina la transferencia empujada, cpool_get_instance() (lib/conncache.c:256) decide a qué pool pertenece la conexión mirando el handle, no la conexión. Como el handle no tiene share, devuelve multi->cpool. cpool_remove_conn() busca la conexión en ese pool, no la encuentra y no la desenlaza, pero la conexión se libera igualmente. El resultado es una estructura connectdata liberada que sigue enlazada en el pool compartido con bits.in_cpool a TRUE, y cualquier recorrido posterior de share->cpool la lee.

Versiones afectadas según el reporte: curl 8.21.0 y la rama master en el commit 2112f185c (4 de agosto de 2026, libcurl 8.22.0-DEV). El investigador lo reprodujo en 3 de 3 ejecuciones.

Estado de los tres puntos implicados en 2112f185c:

| punto | situación | |---|---| | h2_duphandle — lib/http2.c:699 | llama a curl_easy_duphandle(data); share no aparece ni una vez en lib/http2.c | | curl_easy_duphandle — lib/easy.c:970 | no copia ->share | | cpool_get_instance — lib/conncache.c:256 | resuelve el pool a partir del handle, no de la conexión |

Secuencia completa, paso a paso:

  1. push_promise() → h2_duphandle() → curl_easy_duphandle(): el handle del push nace con share == NULL.
  2. push_promise() → Curl_multi_add_perform(data->multi, newhandle, cf->conn) → Curl_attach_connection(newhandle, cf->conn): ese handle pasa a usar la conexión del padre, que está en share->cpool.
  3. cpool_get_instance(data) solo devuelve &data->share->cpool si se cumple CURL_SHARE_KEEP_CONNECT(data->share); para el handle del push devuelve multi->cpool.
  4. multi_done() → Curl_cpool_do_locked() → Curl_conn_terminate() → cpool_remove_conn(multi->cpool, conn): la conexión no está en ese pool, no se desenlaza y bits.in_cpool sigue a TRUE.
  5. cpool_discard_conn() → Curl_cshutdn_terminate() libera la connectdata.
  6. curl_share_cleanup() → share_destroy() → Curl_cpool_destroy() → cpool_get_first() → Curl_node_elem() lee la memoria ya liberada.

Pasos de reproducción

No hace falta red, servidor ni TLS: el reproductor adjunto (repro.c) es un programa de libcurl autónomo en el que un hilo del propio proceso sirve tramas h2c sin cifrar a través de un socketpair, usando CURLOPT_OPENSOCKETFUNCTION y CURL_SOCKOPT_ALREADY_CONNECTED.

  1. Compilar libcurl en modo release (ENABLE_DEBUG=OFF) con AddressSanitizer:
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_DEBUG=OFF \
  -DBUILD_SHARED_LIBS=OFF -DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF \
  -DCURL_USE_OPENSSL=OFF -DUSE_NGHTTP2=ON -DCURL_USE_LIBPSL=OFF -DUSE_LIBIDN2=OFF \
  -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
  -DCMAKE_C_FLAGS="-fsanitize=address -g -fno-omit-frame-pointer"
cmake --build build && cmake --install build
  1. Compilar y ejecutar el reproductor contra esa librería:
cc -fsanitize=address -g repro.c -o repro -I<prefix>/include \
   <prefix>/lib/libcurl.a -lnghttp2 -lz -lpthread -ldl
ASAN_OPTIONS=detect_leaks=0 ./repro
  1. El programa prepara, antes de empezar la transferencia, un share de conexiones y un callback de push que acepta todo (devuelve CURL_PUSH_OK y no llama a ninguna función de libcurl). Todo en un único hilo y sin tocar opciones después de arrancar:
CURLSH *sh = curl_share_init();
curl_share_setopt(sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
curl_easy_setopt(easy, CURLOPT_SHARE, sh);
curl_multi_setopt(multi, CURLMOPT_PUSHFUNCTION, push_cb);  /* returns CURL_PUSH_OK */
  1. Lanza una transferencia HTTP/2; el servidor envía un PUSH_PROMISE, el cliente lo acepta y, antes de que el stream empujado termine, el servidor manda GOAWAY. Según el investigador, también llevan al mismo camino (multi_done() → Curl_conn_terminate() con el handle sin share) un RST_STREAM, el cierre de la conexión, la expiración de CURLOPT_TIMEOUT_MS o un curl_multi_remove_handle() sobre el handle empujado.
  1. ASan informa de un heap-use-after-free: una READ of size 8 sobre una connectdata liberada de 776 bytes (832 bytes en 8.21.0). Extracto de la traza en master 2112f185c:
READ of size 8:
  Curl_node_elem            lib/llist.c:237
  cpool_get_first           lib/conncache.c:142
  Curl_cpool_destroy        lib/conncache.c:242
  share_destroy             lib/curl_share.c:41
  curl_share_cleanup        lib/curl_share.c:370

freed by:
  Curl_cshutdn_terminate    lib/cshutdn.c:159
  cpool_discard_conn        lib/conncache.c:226
  Curl_conn_terminate       lib/conncache.c:692
  Curl_cpool_do_locked      lib/conncache.c:833
  multi_done                lib/multi.c:750
  multi_handle_timeout      lib/multi.c:1848
  multi_runsingle           lib/multi.c:2757
  multi_perform             lib/multi.c:2888

previously allocated by:
  allocate_conn             lib/url.c:1182
  url_create_needle         lib/url.c:2010
  url_find_or_create_conn   lib/url.c:2250
  Curl_connect              lib/url.c:2456

Con una libcurl compilada como DEBUGBUILD el fallo no llega a la liberación: salta antes un DEBUGASSERT(NULL) en lib/conncache.c:179 ("Should have been in the bundle list"). La comprobación verifynode() de lib/llist.c:36 solo existe en builds de depuración, por eso en release la lectura aparece en Curl_node_elem().

Impacto

La conexión liberada sigue siendo alcanzable desde cualquier operación posterior sobre share->cpool, no solo desde curl_share_cleanup(). Una aplicación que use libcurl con conexiones compartidas y acepte server push puede acabar leyendo memoria de heap liberada cuando un servidor HTTP/2 le empuja un stream y lo corta antes de terminarlo.

Para medir el alcance, el investigador recompiló con -fsanitize-recover=address y ejecutó con ASAN_OPTIONS=halt_on_error=0, de modo que la ejecución siguiera tras el primer error:

| # | acceso | desplazamiento en la región liberada de 776 bytes | punto | |---|---|---|---| | 1 | READ of size 8 | 8 | Curl_node_elem — lib/llist.c:237 | | 2 | READ of size 8 | 0 | Curl_node_llist — lib/llist.c:269 | | 3 | READ of size 8 | 88 | cpool_remove_conn — lib/conncache.c:169 |

Son tres lecturas en tres desplazamientos y tres funciones distintas. En esa ejecución no se observó ninguna escritura sobre la memoria liberada. La conexión se reserva en allocate_conn() (lib/url.c:1182) y se libera en Curl_cshutdn_terminate() (lib/cshutdn.c:159); según el investigador, entre la liberación y las lecturas hay más actividad de reserva de memoria de libcurl. El programa lo clasificó como severidad baja.

Remediación

El reporte no detalla el parche aplicado; consta como resuelto y con CVE asignado. El investigador propuso dos alternativas:

  • Propagar el share del padre al handle empujado en h2_duphandle()/push_promise() antes de llamar a Curl_multi_add_perform(), con su gestión de contador de referencias y bloqueos:
newhandle->share = data->share;
  • O bien terminar la conexión contra el pool que realmente la contiene, en lugar de usar cpool_get_instance(data).

Cronología: enviado a HackerOne el 4 de agosto de 2026 y divulgado el 2 de septiembre de 2026.