HexBrain

curl · Report #4116527

CVE-2026-111878: OpenSSL provider UAF on TLS shutdown

Validstatus
lowseverity
2upvotes
26 Oct 2026disclosed

How it was found

Vulnerability Details

This is an incomplete fix of the earlier connclose() mitigation (CVE-2026-80229). That change stops a connection that borrowed a private OSSL_LIB_CTX from going back into the connection pool. It does not keep the library context alive until the SSL_CTX that borrowed it is destroyed. On the multi interface the connection can sit in multi->cshutdn after the easy handle that owns the context has been freed.

Affected tree: curl master commit 03f3f4c6d97be3c2c9ad42010f65e61bf0a2c05c (LIBCURL_VERSION 8.23.1-DEV). Requires a build against OpenSSL 3 (OPENSSL_HAS_PROVIDERS). curl_easy_perform() is not affected. A normal engine id that ENGINE_by_id() accepts is not affected.

Conditions

  • CURLOPT_SSLENGINE is set to a name that is not an OpenSSL ENGINE id ("default", a provider name, or a provider:propq string). ossl_set_engine() misses the ENGINE and falls through to ossl_set_provider().
  • The transfer uses the multi interface (curl_multi_*, or curl --parallel).
  • The TLS peer does not finish close_notify inside the single non-blocking shutdown attempt done when the transfer completes. That is the normal case for an HTTP/1.1 server that was keeping the connection open. The connection then stays in cshutdn for up to DEFAULT_SHUTDOWN_TIMEOUT_MS (2 seconds), or until curl_multi_cleanup().

What the code does

  1. The provider path allocates a library context and stores it only on the easy handle. There is no OSSL_LIB_CTX_up_ref anywhere in the tree. OpenSSL 3.0 has no such function, and OSSL_LIB_CTX_free() in 3.0.22 always destroys the object.
/* lib/vtls/openssl.c, ossl_set_provider() */
if(!data->state.libctx) {
  OSSL_LIB_CTX *libctx = OSSL_LIB_CTX_new();
  if(!libctx)
    return CURLE_OUT_OF_MEMORY;
  /* ... optional propq ... */
  data->state.libctx = libctx;
}
  1. The handshake builds the SSL_CTX with that pointer. OpenSSL 3.0.22 SSL_CTX_new_ex() only does ret->libctx = libctx. It does not take a reference. It also EVP_MD_fetch()es digests from that context. The only curl-side mitigation is connclose(), which sets conn->bits.close so the connection will not be reused.
/* lib/vtls/openssl.c, Curl_ossl_ctx_init() */
octx->ssl_ctx =
#ifdef OPENSSL_HAS_PROVIDERS
  data->state.libctx ?
  SSL_CTX_new_ex(data->state.libctx, data->state.propq, req_method):
#endif
  SSL_CTX_new(req_method);

#ifdef OPENSSL_HAS_PROVIDERS
if(data->state.libctx)
  /* forbid connection reuse with provider/engine use */
  connclose(data->conn);
#endif
  1. On a successful (not premature) completion, multi_done_locked() sees bits.close and returns CPOOL_DO_CLOSE. cpool_discard_conn() tries shutdown once. ossl_shutdown() starts with SSL_read(). If the peer has not sent close_notify, that returns SSL_ERROR_WANT_READ, done stays false, and the connection is queued on multi->cshutdn. The easy handle is detached. Later shutdown runs on multi->admin, which does not own this libctx. The pointer lives inside the SSL_CTX.
/* lib/conncache.c, cpool_discard_conn() */
if(!done) {
  Curl_cshutdn_try_once(admin, conn, &done);
}
if(done || !data->multi)
  Curl_cshutdn_terminate(admin, conn, FALSE);
else
  Curl_cshutdn_add(&multi->cshutdn, multi, conn, max_shutdowns);
  1. The usual application sequence after CURLMSG_DONE is curl_multi_remove_handle() plus curl_easy_cleanup(). The connection is already detached, so remove does not destroy it. Curl_close() then calls Curl_ssl_close_all() → ossl_close_all() → ossl_provider_cleanup(), which unloads the providers and frees the library context while cshutdn still holds the SSL / SSL_CTX.
/* lib/url.c, Curl_close() */
if(!data->state.internal && data->multi)
  Curl_multi_remove_handle(data->multi, data);
