curl · Report #4122239
CVE-2026-111952: OpenSSL provider use-after-free again
How it was found
Summary
Since 8.23.0, libcurl caches its OpenSSL certificate store per multi handle even when CURLOPT_CAPATH is set. OpenSSL loads CApath certificates lazily during verification, and they belong to the library context of the transfer that loaded them. With CURLOPT_SSLENGINE that is a private OSSL_LIB_CTX, which curl frees in curl_easy_cleanup() while the cached certificates stay in the multi handle. The next verification on that multi handle that looks at one of those certificates as an issuer reads the freed context. A malicious server can arrange this with forged certificates that only name a CA from the victim's CApath as issuer, so no trusted key is needed. In my tests this crashed the client on OpenSSL 3.0.13 and 3.5.5, including the curl tool with --parallel --parallel-max 1 --engine, and on OpenSSL 3.5.5 the server's certificate bytes chose the pointer that libcrypto dereferenced.
Conditions
- libcurl with OpenSSL 3.x, and
CURLOPT_SSLENGINEnaming a provider (tested withdefault) for the transfer that loads the CA. CURLOPT_CAPATHholds a CA that is not also loaded throughCURLOPT_CAINFO, for example a private PKI.- The application cleans up that easy handle and starts another one on the same multi handle. The curl tool does this with
--parallel --parallel-max 1. - Peer verification and the CA cache are on (both default), and the later connection does a full chain verification. A resumed TLS session skips it, so the reproducer uses two origin names.
Root cause
Line numbers refer to lib/vtls/openssl.c in 8.23.0. ossl_set_provider() creates a private OSSL_LIB_CTX (:1786) and the SSL_CTX is created inside it (:3748). Commit 3c60b05098 (PR #23363) made stores with CApath cacheable. X509_STORE_load_path() (:3044) only registers a hash-directory lookup, so OpenSSL reads a CA from the directory during chain building, under the SSL_CTX's library context, and adds it to the cached store. The cache key in ossl_cached_x509_store_different() (:3205) does not cover the library context. curl_easy_cleanup() frees the context through ossl_provider_cleanup() (:1747), while the store and its certificates remain in the multi handle for up to CURLOPT_CA_CACHE_TIMEOUT (24 hours by default). The next transfer attaches the cached store (:3340). When issuer selection reaches one of those certificates, check_sig_alg_match() → EVP_PKEY_is_a() → EVP_KEYMGMT_is_a() → ossl_namemap_stored() reads the freed context.
Affected versions
- curl 8.23.0, and master at
eaa9103225. In master, commitec0a5d1b06(PR #23436) ties the provider context to the connection instead of the easy handle; the cached store still outlives it, and one easy handle withCURLOPT_FORBID_REUSEdoing two transfers is enough. - Releases 8.14.0 to 8.22.0 skip the store cache when CApath is set. Builds with
CURL_CA_FALLBACK, which use OpenSSL's default hashed directory without CApath, were not tested.
Steps to reproduce
- Build curl 8.23.0 against OpenSSL 3.
- Run the attached script:
CURL=/absolute/path/to/curl sh c4repro.sh
The script creates a private PKI with a hashed CApath directory (root and intermediate), forged leaves that name the intermediate as issuer but are signed with an unrelated key, and openssl s_server instances on 127.0.0.1 that send only their leaf. The two attacker cases, shortened:
# [2] forged leaves only, provider on both transfers
curl --parallel --parallel-max 1 --engine default --capath capath \
https://127.0.0.1:8444/ https://localhost:8444/
# [4] provider on the first transfer only; the second leaf carries an
# unknown extension (OID 1.2.3.4) with 327 bytes of 0x41
curl --parallel --parallel-max 1 --engine default --capath capath https://localhost:8444/ \
--next --capath capath https://127.0.0.1:8445/
Tested builds, all x86-64 with glibc: the 8.23.0 release tarball against Ubuntu 24.04's OpenSSL 3.0.13 (3.0.13-0ubuntu3.16) and against OpenSSL 3.5.5 built from the upstream openssl-3.5.5 tag; the curl-8_23_0 tag (commit 3163ec0656) against Ubuntu's OpenSSL 3.0.13 and 3.5.5 packages.
| Case | OpenSSL 3.0.13 | OpenSSL 3.5.5 |
|---|---|---|
| control: no provider, legitimate server | 200, 200 | 200, 200 |
| [1] provider on both, legitimate server | SIGSEGV | 200, 200 |
| [2] provider on both, forged leaves | SIGSEGV | 60, 60 |
| [3] no provider, forged leaves | 60, 60 | 60, 60 |
| [4] provider on the first only, padded forged leaf | SIGSEGV or 60, 60 depending on heap layout | SIGSEGV, 5 of 5 runs |
| [5] like [2], forged leaf without authority key identifier | SIGSEGV | 60, 60 |
Whether a run crashes natively depends on what occupies the freed block when it is read. Under valgrind every case with a provider reports the invalid read, on both OpenSSL versions. Case [5] shows that the forged leaf needs only the CA's subject as its issuer name.
valgrind, release tarball with OpenSSL 3.5.5, case [1] (excerpt):
Invalid read of size 8
at ossl_lib_ctx_get_data (context.c:581)
by ossl_namemap_stored (core_namemap.c:485)
by evp_is_a (evp_fetch.c:665)
by EVP_KEYMGMT_is_a (keymgmt_meth.c:338)
by check_sig_alg_match (v3_purp.c:419)
by check_issued (x509_vfy.c:473)
by X509_STORE_CTX_get1_issuer (x509_vfy.c:448)
...
by ossl_connect_step2 (openssl.c:4166)
Address 0x53407f8 is 184 bytes inside a block of size 328 free'd
by ossl_provider_cleanup (openssl.c:1747)
by Curl_close (url.c:244)
by curl_easy_cleanup (easy.c:891)
Block was alloc'd at
by OSSL_LIB_CTX_new (context.c:446)
by ossl_set_provider (openssl.c:1786)
by curl_easy_setopt (setopt.c:2943)
In case [4] on OpenSSL 3.5.5, the 327-byte extension, which ASN1_STRING_set() stores in a 328-byte allocation, takes over the freed context, and offset 184 holds its name-map pointer. gdb at the crash:
rdi 0x4141414141414141
=> <ossl_namemap_empty+14>: mov 0x20(%rdi),%eax
#0 ossl_namemap_empty (namemap=0x4141414141414141) at crypto/core_namemap.c:97
#1 ossl_namemap_stored (libctx=<optimized out>) at crypto/core_namemap.c:491
#2 evp_is_a (..., name="rsaEncryption") at crypto/evp/evp_fetch.c:665
With 0x42 bytes in the extension the same run faults with namemap=0x4242424242424242. The attached c4test.c runs the same sequence through the libcurl multi API (two mode; a final - argument disables the provider for the second handle).
Suggested fix
Bypass the shared X509 store cache, both lookup and insertion, while a transfer uses a private provider library context. Removing the cache entry when the context is freed is not enough, because connections that already attached the store keep referencing its certificates, and comparing provider names or context addresses does not guarantee the lifetime either. See attached patch-files for master and 8.23.0.
AI
AI tools were used for this report. An LLM-based code review pointed at the code, and Claude and Codex helped with the analysis, the reproducers and the wording. All results above come from local runs of the attached reproducers; no curl infrastructure was contacted.
Impact
Any server the application connects to, or anyone who can intercept one of its TLS connections, can trigger the use-after-free under the conditions above; the table shows the resulting crashes. The forged certificate needs no trusted signature and no authority key identifier, only the subject name of a CA in the victim's CApath, which appears as the issuer name in every certificate that CA has issued. On OpenSSL 3.5.5 the server also picks the address that libcrypto reads from during verification. Disclosure of the read value, a write or code execution are not demonstrated. The extension size that reclaims the freed block depends on the OpenSSL version and the allocator.