curl · Report #4007917
One Strict-Transport-Security response header empties the victim's entire HSTS store, silently,
How it was found
Summary
One Strict-Transport-Security response header, from any HTTPS server the victim contacts, leaves
the victim's on-disk HSTS store at zero bytes. Every host in it loses HSTS protection, including
hosts unrelated to the server that sent the header. curl exits 0 and prints nothing.
Two behaviours combine, each harmless alone. Curl_fopen() truncates the real store before it has
produced any replacement content, and hsts_out() abandons the write when it meets an entry whose
timestamp gmtime cannot render. The caller then unlinks the temporary file, so nothing is renamed
over the already-emptied original.
The bug
One. lib/curl_fopen.c:98 opens the real target in "w" mode purely to fstat it and clone
its permission bits onto a temporary file:
*fh = curlx_fopen(filename, FOPEN_WRITETEXT); /* "w", truncates */
if(!*fh)
goto fail;
if(curlx_fstat(fileno(*fh), &sb) == -1 || !S_ISREG(sb.st_mode)) {
return CURLE_OK;
}
curlx_fclose(*fh);
The store is empty on disk from this point, before a byte of output exists.
Two. lib/hsts.c:347-353, the serialiser gives up on an unrenderable timestamp:
static CURLcode hsts_out(struct stsentry *sts, FILE *fp)
{
struct tm stamp;
if(sts->expires != TIME_T_MAX) {
CURLcode result = curlx_gmtime((time_t)sts->expires, &stamp);
if(result)
return result; /* abandons the write */
and lib/hsts.c:389-407, where one bad entry aborts the loop and discards the replacement:
result = hsts_out(sts, out);
if(result)
break;
...
if(result && tempstore)
unlink(tempstore); /* real file was already truncated */
The enabling step. lib/hsts.c:256-268 converts a parser overflow instead of rejecting it:
rc = curlx_str_number(&vp, &expires, TIME_T_MAX);
if(rc == STRE_OVERFLOW)
expires = CURL_OFF_T_MAX;
So a stored expires above what gmtime accepts but below TIME_T_MAX is reachable from a header.
Commit 4080ace060 rewrote this parser and preserved that conversion.
Reproduce
printf 'example.com "20340101 00:00:00"\n.bank.example "20340101 00:00:00"\n' > store.txt
# server replies to https:// with:
# Strict-Transport-Security: max-age=4611686018427387904
curl --hsts store.txt https://attacker.example/
echo "exit=$? size=$(stat -c %s store.txt)"
exit=0 size=0
Control, identical in every respect except the header value:
Strict-Transport-Security: max-age=31536000 -> exit=0 size=256 intact
Downgrade, measured end to end after the wipe: the Switched from HTTP to HTTPS due to HSTS line
is gone, curl dials port 80, and against a plaintext listener the query string ?token=SECRET123
left the machine in the clear.
The trigger window
max-age + now must land in (67768036191676799, TIME_T_MAX), exclusive of the upper endpoint.
The lower edge is the largest time_t glibc gmtime_r accepts. The upper edge is fail-safe,
because reaching CURL_OFF_T_MAX clamps to exactly TIME_T_MAX and routes into the literal
"unlimited" branch at lib/hsts.c:336.
| max-age | exit | store after |
|---|---|---|
| 31536000 | 0 | 256, intact |
| 253402300800 | 0 | 257, intact |
| 67768036191676800 | 0 | 0, wiped |
| 4611686018427387904 | 0 | 0, wiped |
| 9223372035066357361 | 0 | 0, wiped |
9223372036854775807 (TIME_T_MAX) | 0 | 248, intact |
| 99999999999999999999999999 | 0 | 248, intact |
Anything in [1e17, 9e18] works regardless of the victim's clock, so no clock knowledge is needed.
It is still a window, and the naive "very large number" payload falls outside it. A 32-bit time_t
is not required; this was measured on a 64-bit build.
Measured on x86_64 glibc, SIZEOF_TIME_T 8, HAVE_GMTIME_R 1.
Impact
Anyone who persists an HSTS store, which is the point of --hsts and CURLOPT_HSTSWRITEDATA. One
header from a server the victim merely visits removes HSTS for every host in the store, so the
header from attacker.example is what downgrades bank.example. Realising the loss needs a network
position afterwards, which is why I am not asking for High.
The "Saving files" exclusion does not reach this: no attacker has write access to the directory. curl destroys its own store in response to a remote header, and the asset is a security control rather than a downloaded file.
Severity
Medium. CVE-2023-46219 ("HSTS long filename clears contents") is the same outcome and curl rated it Low, as it did CVE-2023-23915, CVE-2023-23914 and CVE-2024-9681. I am asking one level above CVE-2023-46219 on the trigger and not the outcome: that one needed an unusual local filename, this needs one response header from any server and it takes down unrelated hosts.
Fix
Either change breaks the chain; both is better.
- Stop truncating the real file to stat it,
lib/curl_fopen.c:98. Usecurlx_stat()on the path, or openO_RDONLY, and drop thecurlx_fclose. Output already goes to the descriptor made at line 141. This fixes everyCurl_fopenconsumer at once, including alt-svc, and turns this whole family of failures from destructive into inert. - Make
hsts_outfail safe,lib/hsts.c:350. Skip the entry, or write it as"unlimited"the way theexpires == TIME_T_MAXbranch at line 337 already does. The loader already parsesUNLIMITED, so no format change is needed.
Rejecting an unrenderable max-age at parse time would also work, but it patches this one trigger
rather than the mechanism.
Impact
Impact
Anyone who persists an HSTS store, which is the point of --hsts and CURLOPT_HSTSWRITEDATA. One
header from a server the victim merely visits removes HSTS for every host in the store, so the
header from attacker.example is what downgrades bank.example. Realising the loss needs a network
position afterwards, which is why I am not asking for High.
The "Saving files" exclusion does not reach this: no attacker has write access to the directory. curl destroys its own store in response to a remote header, and the asset is a security control rather than a downloaded file.