HexBrain

curl · Report #3972357

CVE-2026-87862: HSTS cache import drops longer-lived child

Validstatus
14 Oct 2026disclosed

How it was found

HSTS cache import silently drops a longer-lived child beneath an includeSubDomains parent

Summary

Loading an HSTS cache (file or CURLOPT_HSTSREADFUNCTION callback) can silently discard a child entry. When a live includeSubDomains parent is imported before a longer-lived includeSubDomains child, only the parent is retained. After the parent expires, the child's independently valid HSTS policy is gone and curl sends a plaintext HTTP request instead of upgrading to HTTPS. This violates the independent-policy model required by RFC 6797 §5.3.

Affected versions

The file-load path regressed in commit a94973805df96269bf3f3bf0a20ccb9887313316 (CVE-2024-9681 fix), first released in curl 8.11.0. The callback-load path inherited the same logic in commit e65ba1bd349a7affb09275a442b6a0837df079d9, released in curl 8.20.0. Verified at c2676bf9e639e31e44b12a2cc82cf2046231b056.

Root cause

hsts_add_host_expire() in lib/hsts.c calls hsts_check(h, host, hostlen, subdomain) with subdomain=TRUE at line 464. hsts_check() performs hierarchical matching: if the parent .example.test is already loaded with includeSubDomains, it returns the parent as a match for child.example.test. The caller creates an entry only when no match exists, and its update branch runs only when the returned hostname exactly matches the imported hostname. An ancestor match therefore causes neither an update nor a creation — the child is silently dropped.

Network-learned Strict-Transport-Security processing does not have this problem because Curl_hsts_parse() uses exact-only lookup (hsts_check(..., FALSE)) at line 296.

Reproduction steps

Build curl from current master (or any version since 8.11.0) with HSTS support enabled and ENABLE_DEBUG=ON (for CURL_TIME clock control). Create an HSTS cache file with two includeSubDomains entries where the parent is listed first and the child has a later expiry, e.g.: .example.test "20241010 00:00:00" .child.example.test "20251010 00:00:00" Set the clock to a time when both entries are still valid (e.g. CURL_TIME=1728465947, October 2024). Run any curl request with --hsts <cache-file> to force curl to load and rewrite the cache. Inspect the rewritten cache file — it now contains only one entry (the parent). The child entry has been silently dropped. Advance the clock past the parent's expiry but before the child's (e.g. CURL_TIME=1728600000, October 11 2024). Start a plaintext HTTP server on localhost and run curl --hsts <cache-file> --resolve child.example.test:80:127.0.0.1 http://child.example.test/. Observe that curl sends the request over plaintext HTTP instead of upgrading to HTTPS — the child's still-valid HSTS policy is gone. As a control, repeat with the child listed before the parent in the original cache file — both entries are preserved and the request is correctly upgraded to HTTPS. As a second control, repeat with the child entry without includeSubDomains (no leading dot: child.example.test) — both entries are preserved regardless of ordering.

Suggested fix

Use exact-host lookup for import deduplication, as Curl_hsts_parse() already does for network-learned policies. Replace the hsts_check() call at line 464 with subdomain=FALSE:

-    e = hsts_check(h, host, hostlen, subdomain);
+    e = hsts_check(h, host, hostlen, FALSE);

This reserves hierarchical matching for deciding whether HSTS applies to a request, while import deduplication only matches on exact hostname.

Impact

Summary:

When curl rewrites its HSTS cache after loading, the child entry is gone. After the parent expires, a request to http://child.example.test is no longer upgraded to HTTPS. An active network attacker can observe or modify traffic that should have been protected by HSTS.

The prerequisites limit practical severity: HSTS must be enabled; both entries must use includeSubDomains; the parent must be loaded first while still live; the child must outlive it; and the collapsed state must persist until after the parent expires. Reloading from an intact original source after parent expiry restores the child.

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