Reports
BAJA[TLS Y CRIPTOGRAFÍA]#3788984

curl (CVE-2026-11564): un handle reutilizado sigue confiando en el almacén de CA del sistema tras fijar una CA propia

En builds de libcurl con CA nativa, un easy handle que ya hizo una transferencia con la confianza por defecto mantenía activo el almacén del sistema aunque después se configurase una CA propia con CURLOPT_CAINFO.

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 lo encontró Trail of Bits y bagder lo trasladó al programa de curl en HackerOne para hacer seguimiento. Recibió el identificador CVE-2026-11564.

El problema está en cómo libcurl decide en qué certificados raíz confiar cuando se reutiliza un mismo easy handle para varias transferencias seguidas, algo que libcurl admite y fomenta expresamente. Afecta a builds compiladas con USE_APPLE_SECTRUST o CURL_CA_NATIVE.

En esas builds, si una transferencia usa la configuración TLS por defecto (sin CA propia), la función Curl_ssl_easy_config_complete de lib/vtls/vtls_config.c activa automáticamente el indicador native_ca_store, que hace que se consulte el almacén de certificados del sistema operativo:

#if defined(USE_APPLE_SECTRUST) || defined(CURL_CA_NATIVE)
  if(!sslc->custom_capath && !sslc->custom_cafile && !sslc->custom_cablob)
    sslc->native_ca_store = TRUE;
#endif

El fallo es que ese indicador solo se pone a TRUE, nunca se vuelve a calcular. Y las opciones con las que la aplicación indica su propio material de confianza, en lib/setopt.c, tampoco lo desactivan: solo marcan su indicador correspondiente y guardan el valor.

case CURLOPT_CAINFO:
  s->ssl.custom_cafile = TRUE;
  return Curl_setstropt(&s->str[STRING_SSL_CAFILE], ptr);
...
case CURLOPT_CAPATH:
  s->ssl.custom_capath = TRUE;
  return Curl_setstropt(&s->str[STRING_SSL_CAPATH], ptr);
...
case CURLOPT_CAINFO_BLOB:
  s->ssl.custom_cablob = TRUE;
  return Curl_setblobopt(&s->blobs[BLOB_CAINFO], blob);

El resultado es que el comportamiento depende del historial del handle, no de las opciones que tiene puestas. Dos handles con exactamente las mismas opciones públicas (CURLOPT_CAINFO_BLOB fijado y sin CURLSSLOPT_NATIVE_CA explícito) pueden usar almacenes de confianza distintos:

/* Fresh handle: custom CA is present before TLS config is completed. */
CURL *fresh = curl_easy_init();
curl_easy_setopt(fresh, CURLOPT_CAINFO_BLOB, &private_ca);
curl_easy_perform(fresh); /* native_ca_store is not auto-enabled */

/* Reused handle: an earlier default-trust transfer set native_ca_store. */
CURL *reused = curl_easy_init();
curl_easy_setopt(reused, CURLOPT_URL, "https://public.example/");
curl_easy_perform(reused); /* native_ca_store becomes TRUE */

curl_easy_setopt(reused, CURLOPT_URL, "https://api.example/");
curl_easy_setopt(reused, CURLOPT_CAINFO_BLOB, &private_ca);
curl_easy_perform(reused); /* native_ca_store can remain TRUE */

Qué pasa luego con ese indicador olvidado depende del backend TLS:

  • OpenSSL, en las plataformas donde tiene implementada la búsqueda nativa, consulta también los certificados del sistema al cargar los anclajes de confianza (lib/vtls/openssl.c):
if(ssl_config->native_ca_store) {
  ...
}
  • Rustls directamente elige el verificador de la plataforma en lugar del verificador construido con la CA propia (lib/vtls/rustls.c):
else if(ssl_config->native_ca_store) {
  result = init_config_builder_platform_verifier(data, config_builder);
  ...
}
else if(ca_info_blob || ssl_cafile) {
  result = init_config_builder_verifier(data, config_builder,
                                        conn_config, ca_info_blob,
                                        ssl_cafile);
  ...
}
  • Schannel no se ve afectado, porque el código se salta la activación automática de la CA nativa en ese backend.

El mismo patrón se da con la confianza TLS hacia proxies HTTPS cuando se fijan CURLOPT_PROXY_CAINFO, CURLOPT_PROXY_CAPATH o CURLOPT_PROXY_CAINFO_BLOB después de una configuración por defecto.

