Resumen
curl puede guardar en un fichero las políticas HSTS que recibe (opción --hsts <file> o CURLOPT_HSTS en libcurl), para que en ejecuciones posteriores siga forzando HTTPS en esos dominios. El investigador encontró que un único servidor HTTPS podía vaciar ese fichero entero, eliminando también las políticas de dominios que no tienen nada que ver con él. Se le asignó el CVE-2026-87863 y HackerOne lo clasifica como condición de carrera Time-of-check Time-of-use (TOCTOU).
El fallo es la suma de cuatro piezas que, por separado, parecen inofensivas:
Curl_hsts_parse()(lib/hsts.c:243) leemax-ageconcurlx_str_number()y solo comprueba que no supereTIME_T_MAX. Un valor como67768036190000000pasa el filtro y se suma a la hora actual para obtener la fecha de caducidad (línea 293). En 64 bits el resultado cabe entime_t, pero queda fuera de lo que puede representar unastruct tm.Curl_hsts_save()(lib/hsts.c:387) llama aCurl_fopen(). Fuera de Windows, esta función abre el fichero de destino en modo escritura con truncado (lib/curl_fopen.c:98) antes de comprobar si es un fichero normal (líneas 101-104). En ese instante la caché original ya mide cero bytes.hsts_out()(lib/hsts.c:348-351) intenta convertir la caducidad a texto concurlx_gmtime(), que falla por el desbordamiento. El bucle de guardado se interrumpe y el fichero temporal se cierra y se borra (lib/hsts.c:399-404). Nada sustituye al original ya truncado.Curl_close()ignora el valor devuelto porCurl_hsts_save()(lib/url.c:260), así que la transferencia y el proceso terminan con éxito y el usuario no se entera.
Origen de la regresión
El truncado prematuro llegó con el commit 0c667188e0 ("fopen: optimize"), publicado en curl 8.2.0. Antes, Curl_fopen() hacía un stat() sobre la ruta sin tocarla y abría el fichero solo después; el cambio pasó a llamar primero a fopen() (que trunca) y luego a fstat() sobre el descriptor. Ese efecto secundario ya se había señalado en la incidencia #21610; este reporte demuestra que se puede provocar a distancia.
El problema de gmtime_r() con fechas enormes también tenía antecedentes (#7720): allí se trató el caso concreto de TIME_T_MAX, pero no los valores menores que siguen sin caber en struct tm.
Versiones afectadas: todas desde la 8.2.0, siempre que se use la persistencia de HSTS en fichero.
Pasos de reproducción
- Compilar curl desde el commit
c2676bf9e639e31e44b12a2cc82cf2046231b056en Linux o macOS de 64 bits, con OpenSSL y cmake (-DCURL_USE_OPENSSL=ON). - Generar un certificado TLS autofirmado para
attacker.test(con el SAN correspondiente), válido al menos un día. - Crear una caché HSTS de partida,
test.cache, con una entrada de otro dominio:
# Your HSTS cache. https://curl.se/docs/hsts.html
# This file was generated by libcurl! Edit at your own risk.
unrelated.test "20991231 23:59:59"
- Levantar un servidor HTTPS local en
127.0.0.1:8443con ese certificado, que responda a cualquier GET con200 OKy esta cabecera:
Strict-Transport-Security: max-age=67768036190000000
- Lanzar curl contra él usando la caché:
curl --silent --show-error --cacert server.pem --noproxy '*' \
--resolve attacker.test:8443:127.0.0.1 --hsts test.cache \
https://attacker.test:8443/
- Comprobar que el código de salida es
0: curl informa de éxito. - Mirar el tamaño de
test.cache: 0 bytes. La entrada deunrelated.testha desaparecido. - Confirmar la pérdida de la política cargando esa caché vacía en un proceso nuevo de curl y pidiendo
http://unrelated.test:8080/con--write-out '%{url_effective}'. La URL sigue siendohttp://: ya no hay actualización a HTTPS. - Como control, repetir el paso 5 con un valor normal:
- Strict-Transport-Security: max-age=67768036190000000
+ Strict-Transport-Security: max-age=3600
En ese caso la caché conserva unrelated.test y añade attacker.test, lo que confirma que el problema está en el valor imposible de serializar.
Impacto
El efecto directo es que un solo origen HTTPS borra de forma persistente las políticas HSTS de todos los dominios que comparten el mismo fichero de caché. El objetivo del atacante sería quitar esa protección a dominios ajenos para poder hacer después un ataque clásico de SSL stripping (degradar la conexión a HTTP).
Para llegar a exponer tráfico en claro hacen falta además varias condiciones:
- Que se use persistencia de HSTS en fichero (
--hsts <file>oCURLOPT_HSTS), una función que hay que activar expresamente. - Que haya un proceso posterior o una recarga de la caché.
- Que ese proceso pida explícitamente una URL
http://de un dominio cuya política se haya borrado. - Que haya un atacante en la ruta de red capaz de interceptar esa conexión en claro.
Además, la respuesta que dispara el fallo tiene que venir de un origen HTTPS en el que el cliente confíe.
Remediación
El reporte figura como resuelto y con CVE asignado, pero no detalla el parche aplicado. El investigador propuso tres medidas:
- Que
Curl_fopen()no abra el destino con truncado solo para inspeccionarlo: hacerstat()sin modificarlo, escribir todo en un temporal y renombrarlo de forma atómica únicamente si todas las entradas se han serializado bien. - Rechazar o limitar en
Curl_hsts_parse()ohsts_out()losmax-agecuya caducidadcurlx_gmtime()no pueda convertir, en vez de fallar en silencio al guardar. - No descartar el resultado de
Curl_hsts_save()enCurl_close().
Qué aprender de este caso
- Cuando una aplicación persiste cachés de seguridad (HSTS, pinning, listas de cookies), revisa cómo reescribe el fichero: si lo abre con truncado antes de tener el contenido nuevo listo, cualquier fallo a mitad de escritura deja el estado en blanco. Lo seguro es escribir en un temporal y hacer
rename()atómico al final. - Validar un número contra el máximo del tipo (
TIME_T_MAX) no basta si luego pasa por otra función con un rango menor, comogmtime()ystruct tm. Busca valores que se aceptan al leer pero rompen al guardar o mostrar; los tiempos de expiración (max-age,expires) son buenos candidatos. - Los errores ignorados en rutas de cierre o limpieza (
close,cleanup, destructores) esconden fallos destructivos: aquí curl salía con código 0 tras perder la caché. Si guardar falla, al menos hay que avisar y no tocar el fichero original. - Un "optimize" que cambia
stat()+fopen()porfopen()+fstat()altera el comportamiento cuando el modo de apertura trunca: en revisiones de código, cualquier cambio en el orden de comprobación y uso de ficheros merece mirarse como posible TOCTOU.