HexBrain

curl · Report #3972373

CVE-2026-87863: HSTS response erases persisted entries

Validstatus
3upvotes
14 Oct 2026disclosed

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

  1. Curl_hsts_parse() (lib/hsts.c:243) parses max-age via curlx_str_number() with limit TIME_T_MAX. The value 67768036190000000 fits, so no overflow occurs. It is added to the current time (line 293) to form an absolute expiry.

  2. Curl_hsts_save() (lib/hsts.c:387) calls Curl_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.

  3. hsts_out() (lib/hsts.c:348-351) calls curlx_gmtime() on the absolute expiry. It fails because the timestamp exceeds struct 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.

  4. Curl_close() discards the return value of Curl_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:

  1. File-backed HSTS persistence (--hsts <file> or CURLOPT_HSTS).
  2. A later process or cache reload.
  3. An explicit http:// URL for a host whose policy was erased.
  4. 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

  1. Build curl from commit c2676bf9e639e31e44b12a2cc82cf2046231b056 on a 64-bit Linux or macOS host with OpenSSL, using cmake with -DCURL_USE_OPENSSL=ON.

  2. Generate a self-signed TLS certificate for the hostname attacker.test (with a matching SAN), valid for at least one day.

  3. 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"

  1. 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.

  2. 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/

  3. Verify the exit code is 0 — curl reports success.

  4. Check the cache file size — it will be exactly 0 bytes. The pre-existing unrelated.test entry is gone.

  5. 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.

  6. 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

  1. 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.

  2. In Curl_hsts_parse() or hsts_out(), reject or clamp accepted max-age values whose absolute expiry curlx_gmtime() cannot serialize, rather than silently failing at save time.

  3. Do not discard the Curl_hsts_save() result in Curl_close().

Impact

Summary:

The immediate impact is cross-origin persistent HSTS state loss. Plaintext exposure additionally requires:

  1. File-backed HSTS persistence (--hsts <file> or CURLOPT_HSTS).
  2. A later process or cache reload.
  3. An explicit http:// URL for a host whose policy was erased.
  4. 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.

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