Reports
BAJA[TLS Y CRIPTOGRAFÍA]#4122175

curl reutiliza en HTTPS sesiones TLS creadas para FTPS, lo que permite sustituir una respuesta verificada

La caché de sesiones TLS de curl 8.12.0 a 8.23.0 no distingue el esquema de la URL. Un atacante en la red, con varias condiciones a favor, puede hacer pasar un fichero FTPS por una respuesta HTTPS válida sin romper la verificación del certificado.

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 es una regresión de un problema que curl ya había corregido. En 2016, la auditoría de Cure53 señaló con severidad baja la reutilización de sesiones TLS de FTPS (hallazgo CRL-01-011), y curl lo arregló en el commit 1671d84b38ac61f0852e8ff2915fef3346dc53f7 exigiendo que el esquema de la sesión guardada coincidiera con el de la conexión nueva. Esa comprobación desapareció al reescribir la caché de sesiones TLS en el commit fa0ccd9f1fbbbd77bf50b26e3ba231ea6c729474, publicado por primera vez en curl 8.12.0.

El problema está en ssl_peer_key_build(), en lib/vtls/vtls_scache.c. Esa función construye la clave con la que se busca una sesión en la caché, y empieza así:

result = curlx_dyn_addf(&buf, "%s:%d:KEY-V2",
                        peer->origin->hostname, peer->origin->port);

A esa base le añade el transporte, los ajustes de verificación, las versiones y opciones de TLS, los almacenes de confianza, los pines, los datos de autenticación de cliente y la implementación TLS. Lo que nunca añade es peer->origin->scheme ni peer->origin->scheme->family.

Por tanto, una conexión FTPS y otra HTTPS con el mismo host, puerto y configuración TLS generan la misma clave, y Curl_ssl_scache_take() entrega al filtro HTTPS la sesión creada para FTPS. OpenSSL la aplica sin comprobar el protocolo de aplicación. GnuTLS también lo hace cuando el ALPN guardado es nulo, que es lo normal en FTPS.

ALPN tampoco frena el ataque. FTPS no lo negocia, así que la sesión guardada no tiene ALPN; al reanudarla, el extremo FTPS ignora la oferta HTTP y curl vuelve a HTTP/1.x.

Antes de la reescritura, Curl_ssl_getsessionid() incluía esta comparación:

strcasecompare(cf->conn->handler->scheme, check->scheme)

La reescritura eliminó Curl_ssl_session.scheme y no trasladó el esquema al nuevo ssl_peer_key.

Versiones. Se probaron como afectadas curl 8.12.0 y 8.23.0, además de master en eaa9103225; curl 8.11.1 no lo está. La prueba se reprodujo desde la etiqueta curl-8_23_0 con compilaciones Release compartidas, usando OpenSSL 3.0.13 y GnuTLS 3.8.3 en Ubuntu 24.04.3 LTS con GCC 13.3.0. Como la clave no depende del backend, el investigador recomienda revisar los demás que permitan reanudar sesiones, aunque no los incluye en el alcance confirmado.

HackerOne clasifica la debilidad como Incorrect Comparison. El investigador propuso CWE-345 o CWE-488 y severidad baja, y el reporte figura con severidad baja y estado resuelto bajo el CVE-2026-111950. Se envió el 17 de octubre de 2026 y se divulgó el 26 de octubre de 2026.

Referencias citadas en el reporte:

Pasos de reproducción

El ataque necesita una posición de intermediario activo capaz de redirigir conexiones TCP, y dos transferencias simultáneas que compartan caché de sesiones:

  1. curl inicia ftps://host:P/evil-file y establece la sesión de control FTPS S con verificación completa del certificado.
  2. El servidor FTPS anuncia un puerto pasivo de datos. El intermediario redirige la primera conexión de curl a ese puerto hacia un extremo de control FTPS y la mantiene abierta. Ahí curl reanuda S de forma legítima.
  3. curl inicia a la vez https://host:P/good-file con la misma caché de sesiones TLS compartida.
  4. El intermediario redirige esta conexión al puerto real de datos FTPS.
  5. Como la clave no separa FTPS de HTTPS, curl presenta S, y la comprobación del extremo de datos, que asocia la conexión con la sesión de control, da el visto bueno.
  6. El extremo envía evil-file, cuyos bytes forman una respuesta HTTP/1.1. curl da por buena la verificación HTTPS y devuelve el contenido del atacante.

El reporte adjuntaba un reproductor, el directorio cross-scheme-tls-session, que necesita Python 3, un compilador de C, pthreads, la CLI y las bibliotecas de desarrollo de OpenSSL y una compilación compartida de curl hecha con CMake:

python3 run.py \
  --build /path/to/curl-8.23.0-build \
  --source /path/to/curl-8.23.0

run.py compila server.c y repro.c en un directorio temporal, genera un certificado para localhost, arranca el servidor y lanza el cliente. El certificado se declara como confiable con CURLOPT_CAINFO, sin desactivar CURLOPT_SSL_VERIFYPEER ni CURLOPT_SSL_VERIFYHOST. La caché se comparte con la opción documentada CURL_LOCK_DATA_SSL_SESSION.

