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.
- 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
- Con un easy handle, completar la configuración TLS por defecto y comprobar que
native_ca_storequeda activado. - Fijar
CURLOPT_CAINFOen ese mismo handle y volver a completar la configuración TLS conCurl_ssl_easy_config_complete. - Comprobar que
custom_cafileestá activado peronative_ca_storesigue también aTRUE. 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
- 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_BLOBcon un bundle privado para hablar conhttps://api.exampley 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.exampleque 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_completerecalculenative_ca_storea 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ícitamenteCURLSSLOPT_NATIVE_CA. - A largo plazo, separar el estado explícito de
CURLSSLOPT_NATIVE_CAdel activado automáticamente por defecto, y añadir tests de regresión que reutilicen un handle pasando de la confianza por defecto aCURLOPT_CAINFO,CURLOPT_CAPATH,CURLOPT_CAINFO_BLOBy sus equivalentes de proxy.