Pasos de reproducción

La prueba de concepto demuestra el estado interno incorrecto; el efecto final sobre la aceptación de certificados se deduce del código de los backends que leen native_ca_store.

  1. Compilar curl con la CA nativa activada y los tests habilitados:
cmake -S . -B build-review-native -G Ninja \
  -DENABLE_DEBUG=ON -DCURL_CA_NATIVE=ON \
  -DBUILD_TESTING=ON -DBUILD_CURL_EXE=OFF -DBUILD_EXAMPLES=OFF \
  -DCURL_USE_LIBPSL=OFF
cmake --build build-review-native --target curlu units -j8
  1. Con un easy handle, completar la configuración TLS por defecto y comprobar que native_ca_store queda activado.
  2. Fijar CURLOPT_CAINFO en ese mismo handle y volver a completar la configuración TLS con Curl_ssl_easy_config_complete.
  3. Comprobar que custom_cafile está activado pero native_ca_store sigue también a TRUE. Estas son las aserciones de la prueba:
#if defined(USE_APPLE_SECTRUST) || defined(CURL_CA_NATIVE)
  fail_unless(((struct Curl_easy *)curl)->set.ssl.native_ca_store,
              "default TLS config should enable native CA store");
  curl_easy_setopt(curl, CURLOPT_CAINFO, "custom-ca.pem");
  if(Curl_ssl_easy_config_complete((struct Curl_easy *)curl, origin))
    goto unit_test_abort;
  fail_unless(((struct Curl_easy *)curl)->set.ssl.custom_cafile,
              "custom CAfile flag should be set");
  fail_unless(((struct Curl_easy *)curl)->set.ssl.native_ca_store,
              "custom CAfile after default config keeps native CA store");
#endif
  1. Compilar y ejecutar la prueba contra la build anterior (las rutas son las del entorno local del auditor):
python3 _loose_review/valid/build_internal_poc.py build-review-native \
  _loose_review/valid/confirmed-valid/native-ca-state-persists-after-custom-ca/poc_native_ca_stale_after_custom_ca.c \
  build-review-native/poc_native_ca_stale_after_custom_ca
build-review-native/poc_native_ca_stale_after_custom_ca

La ejecución devolvió:

CONFIRMED: native_ca_store persists after switching the easy handle to custom CAINFO.

Impacto

Es un salto de la política de autenticación TLS que la aplicación cree haber configurado. El escenario descrito:

  • Un proceso de larga duración, o un pool de handles, usa el mismo easy handle para varias peticiones HTTPS consecutivas, en una build afectada y con un backend que implementa la CA nativa.
  • La primera petición va a un sitio público con la confianza por defecto, lo que activa native_ca_store.
  • Después la aplicación fija CURLOPT_CAINFO_BLOB con un bundle privado para hablar con https://api.example y espera confiar solo en esa CA.
  • Si esa segunda petición abre un nuevo handshake TLS, un atacante capaz de presentar un certificado para api.example que encadene con el almacén del sistema (aunque no con el bundle privado) consigue completar el handshake.

Es decir, la aplicación intentaba restringir en quién confiar y esa restricción no se aplicaba. Con OpenSSL la confianza se amplía (CA propia más las del sistema); con Rustls se usa el verificador del sistema en lugar del de la CA propia.

Para explotarlo hacen falta varias condiciones a la vez: una build con CA nativa, un backend afectado, una secuencia concreta de reutilización del handle, un handshake nuevo y un certificado del atacante que valide contra el almacén del sistema. En HackerOne el reporte figura con severidad baja, aunque el informe de Trail of Bits y la revisión del equipo de curl la valoraron como media.

Remediación

El reporte figura como resuelto. La recomendación de Trail of Bits fue:

  • A corto plazo, que Curl_ssl_easy_config_complete recalcule native_ca_store a partir de las opciones actuales en lugar de limitarse a activarlo, y que lo desactive cuando haya un fichero, ruta o blob de CA propio, salvo que se haya pedido explícitamente CURLSSLOPT_NATIVE_CA.
  • A largo plazo, separar el estado explícito de CURLSSLOPT_NATIVE_CA del activado automáticamente por defecto, y añadir tests de regresión que reutilicen un handle pasando de la confianza por defecto a CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_CAINFO_BLOB y sus equivalentes de proxy.