HexBrain

curl · Report #4122039

libcurl shared-HSTS-cache race leads to use-after-free with attacker-controlled function-pointer call

N/Astatus
criticalseverity
8upvotes
19 Oct 2026disclosed

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)

MetricValueRationale
Attack VectorNetworkTriggered by HTTPS responses (HSTS headers) processed by the application
Attack ComplexityHighNeeds a multi-threaded application sharing one HSTS cache plus a race window; the memory-writability stage is deterministic once the free happens
Privileges RequiredNoneAny HTTPS origin the application contacts can carry the header
User InteractionNoneNothing beyond the application's normal traffic
ScopeUnchangedCorruption stays inside the libcurl-using process
C / I / AHigh / High / HighDemonstrated 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:

  1. Exact-address reclaim — the next Curl_hsts_parse() call (attacker-controlled header content) re-carves the just-freed stsentry address via libcurl's own hsts_create() calloc.
  2. Full overwrite — all 56 user bytes of the freed object are filled with attacker-chosen content.
  3. Real-code consumption — the REAL Curl_node_remove() from lib/llist.c — the identical call a stale hsts_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/_next relinks.
  4. 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, _next from 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 _ptr as 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

  1. 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.
  2. 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-Security header is attacker-supplied response content. Both sides of the race are attacker-chosen: max-age=0 (deletion/free) and max-age=<large> (creation/reclaim).
  3. 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):

rcMeaning
0UAF_WRITE_PROVEN (with an argument: + COMMAND_EXECUTED)
1RECLAIM_FAILED (allocator did not return the freed address; on Apple retried across fresh processes)
2HSTS_SETUP_FAILED
3CONSUMPTION_FAILED (or build/link failure)

Allocator behavior by platform:

  • Linux aarch64 / glibc — single-shot deterministic: same-thread free + calloc under the per-thread tcache LIFO contract cannot miss, and calloc bypasses 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 via execv() 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/, and curl-src/ were removed locally, so the script's build stage reports BUILD FAILED: bundled OpenSSL incomplete until OpenSSL archives are re-bundled under libs/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 stsentry is reclaimed at the exact address by attacker-driven header content, fully overwritten, and consumed by genuine Curl_node_remove() code into an attacker-chosen function-pointer call with an attacker-chosen argument — demonstrated end-to-end with popen("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

  1. 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 the max-age=0 deletion path, hsts.c:283-291),
    • hsts_check() and every other cache walk/mutation reached from request handling. Concretely: wrap those bodies in Curl_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.
  2. 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 of CURL_LOCK_DATA_HSTS, so the lock fix is strongly preferred).
  3. Consider a regression test in the curl test suite: two threads hammering a shared HSTS handle with alternating max-age=0 / max-age=N reproduces 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, id executed 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}

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