/* ... */
Curl_ssl_close_all(data);   /* ossl_provider_cleanup() frees libctx */
/* lib/vtls/openssl.c, ossl_provider_cleanup() */
if(data->state.baseprov) {
  OSSL_PROVIDER_unload(data->state.baseprov);
  data->state.baseprov = NULL;
}
if(data->state.provider) {
  OSSL_PROVIDER_unload(data->state.provider);
  data->state.provider = NULL;
}
OSSL_LIB_CTX_free(data->state.libctx);
data->state.libctx = NULL;
  1. The next curl_multi_perform(), or curl_multi_cleanup(), drives cshutdn. ossl_shutdown() calls SSL_read() / SSL_shutdown() on that SSL, then ossl_close() calls SSL_free() and SSL_CTX_free(). Those calls use the libctx stored in the SSL_CTX. SSL_read() reaches EVP_MD_fetch() on it.
/* lib/vtls/openssl.c, ossl_shutdown() */
if(!(SSL_get_shutdown(octx->ssl) & SSL_SENT_SHUTDOWN)) {
  ERR_clear_error();
  for(i = 0; i < 10; ++i) {
    nread = SSL_read(octx->ssl, buf, (int)sizeof(buf));
    if(nread <= 0)
      break;
  }
}
/* lib/vtls/openssl.c, ossl_close() */
if(octx->ssl) {
  SSL_free(octx->ssl);
  octx->ssl = NULL;
}
if(octx->ssl_ctx) {
  SSL_CTX_free(octx->ssl_ctx);
  octx->ssl_ctx = NULL;
}

curl_easy_perform() does not hit this. Curl_close() runs curl_multi_cleanup(data->multi_easy) first, which drains cshutdn and destroys the SSL_CTX while the easy handle, and therefore the libctx, is still alive. A premature curl_multi_remove_handle() (mstate < MSTATE_COMPLETED) uses CPOOL_DO_TERMINATE and destroys the connection inside remove, still before ossl_provider_cleanup().

Confirmation

On OpenSSL 3.0.22, the same order as ossl_provider_cleanup() followed by ossl_shutdown() was executed after a completed TLS 1.3 handshake (SSL_CTX_new_ex on a private libctx, unload default and base, OSSL_LIB_CTX_free, then SSL_read):

  • With the freed block left untouched, SSL_read returned. The allocation is small and is not unmapped, so the use-after-free is often silent.
  • With free() quarantining the block (contents poisoned, pointer not reused), SSL_read segfaulted in CRYPTO_THREAD_read_lock, reached from EVP_MD_fetch. That is the first call ossl_shutdown() makes.

Suggested fix

connclose() is not sufficient. OSSL_LIB_CTX_up_ref() is also not a portable fix: it does not exist in OpenSSL 3.0/3.1, and OSSL_LIB_CTX_free() there ignores any count and always destroys the context.

Keep a curl-owned reference on the context for as long as any SSL_CTX created from it exists. curl_easy_cleanup() drops only the easy handle's reference. ossl_close() drops the connection's reference after SSL_CTX_free(). The real OSSL_PROVIDER_unload / OSSL_LIB_CTX_free runs when the last reference goes away, including when that happens later on cshutdn. The same release is required on the QUIC cleanup path, which frees SSL_CTX without calling ossl_close().

The two drops can happen on different threads (curl_easy_cleanup after curl_multi_remove_handle, versus curl_multi_perform on the multi handle), so the counter has to be atomic.

diff --git a/lib/urldata.h b/lib/urldata.h
--- a/lib/urldata.h
+++ b/lib/urldata.h
@@
   void *provider;
   void *baseprov;
   void *libctx;
+  void *libctx_anchor; /* ossl_libctx_anchor; outlives this easy handle */
   char *propq; /* for a provider */
 #endif /* USE_OPENSSL */

diff --git a/lib/vtls/openssl.h b/lib/vtls/openssl.h
--- a/lib/vtls/openssl.h
+++ b/lib/vtls/openssl.h
@@
 struct Curl_ssl_session;
+struct ossl_libctx_anchor;
+
+/* Drop one reference. Frees the OSSL_LIB_CTX only at zero, and only
+ * after every SSL_CTX that borrowed it has been SSL_CTX_free()'d. */
+void Curl_ossl_libctx_anchor_release(struct ossl_libctx_anchor *anchor);

 struct ossl_ctx {
   SSL_CTX *ssl_ctx;
   SSL *ssl;
   BIO_METHOD *bio_method;
+  struct ossl_libctx_anchor *libctx_anchor;
   CURLcode io_result;
   /* ... */
 };

diff --git a/lib/vtls/openssl.c b/lib/vtls/openssl.c
--- a/lib/vtls/openssl.c
+++ b/lib/vtls/openssl.c
@@
 #ifdef OPENSSL_HAS_PROVIDERS
