HexBrain

curl · Report #3739561

SSL session-cache peer key omits signature_algorithms: strict-sigalg handle silently resumes a permissive sibling's session

Informativestatus
17upvotes
19 May 2026disclosed

How it was found

CURLOPT_SSL_SIGNATURE_ALGORITHMS policy bypass: SSL session cache key omits sigalgs, allowing a strict-sigalg handle to resume a session negotiated under a permissive policy

AI disclosure

This report was prepared with the assistance of an AI coding assistant (Claude). The behavioral diff (pre/post patch) reproduces deterministically on a real OpenSSL-backed curl build against openssl s_server; full verbose logs are attached. Code-reading claims have been double-checked against the source at the commit listed below (vtls.c matcher vs. vtls_scache.c peer-key builder). This vulnerability class has a direct precedent in CVE-2020-8231 (where the same asymmetry existed between the connection-cache matcher and the peer-key builder for a different field).

Affected versions

Introduced in commit a638828c88 ("TLS: add CURLOPT_SSL_SIGNATURE_ALGORITHMS and --sigalgs", Andrei Florea, 2025-04-02) — the option's introducing commit added sigalgs to match_ssl_primary_config and the clone path, but not to Curl_ssl_peer_key_make (the session-cache key builder). First affected release: curl 8.14.0 (git describe --tags --contains a638828c88 → curl-8_14_0~167). Affected through curl 8.20.0 (current latest released) and master (curl-8_20_0-78-ga32a2b0b77, LIBCURL_VERSION "8.21.0-DEV"). Confirmed on master built with --with-openssl=/opt/homebrew/opt/openssl@3. The patch applies cleanly to all of them.

Summary

Curl_ssl_peer_key_make (lib/vtls/vtls_scache.c) builds the cache key used to look up cached SSL sessions for reuse. It includes fields like cipher_list13 and curves, but omits signature_algorithms. Meanwhile, match_ssl_primary_config (lib/vtls/vtls.c:219) does compare signature_algorithms when deciding whether two SSL primary configs match.

The result: two CURL handles that differ only in CURLOPT_SSL_SIGNATURE_ALGORITHMS produce identical peer cache keys. If they share an SSL session cache (via CURLSH with CURL_LOCK_DATA_SSL_SESSION), a handle with a strict sigalg policy can resume a session that was negotiated under a permissive policy on a sibling handle — silently bypassing its own stricter policy: the resumed handshake does not present a certificate, so the sigalg constraint (which is applied during certificate validation) never runs.

This is the same defect class as CVE-2020-8231 (peer-cache-key omits a field the matcher uses).

Source-level evidence (pre-patch)

lib/vtls/vtls_scache.c, Curl_ssl_peer_key_make:

  if(ssl->cipher_list13) {
    r = curlx_dyn_addf(&buf, ":CIPHER13-%s", ssl->cipher_list13);
    if(r) goto out;
  }
  if(ssl->curves) {
    r = curlx_dyn_addf(&buf, ":CURVES-%s", ssl->curves);
    if(r) goto out;
  }
  /* <-- signature_algorithms NOT added to peer key */
  if(ssl->verifypeer) {
    …
  }

lib/vtls/vtls.c:194-225, match_ssl_primary_config (the predicate used by the connection-reuse decision):

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->verifypeer == c2->verifypeer) &&
     (c1->verifyhost == c2->verifyhost) &&
     (c1->verifystatus == c2->verifystatus) &&
     blobcmp(c1->cert_blob, c2->cert_blob) &&
     blobcmp(c1->ca_info_blob, c2->ca_info_blob) &&
     blobcmp(c1->issuercert_blob, c2->issuercert_blob) &&
     Curl_safecmp(c1->CApath, c2->CApath) &&
     Curl_safecmp(c1->CAfile, c2->CAfile) &&
     Curl_safecmp(c1->issuercert, c2->issuercert) &&
     Curl_safecmp(c1->clientcert, c2->clientcert) &&
#ifdef USE_TLS_SRP
     !Curl_timestrcmp(c1->username, c2->username) &&
     !Curl_timestrcmp(c1->password, c2->password) &&
#endif
     curl_strequal(c1->cipher_list, c2->cipher_list) &&
     curl_strequal(c1->cipher_list13, c2->cipher_list13) &&
     curl_strequal(c1->curves, c2->curves) &&
     curl_strequal(c1->signature_algorithms,        /* line 219 */
                   c2->signature_algorithms) &&
     curl_strequal(c1->CRLfile, c2->CRLfile) &&
     curl_strequal(c1->pinned_key, c2->pinned_key))
    return TRUE;

  return FALSE;
}

The asymmetry: the connection-reuse matcher considers signature_algorithms (line 219, curl_strequal), but the session- cache key builder does not (no :SIGALGS- line in Curl_ssl_peer_key_make). Two handles with the same host/port/CA but different sigalgs therefore agree on cache-key equality (wrong) while disagreeing on connection-config equivalence (correct). The session- cache becomes the weak link.

Reproduction

Build curl with OpenSSL backend (any backend that implements CURLOPT_SSL_SIGNATURE_ALGORITHMS):

./configure --with-openssl=<path> --enable-debug --enable-curldebug
make

Test cert (server, signed with ECDSA+SHA256):

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
  -keyout key.pem -out cert.pem -days 1 -nodes -subj "/CN=localhost"

Run a TLS 1.3 server that supports session tickets:

openssl s_server -accept 14443 -cert cert.pem -key key.pem -tls1_3 -www -quiet

PoC (single C file, ~80 lines, attached as poc_sigalgs_bypass.c):

