curl · Report #3972373
CVE-2026-87863: HSTS response erases persisted entries
How it was found
Summary
An authenticated HTTPS origin can return a Strict-Transport-Security: max-age value that Curl_hsts_parse() accepts but curlx_gmtime() cannot later serialize. On 64-bit platforms, max-age=67768036190000000 produces an absolute expiry within time_t range but outside struct tm's representable range. During HSTS cache save, Curl_fopen() truncates the original cache file before writing a temporary replacement. When serialization fails, the temporary file is deleted, leaving the original cache empty. The save error is silently discarded by Curl_close(), so curl exits successfully.
The result is cross-origin persistent HSTS state loss: one HTTPS origin erases policies for all unrelated hosts sharing the same HSTS cache file.
Affected Versions
This is a remotely triggerable escalation of the truncation regression in #21610, introduced by commit 0c667188e0 ("fopen: optimize") and shipped in curl 8.2.0. The gmtime_r() overflow gap was previously discussed in #7720; that fix special-cased TIME_T_MAX but does not cover smaller accepted timestamps outside struct tm's range.
All versions from 8.2.0 onward are affected, provided HSTS file-based persistence is enabled (--hsts <file> or CURLOPT_HSTS).
Root cause
-
Curl_hsts_parse()(lib/hsts.c:243) parsesmax-ageviacurlx_str_number()with limitTIME_T_MAX. The value67768036190000000fits, so no overflow occurs. It is added to the current time (line 293) to form an absolute expiry. -
Curl_hsts_save()(lib/hsts.c:387) callsCurl_fopen(). On non-Windows,Curl_fopen()opens the destination with write/truncate mode (lib/curl_fopen.c:98) before checking whether it is a regular file (lines 101-104). The original cache is now zero bytes. -
hsts_out()(lib/hsts.c:348-351) callscurlx_gmtime()on the absolute expiry. It fails because the timestamp exceedsstruct tm's representable range.Curl_hsts_save()breaks out of the serialization loop, closes and deletes the temporary file (lib/hsts.c:399-404). Nothing replaces the truncated original. -
Curl_close()discards the return value ofCurl_hsts_save()(lib/url.c:260). The transfer and process exit successfully.
Before commit 0c667188e0, Curl_fopen() called stat() without opening the file, then conditionally opened it. The "optimize" commit switched to fopen() first (which truncates), then fstat() on the file descriptor — introducing the destructive side-effect.
Impact
The immediate impact is cross-origin persistent HSTS state loss. Plaintext exposure additionally requires:
- File-backed HSTS persistence (
--hsts <file>orCURLOPT_HSTS). - A later process or cache reload.
- An explicit
http://URL for a host whose policy was erased. - An on-path attacker able to intercept that later plaintext connection.
The triggering response must come from an HTTPS origin trusted by the client. The --hsts feature is opt-in.
Reproduction
-
Build curl from commit c2676bf9e639e31e44b12a2cc82cf2046231b056 on a 64-bit Linux or macOS host with OpenSSL, using cmake with -DCURL_USE_OPENSSL=ON.
-
Generate a self-signed TLS certificate for the hostname attacker.test (with a matching SAN), valid for at least one day.
-
Create a seed HSTS cache file (test.cache) containing a pre-existing entry for an unrelated host, e.g.:
Your HSTS cache. https://curl.se/docs/hsts.html
This file was generated by libcurl! Edit at your own risk.
unrelated.test "20991231 23:59:59"
-
Start a local HTTPS server on 127.0.0.1:8443 using the generated certificate that responds to any GET request with a 200 OK and the header Strict-Transport-Security: max-age=67768036190000000.
-
Run curl against the local HTTPS server, pointing at the seed cache: curl --silent --show-error --cacert server.pem --noproxy '*'
--resolve attacker.test:8443:127.0.0.1 --hsts test.cache
https://attacker.test:8443/ -
Verify the exit code is 0 — curl reports success.
-
Check the cache file size — it will be exactly 0 bytes. The pre-existing unrelated.test entry is gone.
-
Confirm HSTS policy loss by loading the empty cache in a new curl process and requesting http://unrelated.test:8080/ with --write-out '%{url_effective}'. The URL will remain http:// (no HSTS upgrade), proving the policy was erased.
-
As a control, repeat step 5 with an ordinary max-age=3600 instead. The cache file will retain the unrelated.test entry and add the attacker.test entry normally, confirming the issue is specific to the unserializable value.
Suggested fix
-
In
Curl_fopen(), do not open the destination in truncate mode to inspect it.stat()the existing path without modifying it, write the complete output to a temporary file, and atomically rename only after every entry is serialized successfully. -
In
Curl_hsts_parse()orhsts_out(), reject or clamp acceptedmax-agevalues whose absolute expirycurlx_gmtime()cannot serialize, rather than silently failing at save time. -
Do not discard the
Curl_hsts_save()result inCurl_close().
Impact
Summary:
The immediate impact is cross-origin persistent HSTS state loss. Plaintext exposure additionally requires:
- File-backed HSTS persistence (
--hsts <file>orCURLOPT_HSTS). - A later process or cache reload.
- An explicit
http://URL for a host whose policy was erased. - An on-path attacker able to intercept that later plaintext connection.
The triggering response must come from an HTTPS origin trusted by the client. The --hsts feature is opt-in.
The attacker's goal is to strip HSTS protection from unrelated domains, enabling a classic SSL-stripping / protocol downgrade attack.