HexBrain

curl · Report #3993870

CVE-2026-86415: auto client cert connection reuse mixup

Validstatus
lowseverity
2upvotes
14 Oct 2026disclosed

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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original