curl · Report #4129638
TLS session cache ignores CURLOPT_SSL_CTX_FUNCTION identity and resumes one client's session for another
How it was found
Researcher: Maturi Jaswanth Sai
Program: curl (HackerOne)
Asset: github.com/curl/curl, lib/vtls/vtls_scache.c
Affected: curl 8.21.0, 8.22.0 and 8.23.0 (tag curl-8_23_0,
2026-10-14), plus master eb2b201a (8.23.1-DEV, 2026-10-19)
Weakness: CWE-295, wrong client identity on a resumed TLS session. Same
class as CVE-2016-5419 and CVE-2017-7468
Before we went into report to be clear I have created separate file for reproduce steps to validate manually for the team so check that.
What happens
CURLOPT_SSL_CTX_FUNCTION lets an application configure the TLS context,
including loading a client certificate. Two easy handles that pick different
client certificates only through this callback, without setting
CURLOPT_SSLCERT or CURLOPT_SSLKEY, end up with the same TLS session-cache
peer key, because the key is built from struct ssl_filter_config fields and
never looks at the callback or its userdata.
The second handle opens its own TCP connection and its callback runs and loads its own certificate, but curl offers the first handle's cached TLS session. The server accepts the resumption, never asks for the second certificate, and continues as the first identity. Both requests return the first client's data and no error is reported.
Root cause
cf_ssl_peer_key_add_mtls() (8.23.0, lib/vtls/vtls_scache.c:126) adds the
client certificate material that comes from the normal options:
if(ssl->clientcert && ssl->clientcert[0]) {
result = cf_ssl_peer_key_add_path(buf, "CCERT", ssl->clientcert, is_local);
...
if(ssl->key && ssl->key[0]) {
result = cf_ssl_peer_key_add_path(buf, "KEY", ssl->key, is_local);
...
if(ssl->key_blob) {
result = cf_ssl_peer_key_add_hash(buf, "KEYBlob", ssl->key_blob);
ssl_peer_key_build() calls it at line 320. There is no entry for
data->set.ssl_fsslctx or data->set.ssl_fsslctxp, and no code that disables
the session cache when the callback is set.
The connection-reuse matcher (match_ssl_primary_config() in
lib/vtls/vtls_config.c) is fine and is not what causes this: the two requests
use different connections (ids 0 and 1). The confusion happens at the TLS
session layer.
Reproduce
One file, one command:
./poc_sslctx.py
It runs the bug case, the failed-workaround case and the control by itself.
The script generates two client certificates (alice, bob) and a server certificate, builds its C client against the installed libcurl, starts an mTLS server, and runs three cases. Server connections, callbacks and responses are printed while they happen:
default— bug: both connections showalice-clientforbid-reuse— callback setsCURLOPT_FORBID_REUSE, stillalice-clientno-session-cache—CURLOPT_SSL_SESSIONID_CACHE=0, server showsbob-client
To use a libcurl built from the curl-8_23_0 tag instead of the system
library (full commands in REPRODUCE.md):
CURL_CFLAGS="-I$PWD/curl/include" \
CURL_LIBS="$PWD/curl/build/lib/libcurl.a -lssl -lcrypto -lpsl \
-lbrotlidec -lbrotlicommon -lz -lzstd -lpthread -ldl" \
./poc_sslctx.py
Full walkthrough, including building libcurl from source: [REPRODUCE.md].
Evidence
Server log, default case (8.23.0):
CONN#1 from ('127.0.0.1', 42158): client_cert_CN=alice-client first_line=b'GET / HTTP/1.1'
CONN#2 from ('127.0.0.1', 42164): client_cert_CN=alice-client first_line=b'GET / HTTP/1.1'
Client output from the same run:
[callback] loading alice.crt into SSL_CTX
connection id: 0
=== request 2: bob's SSL_CTX_FUNCTION ===
[callback] loading bob.crt into SSL_CTX
connection id: 1
resp1: seen-cert=alice-client
resp2: seen-cert=alice-client
connections: 0 then 1 (separate connections)
identity the server actually saw: alice-client
RESULT: BUG (bob authenticated as alice)
Control case with the session cache disabled:
CONN#2 from ('127.0.0.1', 54416): client_cert_CN=bob-client
resp2: seen-cert=bob-client
RESULT: OK (bob authenticated as bob)
The default case reproduced on every run with 8.23.0 and master, and also reproduced with 8.21.0 and 8.22.0. The control always flipped to bob.
Screenshot of the full run, all three cases in one capture:
The documented workaround does not work
docs/libcurl/opts/CURLOPT_SSL_CTX_FUNCTION.md says:
The CURLOPT_SSL_CTX_FUNCTION callback allows the application to reach in and modify SSL details in the connection without libcurl itself knowing anything about it, which then subsequently can lead to libcurl unknowingly reusing SSL connections with different properties. To remedy this you may set CURLOPT_FORBID_REUSE from the callback function.
Setting CURLOPT_FORBID_REUSE inside both callbacks gives two separate
connections but the same result: the second connection resumes the first
session and the server still sees alice-client. The workaround covers
connection reuse, not session resumption. Only
CURLOPT_SSL_SESSIONID_CACHE=0 changes the outcome.
What I checked
match_ssl_primary_config()inlib/vtls/vtls_config.cis complete forssl_primary_config; this is not a connection-reuse bug.fsslctxhas 20 references inlib/: setopt storage, the OpenSSL, wolfSSL and mbedTLS backends that invoke the callback, DoH propagation and the callback bookkeeping inapi.c.grep -rn fsslctx lib/vtls/vtls_scache.creturns nothing, so the session cache cannot see the callback at all.docs/libcurl/opts/CURLOPT_SSL_SESSIONID_CACHE.mdsays "nothing ever should get hurt by attempting to reuse SSL session-IDs". This is a counterexample.- Searched curl issues and PRs for
SSL_CTX_FUNCTION session,session resumption client certandfsslctx; no existing report matches.
Suggested fix
Only reuse a cached session when the callback identity matches. The pointer values are enough:
- add
ssl_fsslctxandssl_fsslctxpto the peer key inssl_peer_key_build(), or - do not cache or resume sessions for handles where
ssl_fsslctxis set.
A test can use two callbacks that load different client certs and assert the second connection is authenticated as the second identity.
Impact
An application that uses CURLOPT_SSL_CTX_FUNCTION to select a client
certificate per request against the same host, for example a gateway or proxy
that authenticates to one backend as different tenants, can have one tenant's
request authenticated as another tenant. The correct callback runs and returns
success, the server accepts a valid resumption, and nothing reports an error.
Preconditions are specific: the client certificate must be set only through the callback, and both handles must share a multi handle so they share the session cache. In exchange the impact is a server-side identity mix-up, which is why I rate this Medium. The project has fixed this class twice before (CVE-2016-5419, CVE-2017-7468) for the normal certificate options.