curl · Report #4116527
CVE-2026-111878: OpenSSL provider UAF on TLS shutdown
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_SSLENGINEis set to a name that is not an OpenSSL ENGINE id ("default", a provider name, or aprovider:propqstring).ossl_set_engine()misses the ENGINE and falls through toossl_set_provider().- The transfer uses the multi interface (
curl_multi_*, orcurl --parallel). - The TLS peer does not finish
close_notifyinside 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 incshutdnfor up toDEFAULT_SHUTDOWN_TIMEOUT_MS(2 seconds), or untilcurl_multi_cleanup().
What the code does
- The provider path allocates a library context and stores it only on the easy handle. There is no
OSSL_LIB_CTX_up_refanywhere in the tree. OpenSSL 3.0 has no such function, andOSSL_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;
}
- The handshake builds the
SSL_CTXwith that pointer. OpenSSL 3.0.22SSL_CTX_new_ex()only doesret->libctx = libctx. It does not take a reference. It alsoEVP_MD_fetch()es digests from that context. The only curl-side mitigation isconnclose(), which setsconn->bits.closeso 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
- On a successful (not premature) completion,
multi_done_locked()seesbits.closeand returnsCPOOL_DO_CLOSE.cpool_discard_conn()tries shutdown once.ossl_shutdown()starts withSSL_read(). If the peer has not sentclose_notify, that returnsSSL_ERROR_WANT_READ,donestays false, and the connection is queued onmulti->cshutdn. The easy handle is detached. Later shutdown runs onmulti->admin, which does not own thislibctx. The pointer lives inside theSSL_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);
- The usual application sequence after
CURLMSG_DONEiscurl_multi_remove_handle()pluscurl_easy_cleanup(). The connection is already detached, so remove does not destroy it.Curl_close()then callsCurl_ssl_close_all()→ossl_close_all()→ossl_provider_cleanup(), which unloads the providers and frees the library context whilecshutdnstill holds theSSL/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;
- The next
curl_multi_perform(), orcurl_multi_cleanup(), drivescshutdn.ossl_shutdown()callsSSL_read()/SSL_shutdown()on thatSSL, thenossl_close()callsSSL_free()andSSL_CTX_free(). Those calls use thelibctxstored in theSSL_CTX.SSL_read()reachesEVP_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_readreturned. 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_readsegfaulted inCRYPTO_THREAD_read_lock, reached fromEVP_MD_fetch. That is the first callossl_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
| Location | What |
|---|---|
lib/vtls/openssl.c L1653–L1683 | Non-ENGINE name falls through to ossl_set_provider() |
lib/vtls/openssl.c L1785–L1796 | OSSL_LIB_CTX_new() stored on the easy handle, no extra reference |
lib/vtls/openssl.c L3745–L3760 | SSL_CTX_new_ex(data->state.libctx) then connclose() only |
lib/multi.c L651–L659 | Clean completion with bits.close returns CPOOL_DO_CLOSE (not immediate terminate) |
lib/conncache.c L221–L242 | Failed one-shot shutdown queues the connection on multi->cshutdn |
lib/cshutdn.c L41 | Deferred shutdown lives for 2 seconds by default |
lib/cshutdn.c L349–L354 | Later cshutdn_perform() runs shutdown on multi->admin and then destroys the SSL |
lib/vtls/openssl.c L1854–L1863 | ossl_shutdown() calls SSL_read() on the borrowed context |
lib/url.c L207–L244 | Multi 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–L1750 | ossl_provider_cleanup() frees libctx from ossl_close_all() |
lib/vtls/openssl.c L1956–L1997 | SSL_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.