Resumen
Este fallo es la continuación de uno anterior, CVE-2026-80229. Aquella corrección impedía que una conexión que había usado un OSSL_LIB_CTX privado volviese al pool de conexiones para reutilizarse (mediante connclose()). Pero no garantizaba lo importante: que ese contexto de OpenSSL siguiese vivo mientras existiera el SSL_CTX que lo usa.
El problema aparece en lib/vtls/openssl.c cuando se dan tres condiciones a la vez:
CURLOPT_SSLENGINEtiene un valor que no es un ENGINE válido de OpenSSL (por ejemplo"default", el nombre de un proveedor o una cadenaprovider:propq). En ese casoossl_set_engine()no encuentra el ENGINE y pasa aossl_set_provider().- La transferencia usa la interfaz multi (
curl_multi_*ocurl --parallel). - El servidor TLS no completa el
close_notifyen el único intento de cierre no bloqueante que se hace al terminar la transferencia. Es lo habitual con un servidor HTTP/1.1 que mantiene la conexión abierta.
Afecta a curl master en el commit 03f3f4c6d97be3c2c9ad42010f65e61bf0a2c05c (LIBCURL_VERSION 8.23.1-DEV) compilado contra OpenSSL 3 (OPENSSL_HAS_PROVIDERS). curl_easy_perform() no está afectado, ni tampoco el uso de un ENGINE normal que ENGINE_by_id() acepte.
Cómo se produce el fallo
La raíz está en quién es dueño del contexto. ossl_set_provider() crea el OSSL_LIB_CTX y lo guarda solo en el handle easy, sin ningún contador de referencias (en OpenSSL 3.0 no existe OSSL_LIB_CTX_up_ref, y OSSL_LIB_CTX_free() en 3.0.22 lo destruye siempre):
/* lib/vtls/openssl.c, ossl_set_provider() */
if(!data->state.libctx) {
OSSL_LIB_CTX *libctx = OSSL_LIB_CTX_new();
if(!libctx)
return CURLE_OUT_OF_MEMORY;
/* ... optional propq ... */
data->state.libctx = libctx;
}
Durante el handshake, el SSL_CTX se crea a partir de ese puntero. SSL_CTX_new_ex() de OpenSSL 3.0.22 se limita a copiarlo (ret->libctx = libctx), sin tomar referencia, y además obtiene de ese contexto los algoritmos de resumen con EVP_MD_fetch(). La única defensa de curl es connclose(), que solo marca la conexión para que no se reutilice:
/* lib/vtls/openssl.c, Curl_ossl_ctx_init() */
octx->ssl_ctx =
#ifdef OPENSSL_HAS_PROVIDERS
data->state.libctx ?
SSL_CTX_new_ex(data->state.libctx, data->state.propq, req_method):
#endif
SSL_CTX_new(req_method);
#ifdef OPENSSL_HAS_PROVIDERS
if(data->state.libctx)
/* forbid connection reuse with provider/engine use */
connclose(data->conn);
#endif
A partir de aquí la secuencia es:
- Al terminar la transferencia con éxito,
multi_done_locked()ve la marca de cierre (bits.close) y devuelveCPOOL_DO_CLOSE. cpool_discard_conn()intenta cerrar la conexión una vez.ossl_shutdown()empieza conSSL_read(); si el servidor aún no ha mandadoclose_notify, obtieneSSL_ERROR_WANT_READy la conexión se encola enmulti->cshutdn, ya separada del handle easy. El cierre posterior lo hacemulti->admin, que no es dueño de eselibctx:
/* lib/conncache.c, cpool_discard_conn() */
if(!done) {
Curl_cshutdn_try_once(admin, conn, &done);
}
if(done || !data->multi)
Curl_cshutdn_terminate(admin, conn, FALSE);
else
Curl_cshutdn_add(&multi->cshutdn, multi, conn, max_shutdowns);
- La aplicación hace lo normal tras
CURLMSG_DONE:curl_multi_remove_handle()ycurl_easy_cleanup(). Como la conexión ya no pertenece al handle, la retirada no la destruye; en cambioCurl_close()llama aCurl_ssl_close_all()→ossl_close_all()→ossl_provider_cleanup(), que descarga los proveedores y libera el contexto mientrascshutdntodavía tiene elSSL/SSL_CTX:
/* lib/url.c, Curl_close() */
if(!data->state.internal && data->multi)
Curl_multi_remove_handle(data->multi, data);
/* ... */
Curl_ssl_close_all(data); /* ossl_provider_cleanup() frees libctx */
/* lib/vtls/openssl.c, ossl_provider_cleanup() */
if(data->state.baseprov) {
OSSL_PROVIDER_unload(data->state.baseprov);
data->state.baseprov = NULL;
}
if(data->state.provider) {
OSSL_PROVIDER_unload(data->state.provider);
data->state.provider = NULL;
}
OSSL_LIB_CTX_free(data->state.libctx);
data->state.libctx = NULL;
- En el siguiente
curl_multi_perform()o encurl_multi_cleanup()se procesa la colacshutdn:ossl_shutdown()llama aSSL_read()/SSL_shutdown()y despuésossl_close()ejecutaSSL_free()ySSL_CTX_free(), todos sobre unSSL_CTXcuyolibctxya está liberado.SSL_read()llega aEVP_MD_fetch()sobre esa memoria:
/* lib/vtls/openssl.c, ossl_shutdown() */
if(!(SSL_get_shutdown(octx->ssl) & SSL_SENT_SHUTDOWN)) {
ERR_clear_error();
for(i = 0; i < 10; ++i) {
nread = SSL_read(octx->ssl, buf, (int)sizeof(buf));
if(nread <= 0)
break;
}
}
/* lib/vtls/openssl.c, ossl_close() */
if(octx->ssl) {
SSL_free(octx->ssl);
octx->ssl = NULL;
}
if(octx->ssl_ctx) {
SSL_CTX_free(octx->ssl_ctx);
octx->ssl_ctx = NULL;
}
La conexión puede quedarse en la cola hasta DEFAULT_SHUTDOWN_TIMEOUT_MS (2 segundos) o hasta curl_multi_cleanup(), así que hay margen de sobra.
¿Por qué no pasa con curl_easy_perform()? Porque ahí Curl_close() ejecuta antes curl_multi_cleanup(data->multi_easy), que vacía cshutdn y destruye el SSL_CTX mientras el contexto sigue vivo. Y si la aplicación quita el handle antes de que la transferencia termine (mstate < MSTATE_COMPLETED), se usa CPOOL_DO_TERMINATE y la conexión se destruye dentro de la propia retirada, también antes de ossl_provider_cleanup().
Pasos de reproducción
El investigador lo confirmó reproduciendo con OpenSSL 3.0.22 el mismo orden de llamadas que hacen ossl_provider_cleanup() y después ossl_shutdown(), tras un handshake TLS 1.3 completo:
- Crear un
SSL_CTXconSSL_CTX_new_exsobre unlibctxprivado y completar el handshake. - Descargar los proveedores
defaultybasey llamar aOSSL_LIB_CTX_free. - Llamar a
SSL_read(lo primero que haceossl_shutdown()).
Resultado:
- Con la memoria liberada sin tocar,
SSL_readdevolvía sin más: el bloque es pequeño y no se desmapea, así que el use-after-free suele pasar desapercibido. - Con un
free()que pone el bloque en cuarentena (contenido envenenado y sin reutilizar),SSL_readprovocaba un segfault enCRYPTO_THREAD_read_lock, llamado desdeEVP_MD_fetch.
Impacto
Una aplicación que configure CURLOPT_SSLENGINE con un proveedor de OpenSSL 3 y use la interfaz multi de la forma habitual (esperar CURLMSG_DONE, retirar el handle, curl_easy_cleanup() y seguir usando el handle multi) acaba usando memoria liberada en SSL_read, SSL_shutdown y SSL_free, con corrupción de memoria dentro del proceso que enlaza libcurl (CWE-416).
No está activo por defecto y un servidor remoto no puede provocar por sí solo la creación del contexto. Pero, una vez la aplicación usa esta opción, el fallo no es una carrera rara: es el camino normal de cierre, y basta con un servidor que no responda al close_notify en el mismo turno. La severidad que figura en HackerOne es baja.
Remediación
El reporte figura como resuelto. El investigador explicó por qué ni connclose() ni OSSL_LIB_CTX_up_ref() bastan (esta última no existe en OpenSSL 3.0/3.1, donde OSSL_LIB_CTX_free() ignora cualquier contador) y propuso un "ancla" propia de curl con contador de referencias:
- El handle easy tiene una referencia, y cada
SSL_CTXcreado a partir del contexto toma otra. curl_easy_cleanup()suelta solo la del handle;ossl_close()suelta la de la conexión después deSSL_CTX_free().- La descarga real de proveedores y el
OSSL_LIB_CTX_freeocurren al soltar la última referencia, aunque sea más tarde desdecshutdn. - Lo mismo en la ruta de limpieza de QUIC (
lib/vquic/vquic-tls.c), que libera elSSL_CTXsin pasar porossl_close(). - El contador debe ser atómico, porque las dos liberaciones pueden ocurrir en hilos distintos. Si
SSL_CTX_new_ex()falla, no se toma la referencia extra.
El núcleo del cambio propuesto:
static void ossl_provider_cleanup(struct Curl_easy *data)
{
- if(data->state.baseprov) {
- OSSL_PROVIDER_unload(data->state.baseprov);
- data->state.baseprov = NULL;
- }
- if(data->state.provider) {
- OSSL_PROVIDER_unload(data->state.provider);
- data->state.provider = NULL;
- }
- OSSL_LIB_CTX_free(data->state.libctx);
- data->state.libctx = NULL;
- curlx_safefree(data->state.propq);
+ struct ossl_libctx_anchor *anchor = data->state.libctx_anchor;
+ data->state.libctx_anchor = NULL;
+ data->state.baseprov = NULL;
+ data->state.provider = NULL;
+ data->state.libctx = NULL;
+ data->state.propq = NULL; /* owned by the anchor */
data->state.provider_loaded = FALSE;
+ Curl_ossl_libctx_anchor_release(anchor);
}
@@
if(octx->ssl_ctx) {
SSL_CTX_free(octx->ssl_ctx);
octx->ssl_ctx = NULL;
octx->x509_store_setup = FALSE;
}
+ /* libctx must outlive SSL_CTX_free(); release only after it */
+ Curl_ossl_libctx_anchor_release(octx->libctx_anchor);
+ octx->libctx_anchor = NULL;
@@
if(data->state.libctx) {
+ struct ossl_libctx_anchor *anchor = data->state.libctx_anchor;
+ DEBUGASSERT(anchor);
+ anchor->refcount++;
+ octx->libctx_anchor = anchor;
connclose(data->conn);
+ }
Se le asignó CVE-2026-111878. El parche completo y las referencias al código están en el reporte original.
Qué aprender de este caso
- Impedir que una conexión se reutilice no es lo mismo que mantener vivos los recursos que usa: cuando un objeto (aquí el
SSL_CTX) guarda un puntero "prestado" sin tomar referencia, hay que comprobar que el dueño no pueda liberarlo antes. - Las colas de cierre diferido, como
cshutdn, son un buen sitio donde buscar use-after-free: el objeto sobrevive al handle que lo creó y se procesa más tarde desde otro contexto (multi->admin). - Revisa también el arreglo de un CVE anterior: si solo bloquea un camino (la reutilización del pool), otros caminos de vida del objeto, como el cierre en dos tiempos, pueden seguir abiertos.
- Al probar un use-after-free, que no falle no demuestra nada: aquí la memoria liberada solo producía un segfault con un asignador que envenena y pone en cuarentena los bloques.