curl · Report #3973124
14: HSTS cache import silently drops a longer-lived child beneath an includeSubDomains parent
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.
Security impact
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.
Reproduction
- Build an affected curl release, starting with 8.11.0, with HSTS support enabled.
- Choose a parent hostname and one of its child hostnames, such as
example.testandchild.example.test. - Create an HSTS cache file containing two live entries. Place the parent first, mark both entries as
includeSubDomains, and give the child a later expiration time than the parent. - Preserve an unchanged copy of this original cache for use as a control.
- Make curl load the test cache and complete an unrelated HTTP transfer that does not return a
Strict-Transport-Securityheader. This causes curl to rewrite the loaded cache without learning replacement HSTS state for either test hostname. - Inspect the rewritten cache. On an affected build, only the parent entry remains; the independently valid child entry has been silently discarded.
- As an ordering control, repeat the import with the child listed before the parent. Confirm that both entries remain in the rewritten cache.
- As a matching control, repeat the parent-first import with the child configured as an exact-host policy rather than
includeSubDomains. Confirm that both entries remain. - Wait until the parent policy has expired while the child's original expiration time is still in the future. A debug build with a controlled clock may be used instead of waiting.
- Start a plaintext HTTP server and map the child test hostname to that server.
- Request an
http://URL on the child hostname while using the collapsed, rewritten cache. - Observe the vulnerable result: curl retains the HTTP scheme and sends the request to the plaintext server because the parent has expired and the child policy was lost.
- Repeat the same request using the preserved original cache in which the child entry is still present.
- Observe the expected control result: curl upgrades the URL to HTTPS. If the test endpoint supports only plaintext HTTP, the TLS handshake fails instead of transmitting an HTTP request.
- Confirm a fix by changing HSTS import deduplication to use exact-host matching while retaining hierarchical matching only for policy application. Repeat the parent-first test and verify that both entries persist and the later child request is upgraded to HTTPS.
Expected result
Parent and child HSTS policies must be stored independently. Import order must not remove a child's longer-lived policy, and the child must continue to force HTTPS after the parent expires.
Vulnerable result
The importer mistakes the already-loaded parent for a duplicate of the child and drops the child entry. Once the parent expires, curl sends a request to the child over plaintext HTTP.
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:
After the incorrectly retained parent HSTS policy expires, curl may send a child-domain request over plaintext HTTP even though the child’s own policy remains valid. An active network attacker can intercept or modify that request, potentially exposing credentials, authorization headers, request bodies, and response data that should have been protected by HTTPS.