curl · Report #4023192
CVE-2026-92390: -J no-clobber guarantee bypassed with 103 early hints
How it was found
Hi all,
I found a way for a server to defeat --remote-header-name's (-J) no-clobber guarantee: with curl -O -J -i, sending a 103 Early Hints before the final response (or an ETag ahead of Content-Disposition under --etag-save) lets a server overwrite an existing file it names via Content-Disposition, even though -J documents that exact case as protected.
This is a post-CVE-2020-8177 variant of the same -J plus -i header-overwrite class. The earlier flaw was fixed in curl 7.71.0; this case reaches the same unsafe state through informational-response and --etag-save header ordering after support for combining -J with displayed headers was added.
Why it happens
-J promises not to overwrite an existing server-selected filename (documented here). Curl waits for a usable Content-Disposition header, or the 8.19.0 redirect Location fallback, before applying that check.
With -i, curl normally buffers displayed headers while it waits for the filename. However, the displayed-header path still opens the output file for an HTTP 1xx response such as 103 Early Hints. The same happens for an ETag line when --etag-save handles it before Content-Disposition. That early open uses the normal truncating mode on the URL-derived filename.
When the final Content-Disposition arrives, curl sees that the output stream is already open and aborts. The protected no-clobber open is never reached, but the URL-derived file has already been truncated. If the URL is .../victim.txt and the server names victim.txt, this is the exact file that -J promises to protect.
This also matches RFC 8297's requirement that Early Hints must not affect final-response processing. See RFC 8297.
PoC
Server sends 103 Early Hints, then a final response whose Content-Disposition filename is identical to the URL basename and to a pre-existing local file. Control: same server, same file, no 103.
#!/bin/sh
# Reproduces AISLE-2026-0115-00244: curl -O -J -i lets a server's 103 Early
# Hints defeat -J's O_EXCL no-clobber protection when Content-Disposition
# names the same file as the URL basename.
set -e
MODE="$1" # exploit (103 first) | control (no 103)
PORT="${VF_PORT:-18080}"
WORK="${VF_WORK:-/tmp/vf-j-noclob}"
mkdir -p "$WORK"; cd "$WORK"
printf 'ORIGINAL-VICTIM-CONTENT-DO-NOT-DELETE\n' > victim.txt
python3 -c "
import socket, time
mode = '$MODE'
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', $PORT)); s.listen(1)
c, _ = s.accept()
c.recv(65536)
if mode == 'exploit':
c.sendall(b'HTTP/1.1 103 Early Hints\r\n\r\n')
time.sleep(0.1)
body = b'ATTACKER-CONTROLLED-BODY\n'
# Content-Disposition filename matches the URL basename on purpose.
c.sendall(b'HTTP/1.1 200 OK\r\nContent-Disposition: attachment; filename=\"victim.txt\"\r\n'
b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
" &
sleep 0.3
rc=0
curl -sS -O -J -i "http://127.0.0.1:$PORT/victim.txt" || rc=$?
wait
echo "mode=$MODE rc=$rc victim=[$(cat victim.txt)]"
Measured with the local curl 8.22.1-DEV build; the same relevant code is present at curl-8_22_0:
mode=control rc=23 victim=[ORIGINAL-VICTIM-CONTENT-DO-NOT-DELETE]
mode=exploit rc=23 victim=[HTTP/1.1 103 Early Hints
]
Same server-chosen filename, same pre-existing file, same final response — the only difference is the 103 arriving first. control shows the guarantee working: curl refuses to touch victim.txt and exits with an error. exploit shows it defeated: the file is truncated and replaced with header text before curl ever gets to the check that was supposed to protect it. --no-clobber preserves the original file in both cases, although it may create a numbered sibling such as victim.txt.1 and the transfer still exits with error 23, so it is a working mitigation in the meantime.
Suggested direction
Header display needs to be held back the same way Content-Disposition parsing already is, for as long as honor_cd_filename is active: buffer displayed headers from informational responses and the --etag-save ETag line until the filename decision is final, then open exactly one path with its correct clobber mode. The implementation must continue to parse filename-bearing headers only from the applicable 2xx/3xx response block; RFC 8297 states that 103 headers must not affect final-response processing. A regression test wants a 103 (or an ETag-before-Content-Disposition response under --etag-save) naming the same file as the URL basename, asserting the pre-existing file survives. See RFC 8297.
Preconditions: curl's command-line tool invoked with -O -J -i, default clobber mode, an HTTP server able to send an informational response or an early ETag before its applicable Content-Disposition, and a pre-existing local file matching the name the server chooses.
Cheers,
Josh
Impact
A server a user runs curl -O -J -i against can destroy a specific existing local file: it picks a Content-Disposition filename matching the URL basename (or the fallback filename extracted from a redirect target when -L is used) and precedes the applicable response with a 103 or an early ETag. This defeats a guarantee -J documents explicitly, not just the ordinary -O overwrite behavior.