El servidor de prueba compara los identificadores de sesión de TLS 1.2 y solo suelta ATTACKER-CONTROLLED-CONTENT si el de la conexión HTTPS coincide exactamente con el de la sesión de control FTPS. Un servidor HTTPS malicioso cualquiera no podría cumplir esa condición.

Salida esperada en una versión vulnerable (abreviada):

ftps_result=0 (No error)
https_result=0 (No error)
https_verify_result=0
https_body=ATTACKER-CONTROLLED-CONTENT
attack_content_accepted=1
FTPS_FAKE_DATA_REUSED=1
FTPS_FAKE_DATA_MATCHES_CONTROL=1
HTTPS_CROSS_SCHEME_REUSED=1
HTTPS_SESSION_MATCHES_FTPS_CONTROL=1
ATTACK_RELEASED=1
client_exit=0
server_exit=0

El resultado se repitió en 10 de 10 ejecuciones seguidas contra curl-8_23_0 compilado en modo Release con OpenSSL.

Con el mismo arnés y la opción --expect-blocked, curl 8.11.1 no cae:

ftps_result=0 (No error)
https_result=52 (Server returned nothing (no headers, no data))
attack_content_accepted=0
FTPS_FAKE_DATA_MATCHES_CONTROL=1
HTTPS_CROSS_SCHEME_REUSED=0
HTTPS_SESSION_MATCHES_FTPS_CONTROL=0
ATTACK_RELEASED=0

Impacto

Un atacante puede sustituir por bytes elegidos por él una respuesta HTTPS, sin que se haya desactivado la verificación del certificado ni del nombre de host. La aplicación recibe un resultado correcto (https_verify_result=0) con un contenido que no procede del servicio HTTPS esperado.

El ataque demostrado exige que se cumplan todas estas condiciones, y por eso el investigador lo valora como de severidad baja:

  1. La aplicación permite al atacante provocar una transferencia FTPS y otra HTTPS contra el mismo nombre de host y el mismo puerto numérico.
  2. Ambas transferencias comparten caché de sesiones TLS. Es una configuración documentada de libcurl, y los easy handles de un mismo multi handle ya la comparten por defecto.
  3. El atacante es un intermediario activo que puede redirigir conexiones TCP, pero no falsificar certificados ni descifrar TLS.
  4. Existe un servicio FTPS en ese nombre de host que usa la reutilización de sesión TLS para asociar las conexiones de control y de datos.
  5. El atacante puede elegir o aportar el fichero descargado por FTP, y este debe empezar por una respuesta HTTP sintácticamente válida, porque curl moderno rechaza HTTP/0.9 por defecto.
  6. La ruta demostrada usa TLS 1.2 y necesita transferencias simultáneas.

Aun así, la frontera de seguridad es real: sin la reutilización de sesión entre esquemas, el extremo de datos FTPS rechaza la conexión HTTPS redirigida y no entrega ningún fichero.

Remediación

El investigador propuso volver a separar los esquemas en ssl_peer_key_build():

result = cf_ssl_peer_key_add_string(&buf, "SCHEME",
                                    peer->origin->scheme->name);
if(result)
  goto out;

Su fix.patch incluía este cambio y una prueba unitaria (unit3304). Tras aplicarlo y recompilar, la comprobación se lanza así:

python3 run.py --expect-blocked \
  --build /path/to/patched-build \
  --source /path/to/patched-source

Con el parche aplicado, el árbol actual obtiene el mismo resultado bloqueado que curl 8.11.1, la prueba pasa y la reutilización legítima entre control y datos de FTPS sigue funcionando:

FTPS_FAKE_DATA_REUSED=1
FTPS_FAKE_DATA_MATCHES_CONTROL=1

También planteó una alternativa: usar peer->origin->scheme->family como clave. Así se conservaría la reutilización entre las variantes seguras y STARTTLS de un mismo protocolo y se seguiría bloqueando el cruce de FTP a HTTP. La comparación exacta del esquema, en cambio, restaura la regla que curl aplicó desde 2016 hasta la 8.11.1.

El reporte figura como resuelto, pero no indica qué cambio se aplicó finalmente ni en qué versión.

Qué aprender de este caso

  • Si una caché de sesiones TLS guarda por host y puerto, comprueba qué identifica el protocolo de aplicación: sin esquema o familia en la clave, dos servicios con el mismo certificado pueden intercambiarse la sesión.
  • Antes de reescribir una caché, enumera las condiciones que comparaba la versión anterior y conviértelas en pruebas. Aquí, una línea con strcasecompare sobre el esquema se perdió y reabrió un hallazgo de 2016.
  • No cuentes con ALPN para separar protocolos si uno de los dos no lo negocia: una sesión guardada sin ALPN deja esa defensa sin efecto, como ocurría aquí con FTPS.
  • Si tu servicio usa la reanudación de sesión como prueba de identidad entre dos conexiones, como hace FTPS entre control y datos, recuerda que demuestra quién tiene la sesión, no qué protocolo cree estar hablando el cliente.