curl · Report #3739561
SSL session-cache peer key omits signature_algorithms: strict-sigalg handle silently resumes a permissive sibling's session
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_ALGORITHMSfor 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
UNDEFcurve 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_ALGORITHMSper 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 withUNDEFcurve 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-%stoCurl_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.