Resumen
libcurl tiene una opción, CURLSSLOPT_AUTO_CLIENT_CERT (dentro de CURLOPT_SSL_OPTIONS), que en Windows con el backend Schannel localiza y presenta automáticamente un certificado de cliente. Para que ese certificado no se use con hosts a los que la aplicación no apuntó directamente, libcurl recalcula en cada petición si la opción aplica, y la desactiva cuando una redirección lleva la transferencia a un origen distinto del inicial.
El fallo (CVE-2026-86415) está en que esa decisión nunca llega al caché de conexiones. Al elegir qué conexión reutilizar, libcurl no mira el valor de auto_client_cert, de modo que una petición redirigida —para la que se ha calculado explícitamente auto_client_cert = FALSE— puede acabar en una conexión que ya estaba abierta y autenticada con certificado de cliente.
Dónde está el problema
El cálculo por petición se hace en Curl_ssl_easy_config_complete():
// lib/vtls/vtls_config.c:236-238
sslc->auto_client_cert = Curl_peer_equal(origin, initial_origin) &&
!!(options & CURLSSLOPT_AUTO_CLIENT_CERT);
initial_origin es el origen de la primera URL de curl_easy_perform() y se mantiene durante toda la cadena de redirecciones. Si la redirección cambia de host, el resultado es FALSE, que es lo correcto.
Pero ese indicador vive en struct ssl_config_data:
// lib/vtls/vtls_config.h:74-75
BIT(auto_client_cert); /* automatically locate and use a client
certificate for authentication (Schannel) */
mientras que las conexiones del pool solo guardan struct ssl_primary_config:
// lib/urldata.h:322
struct ssl_primary_config ssl_config;
La función que decide la reutilización, match_ssl_primary_config(), recibe dos punteros a ssl_primary_config, así que ni siquiera tiene acceso al campo:
// lib/vtls/vtls_config.c:136-146
static bool match_ssl_primary_config(struct Curl_easy *data,
struct ssl_primary_config *c1,
struct ssl_primary_config *c2)
{
(void)data;
if((c1->version == c2->version) &&
(c1->version_max == c2->version_max) &&
(c1->ssl_options == c2->ssl_options) &&
(c1->native_ca_store == c2->native_ca_store) &&
(c1->verifypeer == c2->verifypeer) &&
(c1->verifyhost == c2->verifyhost) &&
Sí compara ssl_options, la máscara de bits tal como la configuró la aplicación, pero esa máscara es idéntica en todas las peticiones del mismo easy handle y siempre coincide. El valor derivado que de verdad indica "¿se puede usar el certificado con este origen?" no se consulta en ningún momento.
Como al reutilizar no hay handshake nuevo, el FALSE recién calculado nunca llega a aplicarse. El único sitio donde se lee fuera de vtls_config.c es Schannel, al preparar las credenciales:
// lib/vtls/schannel.c:783-786
if(!ssl_config->auto_client_cert) {
flags &= ~(DWORD)SCH_CRED_USE_DEFAULT_CREDS;
flags |= SCH_CRED_NO_DEFAULT_CREDS;
infof(data, "schannel: disabled automatic use of client certificate");
(también se usa para ISC_REQ_USE_SUPPLIED_CREDS en lib/vtls/schannel.c:981). Ningún otro backend TLS lee el campo: con OpenSSL, GnuTLS y el resto, CURLSSLOPT_AUTO_CLIENT_CERT no tiene efecto y el fallo no se manifiesta.
Origen del fallo
Según el investigador, lo introdujo el commit d69bfad3fa3daf5e72331f6870667607828d5891 ("ssl native_ca_store: always reinit", 8 de junio de 2026). Antes, auto_client_cert se fijaba una sola vez en setopt.c a partir del bit de CURLOPT_SSL_OPTIONS, sin tener en cuenta el origen. Ese commit añadió el helper ssl_easy_config_compl_options() y la comprobación con Curl_peer_equal(origin, initial_origin), pero no tocó match_ssl_primary_config(), así que la protección nació incompleta. Seguía presente en main en el commit 38703fe2 (2 de septiembre de 2026).
Pasos de reproducción
El reporte describe el escenario, no un PoC ejecutable. Las condiciones y la secuencia son:
- Usar libcurl en Windows con el backend Schannel y activar
CURLSSLOPT_AUTO_CLIENT_CERTmedianteCURLOPT_SSL_OPTIONS, con el seguimiento de redirecciones activado. - Desde la aplicación, hacer una transferencia directa al host B. Como B es el origen inicial,
auto_client_certvaleTRUEy el certificado de cliente se presenta en el handshake. La conexión queda viva en el pool. - Hacer después una petición al host A, controlado por el atacante, que responde con una redirección 3xx y una cabecera
Location:que apunta a una ruta de B elegida por él. libcurl la procesa enhttp_header_l:
// lib/http.c:3414-3439 (http_header_l)
if(HD_IS(hd, hdlen, "Location:")) {
...
data->req.location = location;
if((k->httpcode >= 300 && k->httpcode < 400) &&
data->set.http_follow_mode) {
...
data->req.newurl = curlx_strdup(data->req.location); /* clone */
- Para la petición redirigida, libcurl calcula
auto_client_cert = FALSE, porque B no es el origen inicial (A). - Al buscar conexión,
match_ssl_primary_config()encuentra la de B del paso 2 y la da por válida, porque no comparaauto_client_cert. - La petición a la ruta elegida por A viaja por la conexión ya autenticada con el certificado de cliente, sin handshake nuevo.
Impacto
Un servidor remoto del que la aplicación sigue redirecciones puede hacer que una petición cuya ruta decide él se envíe a otro host (B) dentro de una sesión ya autenticada con certificado de cliente. El certificado no llega nunca al atacante: se queda en B, que ya lo tenía. Pero A consigue lanzar peticiones autenticadas contra los endpoints de B en una cadena de redirecciones que ni B ni la aplicación querían autenticar, que es justo lo que la comprobación de initial_origin pretendía evitar.
Hacen falta todas estas condiciones: Windows con Schannel, CURLSSLOPT_AUTO_CLIENT_CERT activado, redirecciones seguidas y una conexión viva en el pool hacia el destino de la redirección, abierta directamente y no a través de otra redirección. El reporte consta en HackerOne con severidad baja.
Remediación
El reporte no detalla el parche final; consta como resuelto con el CVE-2026-86415. El investigador propuso dos vías: mover auto_client_cert a ssl_primary_config para que match_ssl_primary_config() pueda compararlo, o hacer que Curl_ssl_conn_config_match() rechace la coincidencia cuando la petición nueva tiene auto_client_cert en FALSE y la conexión candidata se estableció con TRUE.
Qué aprender de este caso
- Si una decisión de seguridad se calcula por petición (usar o no credenciales, certificados o tokens según el origen), comprueba que la función que reutiliza conexiones del pool también la compara. Si no, la decisión solo se aplica en las conexiones nuevas.
- Revisa qué estructura se guarda en la conexión y cuál se compara al reutilizarla: un campo que está en la configuración "secundaria" es invisible para el matcher por definición. Aquí bastaba con ver la firma de
match_ssl_primary_config(). - Comparar la opción tal como la configuró el usuario (
ssl_options) no basta cuando el comportamiento real depende de un valor derivado del contexto (auto_client_certsegún el origen). Hay que comparar el valor efectivo. - Al auditar redirecciones en clientes HTTP, prueba el caso "conexión autenticada ya abierta al destino": lo que se quita en el handshake (credenciales por defecto, certificados) no sirve si no hay handshake.