curl · Report #3993870
CVE-2026-86415: auto client cert connection reuse mixup
How it was found
Howdy,
libcurl's connection-cache matcher never checks auto_client_cert, so a cached, already-authenticated Schannel connection can be handed to a transfer that just got redirected to a different origin, even though the code explicitly computed auto_client_cert = FALSE for that transfer to prevent exactly this.
The bug
Curl_ssl_easy_config_complete() recomputes the effective SSL config for every request, including a request that is the result of following a Location: redirect. Part of that is deciding whether the automatically-selected client certificate applies:
// lib/vtls/vtls_config.c:236-238
sslc->auto_client_cert = Curl_peer_equal(origin, initial_origin) &&
!!(options & CURLSSLOPT_AUTO_CLIENT_CERT);
initial_origin is the origin of the very first URL of the curl_easy_perform(), set once and kept for the whole redirect chain. So the moment a redirect takes the transfer to a different host, auto_client_cert is correctly forced to FALSE — the client shouldn't auto-present a certificate to a host it wasn't explicitly pointed at.
The problem is that this flag lives in struct ssl_config_data, not in struct ssl_primary_config:
// lib/vtls/vtls_config.h:74-75
BIT(auto_client_cert); /* automatically locate and use a client
certificate for authentication (Schannel) */
and connections in the pool only ever retain the primary struct:
// lib/urldata.h:322
struct ssl_primary_config ssl_config;
Connection reuse is decided by match_ssl_primary_config(), which takes two ssl_primary_config * and therefore has no way to see auto_client_cert even in principle:
// lib/vtls/vtls_config.c:136-146
static bool match_ssl_primary_config(struct Curl_easy *data,
struct ssl_primary_config *c1,
struct ssl_primary_config *c2)
{
(void)data;
if((c1->version == c2->version) &&
(c1->version_max == c2->version_max) &&
(c1->ssl_options == c2->ssl_options) &&
(c1->native_ca_store == c2->native_ca_store) &&
(c1->verifypeer == c2->verifypeer) &&
(c1->verifyhost == c2->verifyhost) &&
ssl_options (the raw CURLOPT_SSL_OPTIONS bitmask, including the CURLSSLOPT_AUTO_CLIENT_CERT bit) is identical on every request from the same easy handle, so it always matches. The derived, per-request auto_client_cert boolean that actually encodes "is this okay for the current origin" is simply never consulted during cache lookup.
So: if the connection pool already holds a live connection to host B that was established directly (B == that transfer's initial origin, auto_client_cert was TRUE, client cert was presented at handshake time), and a later request to attacker-controlled host A gets redirected to B, the redirected request reuses that cached, already-authenticated connection instead of opening a fresh one. No new handshake happens, so the auto_client_cert = FALSE just computed for the redirected request never gets a chance to matter — the request rides straight over on B's existing authenticated session, on a path chosen by A's Location: header:
// lib/http.c:3414-3439 (http_header_l)
if(HD_IS(hd, hdlen, "Location:")) {
...
data->req.location = location;
if((k->httpcode >= 300 && k->httpcode < 400) &&
data->set.http_follow_mode) {
...
data->req.newurl = curlx_strdup(data->req.location); /* clone */
This is Schannel-specific: auto_client_cert is read in exactly one place outside vtls_config.c:
// lib/vtls/schannel.c:783-786
if(!ssl_config->auto_client_cert) {
flags &= ~(DWORD)SCH_CRED_USE_DEFAULT_CREDS;
flags |= SCH_CRED_NO_DEFAULT_CREDS;
infof(data, "schannel: disabled automatic use of client certificate");
(also used to set ISC_REQ_USE_SUPPLIED_CREDS at lib/vtls/schannel.c:981). No other TLS backend reads the field, so on OpenSSL/GnuTLS/etc. CURLSSLOPT_AUTO_CLIENT_CERT has no effect at all and this bug is inert there.
Where it came from
Introduced by d69bfad3fa3daf5e72331f6870667607828d5891 — "ssl native_ca_store: always reinit" (2026-06-08). Before this commit, auto_client_cert was set once, unconditionally, from the raw CURLOPT_SSL_OPTIONS bit (in setopt.c) — there was no origin-scoping at all. This commit added the ssl_easy_config_compl_options() helper and, with it, the Curl_peer_equal(origin, initial_origin) gate that first made auto_client_cert redirect-aware — but never touched match_ssl_primary_config(), so the new protection was incomplete from the moment it was introduced. Still present at current main (38703fe2, 2026-09-02).
Suggested direction
Either fold auto_client_cert into ssl_primary_config so match_ssl_primary_config() can compare it directly, or have Curl_ssl_conn_config_match() reject the match when the needle's auto_client_cert is FALSE but the candidate connection was established with it TRUE.
Cheers, Josh
Impact
A remote server (A) that a libcurl-based client follows redirects from can cause an existing, auto-authenticated (client-certificate) Schannel connection to a second host (B) to be reused for a request whose path/target A controls, provided the client already holds a live connection to B that it opened directly (not via redirect) with CURLSSLOPT_AUTO_CLIENT_CERT set. The client certificate is never sent to A — it stays with B, which already had it — but A gets to steer an authenticated request against B's endpoints without B (or the application) ever intending that redirect chain to be authenticated. This is exactly the scenario the auto_client_cert/initial_origin check was written to prevent.
Preconditions: Windows/Schannel backend, CURLSSLOPT_AUTO_CLIENT_CERT set via CURLOPT_SSL_OPTIONS, redirects followed, and a connection pool that still holds a live, directly-established connection to the redirect target.