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 deh2_duphandle()(lib/http2.c), que llama acurl_easy_duphandle(). curl_easy_duphandle()no copia el campo->share; su propia documentación dice que el clon se crea "como siCURLOPT_SHAREfuera NULL". Por tanto, el handle del push queda conshare == NULL.- Aun así,
Curl_multi_add_perform()lo engancha a la conexión del handle padre, que vive enshare->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:
push_promise()→h2_duphandle()→curl_easy_duphandle(): el handle del push nace conshare == NULL.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á enshare->cpool.cpool_get_instance(data)solo devuelve&data->share->cpoolsi se cumpleCURL_SHARE_KEEP_CONNECT(data->share); para el handle del push devuelvemulti->cpool.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 ybits.in_cpoolsigue a TRUE.cpool_discard_conn()→Curl_cshutdn_terminate()libera laconnectdata.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.
- 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
- 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
- El programa prepara, antes de empezar la transferencia, un share de conexiones y un callback de push que acepta todo (devuelve
CURL_PUSH_OKy 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 */
- 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 mandaGOAWAY. Según el investigador, también llevan al mismo camino (multi_done()→Curl_conn_terminate()con el handle sin share) unRST_STREAM, el cierre de la conexión, la expiración deCURLOPT_TIMEOUT_MSo uncurl_multi_remove_handle()sobre el handle empujado.
- ASan informa de un heap-use-after-free: una
READ of size 8sobre unaconnectdataliberada de 776 bytes (832 bytes en 8.21.0). Extracto de la traza en master2112f185c:
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 aCurl_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.