Reports
MEDIA[DENEGACIÓN DE SERVICIO]#4022003

CVE-2026-92387: un servidor FTP malicioso congela todas las transferencias de libcurl saltándose el timeout

Un servidor FTP, IMAP, POP3 o SMTP que nunca envía la línea de respuesta final deja a libcurl girando en un bucle que no comprueba el timeout, bloqueando esa transferencia y todas las demás del mismo multi handle.

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

Un investigador encontró la forma de que un servidor malicioso de FTP, IMAP, POP3 o SMTP anule por completo el timeout de transferencia de libcurl y, de paso, congele cualquier otra transferencia que comparta el mismo multi handle. El truco no requiere nada sofisticado: basta con no enviar nunca la última línea de respuesta del protocolo.

El problema vive en la máquina de estados que libcurl usa para los protocolos de tipo "pregunta-respuesta" (ping-pong), en lib/pingpong.c. La función Curl_pp_statemach() comprueba el timeout de respuesta una sola vez, justo antes de entrar en la máquina de estados:

// lib/pingpong.c:70-75
if(!pp->response_time)
  pp->response_time = ...;
...

A partir de ahí toma el control Curl_pp_readresp(), que es la parte que no devuelve el control a quien la llamó:

// lib/pingpong.c:252-342
do {
  gotbytes = 0;
  ...
  result = pingpong_read(data, sockindex, buffer, sizeof(buffer), &gotbytes);
  ...
  result = Curl_dyn_addn(&pp->recvbuf, buffer, gotbytes);
  ...
  /* scan for newline-terminated lines, dispatch each to endofresp() */
} while(gotbytes == sizeof(buffer));

Esta función lee en un buffer fijo de 900 bytes, busca líneas terminadas en salto de línea y entrega cada una al callback endofresp del protocolo. En FTP ese callback es ftp_endofresp(), que solo da una línea por definitiva cuando es un código de respuesta real de tres dígitos seguido de un espacio (lo comprueba la macro LASTLINE()); una línea de continuación como 220-... nunca cuenta como final.

El detalle clave: dentro de ese bucle do/while no se llama a Curl_timeleft_ms(), ni a Curl_pgrsCheck(), ni se impone ningún límite de bytes o de líneas. Mientras el servidor mantenga al menos 900 bytes en cola, recv() sigue devolviendo lecturas completas, el do/while vuelve a entrar una y otra vez y la función sencillamente nunca termina. Quien decide cuánto dura eso es el atacante, no curl.

De dónde viene

El bucle sin límite es relativamente reciente. Antes del commit a5598b6fcb ("pingpong: drain the input buffer when reading responses", 19-08-2024), Curl_pp_readresp() leía como mucho una vez por llamada: cogía hasta 900 bytes, procesaba las líneas completas que ya tuviera en el buffer y devolvía el control. Cada pasada volvía al bucle de despacho de quien la llamaba, que es también donde el timeout tenía otra oportunidad de comprobarse.

Aquel commit envolvió toda la función en do { ... } while(gotbytes == sizeof(buffer)) precisamente para que los datos que quedaran en los buffers de TLS no obligaran a "dejar algo y esperar a que te vuelvan a llamar". Es un problema legítimo que resolver, pero el arreglo eliminó lo único que separaba a un servidor parlanchín de un bucle de eventos colgado: el punto de retorno natural una vez por llamada. El investigador indica que el fallo seguía presente en la rama main en el momento del análisis.

Pasos de reproducción

La prueba monta dos handles compartiendo un único multi handle:

  • Handle A: habla FTP con un servidor malicioso y lleva CURLOPT_TIMEOUT_MS=5000. El CURLOPT_CONNECTTIMEOUT_MS se pone muy por encima de la ventana de prueba (20 s) para que lo que se ejerza sea el plazo global de la transferencia, no la fase de conexión.
  • Handle B: una petición HTTP normal a un servidor benigno que retrasa su respuesta a propósito 300 ms, de modo que su respuesta está lista mucho antes de que acabe la inundación de A. Así se aísla si la respuesta ya disponible de B se atiende con prontitud o no.

Se construyó y ejecutó contra un checkout limpio de la revisión auditada e5e1c356107ea4f8208757deb69776d94496cfae (CMake), con el libcurl resultante conducido por la API pública multi, sin modificar:

checkout: e5e1c356107ea4f8208757deb69776d94496cfae
build:    cmake -S . -B build -DCURL_USE_OPENSSL=ON -DHTTP_ONLY=OFF -DBUILD_SHARED_LIBS=ON \
              -DCURL_DISABLE_LDAP=ON -DUSE_NGTCP2=OFF -DUSE_QUICHE=OFF -DUSE_LIBSSH2=OFF \
              -DCMAKE_INSTALL_PREFIX=<prefix> && cmake --build build -j4 && cmake --install build
run:      sh reproduce.sh exploit
control:  sh reproduce.sh control

En modo exploit, el servidor FTP malicioso inunda la conexión con líneas de continuación no finales durante la ventana de prueba (90 s). La idea es generar un bloque lleno de líneas 220-...\r\n, que nunca satisfacen LASTLINE(), y escribirlo en bucle:

