Reports
BAJA[TLS Y CRIPTOGRAFÍA]#3972357

curl descarta una entrada HSTS de subdominio al cargar la caché y acaba pidiendo por HTTP sin cifrar

Al importar la caché HSTS, curl desechaba la entrada de un subdominio con includeSubDomains si su dominio padre se cargaba antes. Cuando caducaba el padre, las peticiones a ese subdominio salían por HTTP en claro.

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

curl puede guardar las políticas HSTS que aprende en una caché, ya sea en fichero (--hsts) o a través de la función de lectura CURLOPT_HSTSREADFUNCTION. El fallo, registrado como CVE-2026-87862, aparece al volver a cargar esa caché: si contiene un dominio padre con includeSubDomains y, después en el orden de carga, un subdominio que también tiene includeSubDomains pero caduca más tarde, curl se queda solo con el padre y tira el hijo sin avisar.

Mientras el padre sigue vigente no se nota nada, porque su política cubre también al subdominio. El problema llega cuando el padre caduca: la política del hijo, que debía seguir activa por su cuenta, ya no existe, y curl manda la petición por HTTP sin cifrar en lugar de pasarla a HTTPS. El investigador señala que esto va contra el modelo de RFC 6797 §5.3, según el cual cada host tiene su propia política, independiente de las demás.

La debilidad registrada es Cleartext Transmission of Sensitive Information (CWE-319).

Versiones afectadas

  • Carga desde fichero: el fallo entró con el commit a94973805df96269bf3f3bf0a20ccb9887313316, que era precisamente la corrección de CVE-2024-9681, y salió por primera vez en curl 8.11.0.
  • Carga mediante callback: heredó la misma lógica con el commit e65ba1bd349a7affb09275a442b6a0837df079d9, publicado en curl 8.20.0.
  • Se comprobó en el commit c2676bf9e639e31e44b12a2cc82cf2046231b056.

Causa

Todo está en hsts_add_host_expire(), en lib/hsts.c. Para saber si el host que se importa ya está en la caché, llama en la línea 464 a:

e = hsts_check(h, host, hostlen, subdomain);

Con subdomain=TRUE, hsts_check() hace coincidencia jerárquica: si .example.test ya está cargado con includeSubDomains, devuelve esa entrada como resultado al buscar child.example.test. A partir de ahí, la función solo hace dos cosas:

  • crea una entrada nueva si no hay coincidencia;
  • actualiza la existente si el nombre devuelto es exactamente el del host importado.

Cuando la coincidencia es un antepasado no se cumple ninguna de las dos condiciones, así que el hijo ni se crea ni se actualiza: se pierde.

Las políticas que curl aprende de la red, con la cabecera Strict-Transport-Security, no tienen este problema, porque Curl_hsts_parse() busca solo por nombre exacto (hsts_check(..., FALSE), línea 296).

Pasos de reproducción

  1. Compila curl desde master (o cualquier versión desde la 8.11.0) con soporte HSTS y ENABLE_DEBUG=ON, que permite fijar el reloj con la variable CURL_TIME.
  2. Crea un fichero de caché HSTS con dos entradas includeSubDomains (el punto inicial lo indica): primero el padre y después el hijo, con una caducidad posterior:

`` .example.test "20241010 00:00:00" .child.example.test "20251010 00:00:00" ``

  1. Fija el reloj en un momento en que las dos entradas sigan vigentes, por ejemplo CURL_TIME=1728465947 (octubre de 2024).
  2. Lanza cualquier petición con --hsts <fichero-de-caché> para que curl cargue la caché y la vuelva a escribir.
  3. Abre el fichero reescrito: solo queda la entrada del padre. La del hijo ha desaparecido.
  4. Adelanta el reloj a un momento posterior a la caducidad del padre y anterior a la del hijo, por ejemplo CURL_TIME=1728600000 (11 de octubre de 2024).
  5. Levanta un servidor HTTP sin cifrar en localhost y ejecuta:

`` curl --hsts <fichero-de-caché> --resolve child.example.test:80:127.0.0.1 http://child.example.test/ ``

  1. curl envía la petición por HTTP en claro, en lugar de pasarla a HTTPS.

El investigador añadió dos pruebas de control:

  • Con el hijo antes que el padre en el fichero original, se conservan las dos entradas y la petición sí pasa a HTTPS.
  • Si el hijo no lleva includeSubDomains (sin punto inicial: child.example.test), se conservan las dos entradas sea cual sea el orden.

Impacto

Tras cargar y reescribir la caché, el subdominio se queda sin su política HSTS. En cuanto caduca la del padre, una petición a http://child.example.test ya no pasa a HTTPS, y un atacante activo en la red puede leer o modificar un tráfico que HSTS debía proteger.

El propio reporte reconoce que tienen que darse a la vez varias condiciones, y eso rebaja mucho la gravedad práctica:

  • HSTS tiene que estar activado en curl.
  • Las dos entradas tienen que usar includeSubDomains.
  • El padre tiene que cargarse primero y estar vigente en ese momento.
  • El hijo tiene que caducar después que el padre.
  • La caché recortada tiene que seguir usándose después de que caduque el padre. Si se vuelve a cargar desde una fuente intacta, el hijo vuelve a estar.

Remediación

El investigador propuso que la importación busque duplicados solo por nombre exacto, igual que Curl_hsts_parse() con las políticas aprendidas de la red. Es decir, cambiar la llamada de la línea 464:

-    e = hsts_check(h, host, hostlen, subdomain);
+    e = hsts_check(h, host, hostlen, FALSE);

Así la coincidencia jerárquica queda solo para decidir si HSTS se aplica a una petición, y la deduplicación al importar compara nombres exactos. El reporte consta como resuelto y con CVE asignado.

Qué aprender de este caso

  • Una misma función de búsqueda puede servir para dos preguntas distintas: "¿qué política se aplica a este host?" (jerárquica) y "¿ya tengo esta entrada?" (exacta). Cuando una caché de dominios use la misma búsqueda para las dos, revisa qué pasa al cargar un subdominio después de su padre.
  • Las rutas de importación (cargar desde fichero o callback) suelen ir por un camino distinto al de las políticas que llegan por la red. Compara las dos: aquí la de red usaba FALSE y la de importación no.
  • Prueba el orden de las entradas y las caducidades cruzadas. El fallo solo aparecía con el padre primero y un hijo más longevo, y solo se notaba al pasar el tiempo; fijar el reloj (como CURL_TIME) es lo que lo deja ver.
  • Las correcciones de seguridad también pueden traer fallos nuevos: este entró con el arreglo de CVE-2024-9681. Al revisar un parche, mira también qué casos que antes funcionaban deja fuera.