Reports
BAJA[CORRUPCIÓN DE MEMORIA]#4116527

Use-after-free en curl con proveedores de OpenSSL 3: el contexto se libera antes de cerrar la conexión TLS

Con CURLOPT_SSLENGINE apuntando a un proveedor de OpenSSL 3 y la interfaz multi, curl liberaba el OSSL_LIB_CTX mientras una conexión pendiente de cierre seguía usándolo. Es un arreglo incompleto de CVE-2026-80229.

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

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_SSLENGINE tiene un valor que no es un ENGINE válido de OpenSSL (por ejemplo "default", el nombre de un proveedor o una cadena provider:propq). En ese caso ossl_set_engine() no encuentra el ENGINE y pasa a ossl_set_provider().
  • La transferencia usa la interfaz multi (curl_multi_* o curl --parallel).
  • El servidor TLS no completa el close_notify en 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:

  1. Al terminar la transferencia con éxito, multi_done_locked() ve la marca de cierre (bits.close) y devuelve CPOOL_DO_CLOSE.
  2. cpool_discard_conn() intenta cerrar la conexión una vez. ossl_shutdown() empieza con SSL_read(); si el servidor aún no ha mandado close_notify, obtiene SSL_ERROR_WANT_READ y la conexión se encola en multi->cshutdn, ya separada del handle easy. El cierre posterior lo hace multi->admin, que no es dueño de ese libctx:
/* 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);
  1. La aplicación hace lo normal tras CURLMSG_DONE: curl_multi_remove_handle() y curl_easy_cleanup(). Como la conexión ya no pertenece al handle, la retirada no la destruye; en cambio Curl_close() llama a Curl_ssl_close_all() → ossl_close_all() → ossl_provider_cleanup(), que descarga los proveedores y libera el contexto mientras cshutdn todavía tiene el SSL / 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;
  1. En el siguiente curl_multi_perform() o en curl_multi_cleanup() se procesa la cola cshutdn: ossl_shutdown() llama a SSL_read() / SSL_shutdown() y después ossl_close() ejecuta SSL_free() y SSL_CTX_free(), todos sobre un SSL_CTX cuyo libctx ya está liberado. SSL_read() llega a EVP_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:

  1. Crear un SSL_CTX con SSL_CTX_new_ex sobre un libctx privado y completar el handshake.
  2. Descargar los proveedores default y base y llamar a OSSL_LIB_CTX_free.
  3. Llamar a SSL_read (lo primero que hace ossl_shutdown()).

Resultado:

  • Con la memoria liberada sin tocar, SSL_read devolví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_read provocaba un segfault en CRYPTO_THREAD_read_lock, llamado desde EVP_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_CTX creado a partir del contexto toma otra.
  • curl_easy_cleanup() suelta solo la del handle; ossl_close() suelta la de la conexión después de SSL_CTX_free().
  • La descarga real de proveedores y el OSSL_LIB_CTX_free ocurren al soltar la última referencia, aunque sea más tarde desde cshutdn.
  • Lo mismo en la ruta de limpieza de QUIC (lib/vquic/vquic-tls.c), que libera el SSL_CTX sin pasar por ossl_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.