/* Continuation lines: code 220 followed by '-' at offset 3, never a
 * final "220 ...", per ftp_endofresp()/LASTLINE() in lib/ftp.c. */
char chunk[65536];
size_t off = 0;
while (off + 64 <= sizeof(chunk)) {
  int n = snprintf(chunk + off, sizeof(chunk) - off,
                    "220-still starting up please wait %06zu\r\n", off);
  off += (size_t)n;
}

En modo control, el servidor envía una única línea final real (220 control-mode welcome\r\n) de inmediato y luego se queda inactivo, para ejercer el timeout de 5 s sobre un estado normal de "esperando la siguiente respuesta" en lugar del bucle vulnerable.

El driver añade A y B al mismo multi handle y mide cuándo termina cada uno:

curl_multi_add_handle(multi, a);
curl_multi_add_handle(multi, b);
...
while (still_running && (now_ms() - t0) < watchdog_ms) {
  curl_multi_perform(multi, &still_running);
  ...
  if (still_running) { int numfds = 0; curl_multi_wait(multi, NULL, 0, 100, &numfds); }
}

Resultados medidos (revisión auditada, CURLOPT_TIMEOUT_MS=5000, ventana de inundación de 90 s):

exploit: RESULT configured_timeout_ms=5000 a_done_at_ms=90005.0 a_result=28(Timeout was reached) b_done_at_ms=90005.0 elapsed_ms=90005.0
control: RESULT configured_timeout_ms=5000 a_done_at_ms=5002.0  a_result=28(Timeout was reached) b_done_at_ms=306.1 elapsed_ms=5002.0

En control, el plazo de 5000 ms salta puntualmente (a_done_at_ms=5002.0) y B, ya respondido hacia los 300 ms, acaba justo después (b_done_at_ms=306.1).

En exploit, el mismo plazo no se respeta en absoluto: A solo termina cuando se detiene la inundación de 90 s (a_done_at_ms=90005.0, 18 veces su plazo configurado). Y B, cuyo servidor ya había escrito su respuesta hacia los 300 ms, tampoco se atiende hasta ese mismo instante: 89,7 segundos después de estar lista.

Impacto

El fallo entrega a cualquier servidor FTP/IMAP/POP3/SMTP contactado una primitiva de denegación de servicio a nivel de proceso contra aplicaciones que reparten varias transferencias sobre un mismo multi handle (o un mismo hilo gestionándolo). Un único servidor malicioso —al que la aplicación solo le hizo una pregunta— puede congelar indefinidamente cualquier otra transferencia en curso de ese bucle de eventos, sin importar qué timeout tuvieran configurado esas otras transferencias.

Condiciones previas:

  • Una aplicación que use FTP, IMAP, POP3 o SMTP junto a otras transferencias en el mismo multi handle.
  • CURLOPT_TIMEOUT_MS / --max-time configurado en la transferencia atacada (justo la contramedida que la política de seguridad de curl espera que ponga quien llama).
  • Un servidor dispuesto a mantener la conexión de control legible sin enviar nunca una línea de respuesta final.

No hace falta ningún privilegio más allá de la accesibilidad de red normal: el atacante es el propio servidor FTP al que la aplicación se conecta.

Remediación

La dirección propuesta por el investigador es acotar el trabajo que hace cada invocación de Curl_pp_readresp(): limitar las lecturas, los bytes o las líneas que consume por llamada y devolver el control a quien la llamó aunque el buffer siga llenándose. Además, llamar a Curl_pgrsCheck() y consultar Curl_pp_state_timeleft_ms() dentro del bucle de lectura repetida, y no solo en Curl_pp_statemach() antes de entrar en él, de modo que un servidor siempre legible no pueda impedir que los plazos de respuesta y globales se comprueben nunca, ni para él ni para las demás transferencias del multi handle.

Qué aprender de este caso

  • En máquinas de estado de protocolos de pregunta-respuesta, desconfía de cualquier bucle de lectura cuya condición de salida dependa solo de que el peer deje de enviar datos (while(gotbytes == sizeof(buffer))): si la comprobación de timeout está fuera del bucle, el atacante que mantiene el socket siempre legible la convierte en inalcanzable.
  • Al revisar código de red, comprueba que el chequeo de deadline y de progreso ocurre dentro del bucle que puede iterar indefinidamente, no una sola vez antes de entrarlo; mover un recv a un do/while sin reubicar ese chequeo es exactamente lo que introdujo aquí el fallo.
  • Lo que para un parser es una línea "no final" (una continuación 220-... que nunca satisface el patrón de cierre) es para un atacante una forma de alimentar el bucle sin fin: cualquier estado que solo avance al recibir un terminador concreto necesita además un límite propio de bytes o de líneas.
  • En arquitecturas con un bucle de eventos compartido (como el multi handle de curl), una sola operación que no cede el control bloquea a todas las demás; conviene imponer cuotas de trabajo por iteración para que ninguna conexión monopolice el despacho.