Reports
MEDIA[CORRUPCIÓN DE MEMORIA]#4122239

curl con OpenSSL: la caché de CApath provoca un use-after-free que un servidor malicioso puede disparar

Desde curl 8.23.0, los certificados de CApath quedan en la caché del multi handle tras liberarse su contexto de OpenSSL. Un servidor con un certificado falso puede hacer que el cliente lea esa memoria liberada y se caiga.

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, un use-after-free (CVE-2026-111952, severidad media), está en lib/vtls/openssl.c y nace de un desajuste de vidas: dos objetos que dependen el uno del otro se liberan en momentos distintos.

Por un lado, cuando una transferencia usa CURLOPT_SSLENGINE con un provider, ossl_set_provider() crea un OSSL_LIB_CTX privado y el SSL_CTX nace dentro de él. Ese contexto se libera en curl_easy_cleanup(), a través de ossl_provider_cleanup().

Por otro lado, desde la versión 8.23.0 el almacén de certificados se guarda en caché por multi handle aunque esté definido CURLOPT_CAPATH (commit 3c60b05098, PR #23363). Con CApath, X509_STORE_load_path() solo registra una búsqueda en el directorio hasheado: OpenSSL lee cada CA cuando la necesita durante la construcción de la cadena, bajo el contexto de biblioteca del SSL_CTX, y la añade al almacén cacheado. Ese almacén sigue vivo en el multi handle hasta CURLOPT_CA_CACHE_TIMEOUT (24 horas por defecto), y la clave de caché de ossl_cached_x509_store_different() no tiene en cuenta el contexto de biblioteca.

El resultado es que los certificados cacheados sobreviven al contexto al que pertenecen. La siguiente transferencia adjunta el almacén y, cuando la selección del emisor llega a uno de esos certificados, se lee el contexto ya liberado por esta cadena de llamadas:

check_sig_alg_match() → EVP_PKEY_is_a() → EVP_KEYMGMT_is_a() → ossl_namemap_stored()

Condiciones necesarias

  • libcurl con OpenSSL 3.x y CURLOPT_SSLENGINE con un provider (probado con default) en la transferencia que carga la CA.
  • CURLOPT_CAPATH contiene una CA que no se carga también por CURLOPT_CAINFO; por ejemplo, una PKI privada.
  • La aplicación limpia ese easy handle y arranca otro en el mismo multi handle. La herramienta curl lo hace con --parallel --parallel-max 1.
  • La verificación del par y la caché de CA están activas (ambas lo están por defecto) y la conexión posterior hace una verificación completa de la cadena. Una sesión TLS reanudada se la salta, por eso la reproducción usa dos nombres de origen.

Versiones afectadas

  • curl 8.23.0 y master en eaa9103225. En master, el commit ec0a5d1b06 (PR #23436) ata el contexto del provider a la conexión y no al easy handle, pero el almacén cacheado sigue sobreviviéndole: basta un solo easy handle con CURLOPT_FORBID_REUSE que haga dos transferencias.
  • De 8.14.0 a 8.22.0 no se usa la caché del almacén cuando hay CApath. Las compilaciones con CURL_CA_FALLBACK, que usan el directorio hasheado por defecto de OpenSSL sin CApath, no se probaron.

Pasos de reproducción

  1. Compilar curl 8.23.0 contra OpenSSL 3.
  2. Ejecutar el script adjunto al reporte original:
CURL=/absolute/path/to/curl sh c4repro.sh

El script monta una PKI privada con un directorio CApath hasheado (raíz e intermedia), hojas falsificadas que nombran a la intermedia como emisora pero están firmadas con una clave sin relación, e instancias de openssl s_server en 127.0.0.1 que envían solo su hoja.

Los dos casos de atacante, abreviados por el investigador:

# [2] forged leaves only, provider on both transfers
curl --parallel --parallel-max 1 --engine default --capath capath \
  https://127.0.0.1:8444/ https://localhost:8444/

# [4] provider on the first transfer only; the second leaf carries an
#     unknown extension (OID 1.2.3.4) with 327 bytes of 0x41
curl --parallel --parallel-max 1 --engine default --capath capath https://localhost:8444/ \
  --next --capath capath https://127.0.0.1:8445/

Las pruebas se hicieron en x86-64 con glibc: el tarball de 8.23.0 contra el OpenSSL 3.0.13 de Ubuntu 24.04 (3.0.13-0ubuntu3.16) y contra OpenSSL 3.5.5 compilado desde la etiqueta openssl-3.5.5, y la etiqueta curl-8_23_0 (commit 3163ec0656) contra los paquetes de Ubuntu de OpenSSL 3.0.13 y 3.5.5.

| Caso | OpenSSL 3.0.13 | OpenSSL 3.5.5 | |---|---|---| | control: sin provider, servidor legítimo | 200, 200 | 200, 200 | | [1] provider en ambas, servidor legítimo | SIGSEGV | 200, 200 | | [2] provider en ambas, hojas falsas | SIGSEGV | 60, 60 | | [3] sin provider, hojas falsas | 60, 60 | 60, 60 | | [4] provider solo en la primera, hoja falsa con relleno | SIGSEGV o 60, 60 según el heap | SIGSEGV, 5 de 5 ejecuciones | | [5] como [2], hoja falsa sin authority key identifier | SIGSEGV | 60, 60 |

Que una ejecución se caiga de forma nativa depende de qué ocupe el bloque liberado en el momento de leerlo. Bajo valgrind, todos los casos con provider muestran la lectura inválida en las dos versiones de OpenSSL. El caso [5] demuestra que al certificado falso le basta con llevar el subject de la CA como nombre de emisor.

Extracto de valgrind (tarball con OpenSSL 3.5.5, caso [1]):

Invalid read of size 8
   at ossl_lib_ctx_get_data (context.c:581)
   by ossl_namemap_stored (core_namemap.c:485)
   by evp_is_a (evp_fetch.c:665)
   by EVP_KEYMGMT_is_a (keymgmt_meth.c:338)
   by check_sig_alg_match (v3_purp.c:419)
   by check_issued (x509_vfy.c:473)
   by X509_STORE_CTX_get1_issuer (x509_vfy.c:448)
   ...
   by ossl_connect_step2 (openssl.c:4166)
 Address 0x53407f8 is 184 bytes inside a block of size 328 free'd
   by ossl_provider_cleanup (openssl.c:1747)
   by Curl_close (url.c:244)
   by curl_easy_cleanup (easy.c:891)
 Block was alloc'd at
   by OSSL_LIB_CTX_new (context.c:446)
   by ossl_set_provider (openssl.c:1786)
   by curl_easy_setopt (setopt.c:2943)

En el caso [4] con OpenSSL 3.5.5, la extensión de 327 bytes, que ASN1_STRING_set() guarda en una reserva de 328, ocupa el bloque liberado del contexto, y en el desplazamiento 184 queda su puntero al mapa de nombres. Así se ve en gdb en el momento del fallo:

rdi            0x4141414141414141
=> <ossl_namemap_empty+14>:  mov    0x20(%rdi),%eax
#0  ossl_namemap_empty (namemap=0x4141414141414141) at crypto/core_namemap.c:97
#1  ossl_namemap_stored (libctx=<optimized out>) at crypto/core_namemap.c:491
#2  evp_is_a (..., name="rsaEncryption") at crypto/evp/evp_fetch.c:665

Con bytes 0x42 en la extensión, la misma ejecución falla con namemap=0x4242424242424242. El reporte adjunta también c4test.c, que repite la secuencia con la API multi de libcurl (modo two; un último argumento - desactiva el provider en el segundo handle).

Impacto

Puede provocar el use-after-free, si se dan las condiciones anteriores, cualquier servidor al que se conecte la aplicación o quien pueda interceptar una de sus conexiones TLS. La consecuencia demostrada es la caída del cliente.

Lo más delicado es que el atacante no necesita ninguna firma de confianza ni authority key identifier: solo el subject de una CA presente en el CApath de la víctima, un dato que aparece como emisor en todos los certificados que esa CA ha firmado.

En OpenSSL 3.5.5, además, el servidor elige con los bytes de su certificado la dirección que libcrypto lee durante la verificación. No se ha demostrado revelación del valor leído, escritura ni ejecución de código, y el tamaño de extensión que recupera el bloque liberado depende de la versión de OpenSSL y del asignador de memoria.

Remediación

El reporte original no describe el parche final aplicado por curl, pero sí incluye la propuesta del investigador, con parches adjuntos para master y 8.23.0: evitar por completo la caché compartida del X509_STORE, tanto la consulta como la inserción, mientras la transferencia use un contexto de biblioteca privado.

El investigador descarta dos alternativas. Eliminar la entrada de caché al liberar el contexto no basta, porque las conexiones que ya adjuntaron el almacén siguen referenciando sus certificados. Comparar nombres de provider o direcciones de contexto tampoco garantiza la vida útil del objeto.

Qué aprender de este caso

  • Si una caché guarda objetos que dependen de un contexto con vida más corta (aquí, certificados atados a un OSSL_LIB_CTX), revisa qué libera ese contexto y si la caché puede sobrevivirle: es un buen punto de partida para buscar fallos parecidos.
  • La carga perezosa complica el análisis: X509_STORE_load_path() no carga nada al llamarla, y los objetos entran en el almacén más tarde, durante la verificación. Comprueba bajo qué contexto se crean en ese momento y no solo en el de la configuración.
  • Al ampliar una caché a un caso que antes quedaba fuera, como CApath en este reporte, repasa si la clave sigue cubriendo todo lo que distingue a sus entradas. Y recuerda que quitar una entrada no invalida las referencias que otras conexiones ya tengan a sus objetos.
  • Para fallos de este tipo, un SIGSEGV que aparece o no según la ejecución no descarta nada: valgrind mostró aquí la lectura inválida en casos que de forma nativa terminaban con un simple error 60.