+struct ossl_libctx_anchor {
+  OSSL_LIB_CTX *libctx;
+  OSSL_PROVIDER *provider;
+  OSSL_PROVIDER *baseprov;
+  char *propq;
+  /* easy handle holds one ref; each SSL_CTX created from it holds one more */
+  unsigned int refcount;
+};
+
+void Curl_ossl_libctx_anchor_release(struct ossl_libctx_anchor *anchor)
+{
+  if(!anchor)
+    return;
+  DEBUGASSERT(anchor->refcount);
+  /* atomic_fetch_sub: easy cleanup and cshutdn may run on different threads */
+  if(--anchor->refcount)
+    return;
+  if(anchor->baseprov)
+    OSSL_PROVIDER_unload(anchor->baseprov);
+  if(anchor->provider)
+    OSSL_PROVIDER_unload(anchor->provider);
+  OSSL_LIB_CTX_free(anchor->libctx);
+  curlx_free(anchor->propq);
+  curlx_free(anchor);
+}
+
+static struct ossl_libctx_anchor *ossl_libctx_anchor_new(void)
+{
+  struct ossl_libctx_anchor *anchor = curlx_calloc(1, sizeof(*anchor));
+  if(!anchor)
+    return NULL;
+  anchor->libctx = OSSL_LIB_CTX_new();
+  if(!anchor->libctx) {
+    curlx_free(anchor);
+    return NULL;
+  }
+  anchor->refcount = 1; /* the easy handle */
+  return anchor;
+}

 static void ossl_provider_cleanup(struct Curl_easy *data)
 {
-  if(data->state.baseprov) {
-    OSSL_PROVIDER_unload(data->state.baseprov);
-    data->state.baseprov = NULL;
-  }
-  if(data->state.provider) {
-    OSSL_PROVIDER_unload(data->state.provider);
-    data->state.provider = NULL;
-  }
-  OSSL_LIB_CTX_free(data->state.libctx);
-  data->state.libctx = NULL;
-  curlx_safefree(data->state.propq);
+  struct ossl_libctx_anchor *anchor = data->state.libctx_anchor;
+  /* Drop the easy handle's ref only. A SSL_CTX on cshutdn may still
+   * hold another ref; that ref is dropped from ossl_close() after
+   * SSL_CTX_free(). Do not unload providers or free libctx here. */
+  data->state.libctx_anchor = NULL;
+  data->state.baseprov = NULL;
+  data->state.provider = NULL;
+  data->state.libctx = NULL;
+  data->state.propq = NULL; /* owned by the anchor */
   data->state.provider_loaded = FALSE;
+  Curl_ossl_libctx_anchor_release(anchor);
 }