CURLSH *sh = curl_share_init();
curl_share_setopt(sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);

CURL *easy = curl_easy_init();

/* Handle A: permissive sigalgs (includes ECDSA+SHA256 which the
   server's cert can satisfy). Populates the session cache. */
curl_easy_setopt(easy, CURLOPT_URL, "https://localhost:14443/");
curl_easy_setopt(easy, CURLOPT_SHARE, sh);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(easy, CURLOPT_SSL_SIGNATURE_ALGORITHMS,
                 "ECDSA+SHA1:ECDSA+SHA256");
curl_easy_perform(easy);   /* succeeds, caches session under permissive */

/* Handle B: strict sigalgs (only ECDSA+SHA384). The server cert is
   ECDSA+SHA256 — under B's stated policy, the handshake MUST fail. */
curl_easy_reset(easy);
curl_easy_setopt(easy, CURLOPT_URL, "https://localhost:14443/");
curl_easy_setopt(easy, CURLOPT_SHARE, sh);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(easy, CURLOPT_SSL_SIGNATURE_ALGORITHMS, "ECDSA+SHA384");
CURLcode rc = curl_easy_perform(easy);
printf("B rc=%d (%s)\n", rc, curl_easy_strerror(rc));

Observed behavior

Pre-patch (vulnerable):

=== A_permissive: sigalgs="ECDSA+SHA1:ECDSA+SHA256" ===
SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
    A_permissive rc=0
=== B_strict: sigalgs="ECDSA+SHA384" ===
SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / UNDEF
    B_strict rc=0   <-- SHOULD HAVE FAILED

B_strict connects successfully despite the server cert being signed with ECDSA+SHA256 (which B's policy excludes). The UNDEF curve in the second connection is the TLS 1.3 resumption marker — no fresh key exchange occurred. B reused A's session, so B's stricter sigalg policy never got a chance to reject the cert.

Post-patch (with :SIGALGS- added to peer key):

=== A_permissive: sigalgs="ECDSA+SHA1:ECDSA+SHA256" ===
    A_permissive rc=0
=== B_strict: sigalgs="ECDSA+SHA384" ===
    B_strict rc=35 (SSL connect error)   <-- correctly enforced

B_strict no longer finds A's session in the cache (different peer key), does a fresh handshake under its own policy, and the server's ECDSA+SHA256 cert is correctly rejected.

Impact

  • Policy bypass: any application that uses CURLOPT_SSL_SIGNATURE_ALGORITHMS for tiered authentication (e.g., stricter sigalgs for high-value endpoints, more permissive for legacy/internal) and shares an SSL session cache across handles silently inherits the weakest policy ever used against the host.
  • TLS 1.3 verified, TLS 1.2 plausibly equivalent: TLS 1.3 resumption is PSK-based and skips the Certificate message entirely (verified empirically here, see the UNDEF curve marker in the pre-fix verbose log). TLS 1.2 abbreviated handshakes (both session-ID and ticket-based) likewise omit Certificate; the same bypass shape applies but has not been verified empirically in this report.
  • No memory safety, no cryptographic break. This is a policy- laundering bug, identical in class to CVE-2020-8231 (which had a CVE assigned for the analogous case in another field).
  • Real-world reach: narrow but unsympathetic. Applications that set CURLOPT_SSL_SIGNATURE_ALGORITHMS per handle are by definition applications that want differentiated sigalg enforcement — the exact use case this defect undoes. Common pattern: PQ-ready or non-SHA1 policy on high-value endpoints, legacy on internal services. Either tier of caller, when sharing an SSL session cache with a more-permissive sibling, inherits the weaker policy silently.

Suggested fix

lib/vtls/vtls_scache.c, inside Curl_ssl_peer_key_make, add the signature_algorithms field to the peer key alongside the other ssl-config fields:

   if(ssl->curves) {
     r = curlx_dyn_addf(&buf, ":CURVES-%s", ssl->curves);
     if(r)
       goto out;
   }
+  /* signature_algorithms must be part of the peer key: the connection
+     cache matcher (match_ssl_primary_config) compares this field, so
+     two handles differing only in CURLOPT_SSL_SIGNATURE_ALGORITHMS
+     must not share a cached session. Without this, the stricter
+     handle inherits the permissive handle's negotiated session,
+     bypassing its policy. */
+  if(ssl->signature_algorithms) {
+    r = curlx_dyn_addf(&buf, ":SIGALGS-%s", ssl->signature_algorithms);
+    if(r)
+      goto out;
+  }
   if(ssl->verifypeer) {
     …

After applying, the e2e test in this report flips from B rc=0 to B rc=35 (correct policy enforcement).

Attachments

  • poc_sigalgs_bypass.c — single-file libcurl PoC (~80 lines)
  • verbose-pre-patch.txt — full curl verbose log, pre-fix run (B succeeds with UNDEF curve TLS 1.3 resumption marker)
  • verbose-post-patch.txt — full curl verbose log, post-fix run (B fails rc=35 SSL connect error)
  • suggested-patch.diff — unified diff adding :SIGALGS-%s to Curl_ssl_peer_key_make

Impact

Applications that use CURLOPT_SSL_SIGNATURE_ALGORITHMS for tiered authentication (stricter sigalgs for high-value endpoints, more permissive for legacy/internal) and share an SSL session cache silently inherit the weakest policy ever used against a given host. Verified empirically on TLS 1.3 (PSK resumption, no Certificate message — UNDEF curve marker in the attached log). TLS 1.2 abbreviated handshakes (session-ID and ticket) also omit Certificate, so the same bypass shape applies but was not verified empirically here. No memory-safety impact; this is policy laundering via the session cache.

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