curl · Report #4122039
libcurl shared-HSTS-cache race leads to use-after-free with attacker-controlled function-pointer call
How it was found
Missing lock on the shared HSTS cache in lib/hsts.c (CURL_LOCK_DATA_HSTS) allows a race that frees an stsentry still referenced by another thread — deterministic same-address reclaim proves the freed object is fully attacker-writable and its destructor pointer is called with an attacker-chosen argument
Severity
High (suggested) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H (8.1)
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector | Network | Triggered by HTTPS responses (HSTS headers) processed by the application |
| Attack Complexity | High | Needs a multi-threaded application sharing one HSTS cache plus a race window; the memory-writability stage is deterministic once the free happens |
| Privileges Required | None | Any HTTPS origin the application contacts can carry the header |
| User Interaction | None | Nothing beyond the application's normal traffic |
| Scope | Unchanged | Corruption stays inside the libcurl-using process |
| C / I / A | High / High / High | Demonstrated controlled function-pointer call → arbitrary command execution; the un-reclaimed race also crashes (SIGSEGV 20/20 runs in the companion PoC) |
The program should make the final severity call; the reporter suggests High.
Summary
Curl_hsts_parse() and the internal hsts_check() mutate an HSTS cache without holding the share lock (CURL_LOCK_DATA_HSTS). When an HSTS handle is attached to a curl_share handle and used from multiple threads — the exact scenario the share interface exists for — two threads can operate on the same linked-list node concurrently. The max-age=0 deletion path unlinks and frees an stsentry (Curl_node_remove() → hsts_free()) while another walker still holds a pointer to it: CWE-362 leading to CWE-416.
Only Curl_hsts_loadfiles() takes the HSTS lock (lib/hsts.c:625). The parse and expiry paths do not.
The accompanying PoC removes the race timing and proves, deterministically and through real libcurl code, everything that happens after the free:
- Exact-address reclaim — the next
Curl_hsts_parse()call (attacker-controlled header content) re-carves the just-freedstsentryaddress via libcurl's ownhsts_create()calloc. - Full overwrite — all 56 user bytes of the freed object are filled with attacker-chosen content.
- Real-code consumption — the REAL
Curl_node_remove()fromlib/llist.c— the identical call a stalehsts_check()walker would make — executes the attacker-staged node: the attacker-chosen destructor function pointer is invoked with an attacker-chosen argument, and attacker-directed pointer writes land through the_prev/_nextrelinks. - Command execution — with the destructor staged as a
popen()-based callback and the node's second field pointing at a command string, the dispatch executes an arbitrary shell command (demonstrated:id).
The chain succeeds on the main thread and inside a spawned worker pthread, against a static libcurl built from the vulnerable commit, with no debugger, no RWX pages, and no race timing in the writability proof.
Affected component
- Project: curl / libcurl
- Version: 8.23.1-DEV @ commit
2c04412d8c81784b24fafe03e8389c6ce7797eef - File:
lib/hsts.c(unlocked shared-cache mutation) - Secondary:
lib/llist.c(Curl_node_remove()/take_elem— the consumer of the corrupted node) - Self-reported build string:
libcurl: libcurl/8.23.1-DEV OpenSSL/3.6.4 - Tested platform: macOS (Darwin 27, arm64, native). The PoC source also builds on Linux aarch64 (glibc), where the reclaim is single-shot deterministic via tcache LIFO.
Technical description
The unlocked mutation (root cause)
lib/hsts.c guards only one entry point with the share lock:
/* hsts.c:625 — inside Curl_hsts_loadfiles(): the ONLY place the HSTS
share lock is taken */
Curl_share_lock(data, CURL_LOCK_DATA_HSTS, LOCK_ACCESS_SINGLE);
Curl_hsts_parse() — reached for every HTTPS response carrying a Strict-Transport-Security header — and the internal hsts_check() both read and mutate the cache's linked list without taking CURL_LOCK_DATA_HSTS. On a shared HSTS handle used from two or more easy handles in different threads, this interleaving frees a node the other thread still references:
Thread A (max-age=0 delete path, hsts.c:283-291):
hsts_check() finds a matching entry
-> Curl_node_remove() unlinks the stsentry
-> hsts_free() frees it <- hsts.c:285-288
-> return CURLE_OK (no allocation in between)
Thread B (concurrent parse/lookup on the same shared cache):
still holds / re-reads the freed node -> use-after-free
The allocation that creates the node (and the one that reclaims it) is in hsts_create():
/* hsts.c:123 */
sts = curlx_calloc(1, sizeof(struct stsentry) + hlen + 1);
For a cached host of 8 characters (sizeof(struct stsentry) = 48 on LP64): calloc(1, 56) → a 0x40 chunk (usable 56 bytes on glibc; malloc_size 64 on Apple).
What the freed object gives an attacker
On LP64, the first 32 bytes of an stsentry are the embedded list node and the dtor-carried pointer:
struct llnode { /* offsets in the freed stsentry */
struct llist *_list; /* +0 */
void *_ptr; /* +8 */
struct llnode *_prev; /* +16 */
struct llnode *_next; /* +24 */
};
struct llist {
struct llnode *_head; /* attacker-faked list */
struct llnode *_tail;
void (*_dtor)(void *, void *); /* <-- invoked by real libcurl code */
size_t _size;
};
When the real Curl_node_remove() (lib/llist.c, take_elem) removes the attacker-staged node it:
- reads
_list,_prev,_nextfrom the freed chunk, - performs pointer writes through them (
_prev->_next = _next; _next->_prev = _prev), - decrements the (attacker-faked) list
_size, - and calls
dtor(NULL, elem)— an attacker-chosen function pointer invoked with the attacker-chosen_ptras its second argument.
That is a controlled indirect call plus two controlled-address writes, reached purely through genuine libcurl code operating on the reclaimed object.
Attack preconditions
- The application uses libcurl's share interface with HSTS sharing (
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_HSTS)) — e.g. a multi-threaded downloader, crawler, or HTTP client sharing HSTS state across worker threads. - The attacker can cause the application to process HTTPS responses they influence — any origin they control (or a MITM position) suffices, because the
Strict-Transport-Securityheader is attacker-supplied response content. Both sides of the race are attacker-chosen:max-age=0(deletion/free) andmax-age=<large>(creation/reclaim). - libcurl built without a fix for this race.
The PoC below links the same static libcurl (built with CURL_HIDDEN_SYMBOLS=OFF so the internal symbols are visible) and drives the exact functions involved — Curl_hsts_parse() on real HSTS handles created through libcurl's own init path, and the real Curl_node_remove() from lib/llist.c — so the consumption stage is genuine library code, not a reimplementation.
Steps to reproduce
The poc_curl/ folder shipped alongside this report contains the PoC:
poc_uaf_write.c PoC source (macOS + Linux aarch64)
poc_uaf_write prebuilt static binary (macOS arm64, Darwin 27)
run_uaf_write_macos.sh build + link + run one-shot script (macOS)
hackerone-report.md this report
output/ fresh evidence transcripts (see Evidence)
Run (macOS, native):
cd poc_curl
./run_uaf_write_macos.sh # plain writability proof
./run_uaf_write_macos.sh "id" # + command execution through the hijacked dtor
The script builds a static libcurl from commit 2c04412d8c81784b24fafe03e8389c6ce7797eef (OpenSSL backend from bundled static archives), links the PoC, and runs it once. Exit codes (mapped by the script to a VERDICT):
| rc | Meaning |
|---|---|
| 0 | UAF_WRITE_PROVEN (with an argument: + COMMAND_EXECUTED) |
| 1 | RECLAIM_FAILED (allocator did not return the freed address; on Apple retried across fresh processes) |
| 2 | HSTS_SETUP_FAILED |
| 3 | CONSUMPTION_FAILED (or build/link failure) |
Allocator behavior by platform:
- Linux aarch64 / glibc — single-shot deterministic: same-thread free +
callocunder the per-thread tcache LIFO contract cannot miss, andcallocbypasses tcache so libcurl's own next allocation cannot steal the chunk either. - Apple libmalloc (Darwin 27) — retrieval is keyed on request size, so the PoC hunts
malloc/calloc× request=56/usable=64 (≤512 attempts, misses held unfreed), retries the scenario up to 8× (draining size classes), and on a total miss relaunches a fresh process viaexecv()up to 12× (libmalloc magazine/region population is drawn at startup, so a miss is process-wide). Empirically ~10/12 fresh processes hit immediately (attempt 2 of ≤512, scenario try 1 of ≤8).
Local folder note (not part of the report): the folder currently ships the prebuilt binary;
libs/,build-macos/, andcurl-src/were removed locally, so the script's build stage reportsBUILD FAILED: bundled OpenSSL incompleteuntil OpenSSL archives are re-bundled underlibs/lib/. The prebuilt binary runs as-is.
Scope honesty: this PoC deliberately removes the race timing (the free and the reclaim are sequenced in one thread) so the post-free behavior is deterministic and provable. Winning the live interleaving window against a victim thread is a separate, probabilistic step — a companion race PoC (poc_hsts_race.c, two threads hammering the shared cache) SIGSEGVs 20/20 runs (exit 139) on the same build, establishing that the race itself is trivially reachable; poc_uaf_write.c proves everything that follows the free. This report therefore claims: (a) the race exists and crashes (companion PoC), and (b) the freed object is fully attacker-writable and is consumed by real libcurl code into a controlled indirect call (this PoC, deterministic).
Evidence (fresh runs, 2026-10-17 — full transcripts in output/)
Plain writability proof — output/uaf_write_plain_20261017-120659.txt, exit 0
libcurl: libcurl/8.23.1-DEV OpenSSL/3.6.4
[*] worker-thread: live stsentry at 0x78870400c0 (malloc_size=64)
[*] vulnerable free done (hsts.c:285-288)
[*] worker-thread: attacker calloc(56) -> 0x78870400c0, malloc_size=64
[+] worker-thread: RECLAIM PROVEN: calloc(56) returned the exact freed
stsentry address on attempt 2 of <=512, scenario try 1 of <=8
[+] worker-thread: OVERWRITE PROVEN: all 64 user bytes now hold
attacker-chosen content
[B1 fake node staged by attacker]
+0 _list 000000010250edb8 | +8 _ptr cafef00dbeef1234
+16 _prev 000000010250edd8 | +24 _next 000000010250edf8
[+] worker-thread: CONSUMPTION PROVEN: real Curl_node_remove() executed the
attacker-staged node: controlled function pointer called with
controlled argument (fake_dtor(NULL, 0xcafef00dbeef1234)), controlled-pointer
relink writes landed (scr1+24 <- 0x10250edf8, scr2+16 <- 0x10250edd8), fake list
size 5->4, reclaimed chunk fields NULLed
===== VERDICT: UAF WRITE-CONFIRMED =====
Command execution proof — output/uaf_write_cmd_20261017-121020.txt, exit 0
Same chain with the destructor staged as a popen() callback and _ptr pointing at the command string (libcurl's take_elem always calls dtor(NULL, elem), so the command rides the second argument — the node's _ptr):
[*] total miss is process-wide (libmalloc population drawn at startup):
relaunching a fresh process, attempt 9 of 12, via execv(...)
[+] main-thread: RECLAIM PROVEN: calloc(56) returned the exact freed stsentry
address on attempt 2 of <=512, scenario try 1 of <=8
[+] worker-thread: RECLAIM PROVEN: ...
===== VERDICT: UAF WRITE-CONFIRMED =====
Command: id
Command result: uid=501(alicangonullu) gid=20(staff) groups=20(staff),12(everyone),
61(localaccounts),79(_appserverusr),80(admin), ... (exit 0)
Both transcripts show the complete chain on the main thread AND inside a spawned pthread worker with its own HSTS handle — i.e. the exploitation primitives hold in the exact threading context the share interface creates.
Impact
- Arbitrary code execution in any process whose libcurl shares an HSTS cache across threads: the freed
stsentryis reclaimed at the exact address by attacker-driven header content, fully overwritten, and consumed by genuineCurl_node_remove()code into an attacker-chosen function-pointer call with an attacker-chosen argument — demonstrated end-to-end withpopen("id")output captured in the transcript. - Denial of service even without reclaim: the raw race crashes the process (20/20 SIGSEGV in the companion race PoC).
- Reachable by any HTTPS origin the application contacts (attacker-supplied response header); no privileges, no user interaction beyond normal traffic.
Recommended remediation
- Take the HSTS share lock around every mutation/read of a (potentially shared) HSTS cache, not only in
Curl_hsts_loadfiles():Curl_hsts_parse()(creation, update, and themax-age=0deletion path, hsts.c:283-291),hsts_check()and every other cache walk/mutation reached from request handling. Concretely: wrap those bodies inCurl_share_lock(data, CURL_LOCK_DATA_HSTS, LOCK_ACCESS_SINGLE)/Curl_share_unlock(...)when the handle is shared — the existing pattern at hsts.c:625 shows how.
- Until fixed, document in
CURLOPT_SHARE/ the thread-safety section that the HSTS cache is not safe to share across handles used concurrently (this would negate the documented purpose ofCURL_LOCK_DATA_HSTS, so the lock fix is strongly preferred). - Consider a regression test in the curl test suite: two threads hammering a shared HSTS handle with alternating
max-age=0/max-age=Nreproduces the UAF immediately under ASAN.
Supporting material
poc_uaf_write.c— writability + consumption + command-execution PoC (this report's primary evidence; deterministic post-free chain).output/uaf_write_plain_20261017-120659.txt— fresh plain-mode transcript, exit 0.output/uaf_write_cmd_20261017-121020.txt— fresh command-mode transcript, exit 0,idexecuted via the hijacked dtor.- Companion race PoCs (available on request):
poc_hsts_race.c(live two-thread race, 20/20 SIGSEGV),poc_hsts_rce.c. - Vulnerable commit:
2c04412d8c81784b24fafe03e8389c6ce7797eef(8.23.1-DEV).
Reporter notes
- Tested on macOS (Darwin 27, arm64, native) with a static libcurl + OpenSSL 3.6.4 build from the commit above; the PoC source also targets Linux aarch64/glibc, where reclaim determinism is stronger (tcache LIFO, single-shot).
- Happy to provide the companion race PoC, a Linux reproduction bundle, or an ASAN-instrumented reproduction on request.
- Reporter details / credit line to be filled in at submission time.
PoC
Here is PoC (Tested on macOS 26, Macbook Air M4 ARM) {F7013867} {F7014215}