@@
-  if(!data->state.libctx) {
-    OSSL_LIB_CTX *libctx = OSSL_LIB_CTX_new();
-    if(!libctx)
-      return CURLE_OUT_OF_MEMORY;
-    if(propq) {
-      data->state.propq = curlx_strdup(propq);
-      if(!data->state.propq) {
-        OSSL_LIB_CTX_free(libctx);
-        return CURLE_OUT_OF_MEMORY;
-      }
-    }
-    data->state.libctx = libctx;
+  if(!data->state.libctx_anchor) {
+    struct ossl_libctx_anchor *anchor = ossl_libctx_anchor_new();
+    if(!anchor)
+      return CURLE_OUT_OF_MEMORY;
+    if(propq) {
+      anchor->propq = curlx_strdup(propq);
+      if(!anchor->propq) {
+        Curl_ossl_libctx_anchor_release(anchor);
+        return CURLE_OUT_OF_MEMORY;
+      }
+    }
+    data->state.libctx_anchor = anchor;
+    data->state.libctx = anchor->libctx;
+    data->state.propq = anchor->propq;
   }
@@
-  data->state.provider = OSSL_PROVIDER_try_load(data->state.libctx, name, 1);
+  data->state.provider = OSSL_PROVIDER_try_load(data->state.libctx, name, 1);
+  ((struct ossl_libctx_anchor *)data->state.libctx_anchor)->provider =
+    data->state.provider;
@@
-  data->state.baseprov = OSSL_PROVIDER_try_load(data->state.libctx, "base", 1);
+  data->state.baseprov = OSSL_PROVIDER_try_load(data->state.libctx, "base", 1);
+  ((struct ossl_libctx_anchor *)data->state.libctx_anchor)->baseprov =
+    data->state.baseprov;
@@
 static void ossl_close(struct Curl_cfilter *cf, struct Curl_easy *data)
 {
@@
   if(octx->ssl_ctx) {
     SSL_CTX_free(octx->ssl_ctx);
     octx->ssl_ctx = NULL;
     octx->x509_store_setup = FALSE;
   }
+  /* libctx must outlive SSL_CTX_free(); release only after it */
+  Curl_ossl_libctx_anchor_release(octx->libctx_anchor);
+  octx->libctx_anchor = NULL;
   if(octx->bio_method) {
@@
   octx->ssl_ctx =
 #ifdef OPENSSL_HAS_PROVIDERS
     data->state.libctx ?
     SSL_CTX_new_ex(data->state.libctx, data->state.propq, req_method):
 #endif
     SSL_CTX_new(req_method);
@@
 #ifdef OPENSSL_HAS_PROVIDERS
   if(data->state.libctx) {
+    struct ossl_libctx_anchor *anchor = data->state.libctx_anchor;
+    /* This SSL_CTX borrowed anchor->libctx without an OpenSSL ref.
+     * Hold one curl ref until ossl_close() has destroyed the SSL_CTX.
+     * connclose() only blocks pool reuse; cshutdn can outlive *data. */
+    DEBUGASSERT(anchor);
+    anchor->refcount++;
+    octx->libctx_anchor = anchor;
     connclose(data->conn);
+  }
 #endif

diff --git a/lib/vquic/vquic-tls.c b/lib/vquic/vquic-tls.c
--- a/lib/vquic/vquic-tls.c
+++ b/lib/vquic/vquic-tls.c
@@
   if(ctx->ossl.ssl)
     SSL_free(ctx->ossl.ssl);
   if(ctx->ossl.ssl_ctx)
     SSL_CTX_free(ctx->ossl.ssl_ctx);
+  Curl_ossl_libctx_anchor_release(ctx->ossl.libctx_anchor);
+  ctx->ossl.libctx_anchor = NULL;
 #endif

anchor->refcount++ and the matching decrement need to be atomic (atomic_fetch_add / atomic_fetch_sub, or the equivalent already used in-tree). If SSL_CTX_new_ex() fails, do not take the extra reference. Any error path that destroys an ossl_ctx without ossl_close() has to release libctx_anchor the same way Curl_vquic_tls_cleanup() does, and only after SSL_CTX_free().

Reference

Tree: https://github.com/curl/curl/commit/03f3f4c6d97be3c2c9ad42010f65e61bf0a2c05c

LocationWhat
lib/vtls/openssl.c L1653–L1683Non-ENGINE name falls through to ossl_set_provider()
lib/vtls/openssl.c L1785–L1796OSSL_LIB_CTX_new() stored on the easy handle, no extra reference
lib/vtls/openssl.c L3745–L3760SSL_CTX_new_ex(data->state.libctx) then connclose() only
lib/multi.c L651–L659Clean completion with bits.close returns CPOOL_DO_CLOSE (not immediate terminate)
lib/conncache.c L221–L242Failed one-shot shutdown queues the connection on multi->cshutdn
lib/cshutdn.c L41Deferred shutdown lives for 2 seconds by default
lib/cshutdn.c L349–L354Later cshutdn_perform() runs shutdown on multi->admin and then destroys the SSL
lib/vtls/openssl.c L1854–L1863ossl_shutdown() calls SSL_read() on the borrowed context
lib/url.c L207–L244Multi handle: remove easy, then Curl_ssl_close_all(). multi_easy is drained first, which is why the easy interface is safe
lib/vtls/openssl.c L1737–L1750ossl_provider_cleanup() frees libctx from ossl_close_all()
lib/vtls/openssl.c L1956–L1997SSL_free / SSL_CTX_free happen in ossl_close(), which cshutdn can call after that free

OpenSSL side of the borrow, for the library this build uses (3.0.22): SSL_CTX_new_ex() assigns ret->libctx = libctx and then ssl_evp_md_fetch(libctx, ...) (ssl/ssl_lib.c). OSSL_LIB_CTX_free() deinits and OPENSSL_free()s the context with no reference count (crypto/context.c).

Impact

An application that sets CURLOPT_SSLENGINE to an OpenSSL 3 provider (anything ENGINE_by_id() does not accept) and uses the multi interface in the normal way — wait for CURLMSG_DONE, remove the easy handle, curl_easy_cleanup(), keep using the multi handle — frees an OSSL_LIB_CTX that a live SSL_CTX on multi->cshutdn still borrows. The next multi perform or multi cleanup then calls SSL_read / SSL_shutdown / SSL_free on that freed context (CWE-416). A normal server that does not answer close_notify in the same non-blocking turn is enough to leave the connection queued; the default grace period is 2 seconds. The bug is not on by default and a remote peer cannot create the libctx by itself, but once the application has opted in, the use-after-free is the common shutdown path, not a rare race, and it corrupts memory inside the process that linked libcurl.

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