curl · Report #3968853
OpenSSL ENGINE selection omitted from mTLS connection matching
How it was found
Summary
When an application sets CURLOPT_SSLENGINE to select an OpenSSL ENGINE for resolving ENG-type client certificate and private key identifiers, the engine selection is not included in the TLS configuration compared during connection reuse. A second easy handle with a different engine but the same engine-relative key/cert identifier strings can silently reuse the first handle's mTLS-authenticated connection, sending its requests under the first handle's server-side client identity.
This is an incomplete fix of CVE-2026-8932 (commit 7541ae569d).
How the bug works
1. Application configures two handles with different engines
/* Handle A: uses engine_alpha to resolve "slot0-key" */
curl_easy_setopt(A, CURLOPT_SSLENGINE, "engine_alpha");
curl_easy_setopt(A, CURLOPT_SSLKEYTYPE, "ENG");
curl_easy_setopt(A, CURLOPT_SSLCERTTYPE, "ENG");
curl_easy_setopt(A, CURLOPT_SSLKEY, "slot0-key");
curl_easy_setopt(A, CURLOPT_SSLCERT, "slot0-cert");
/* Handle B: uses engine_beta to resolve the SAME identifier strings */
curl_easy_setopt(B, CURLOPT_SSLENGINE, "engine_beta");
curl_easy_setopt(B, CURLOPT_SSLKEYTYPE, "ENG");
curl_easy_setopt(B, CURLOPT_SSLCERTTYPE, "ENG");
curl_easy_setopt(B, CURLOPT_SSLKEY, "slot0-key");
curl_easy_setopt(B, CURLOPT_SSLCERT, "slot0-cert");
Both handles target the same HTTPS origin and share a curl_multi connection pool. engine_alpha resolves "slot0-key" to User A's private key; engine_beta resolves the same string to User B's private key. Different engines, different credentials, same identifier strings.
2. Where the engine goes at option-set time
CURLOPT_SSLENGINE processing in lib/setopt.c:1962-1971:
case CURLOPT_SSLENGINE:
if(ptr && ptr[0]) {
result = Curl_setstropt(data, STRING_SSL_ENGINE, ptr); // (a)
if(!result)
result = Curl_ssl_set_engine(data, ptr); // (b)
}
Step (a) stores the engine name string in data->set.strings[STRING_SSL_ENGINE].
Step (b) calls ossl_set_engine() (lib/vtls/openssl.c:1640-1683), which does ENGINE_by_id(name) + ENGINE_init(e) and stores the functional ENGINE* pointer in data->state.engine.
Neither location is in struct ssl_primary_config. The engine name goes into the per-handle string table. The ENGINE pointer goes into per-handle runtime state. Neither is ever propagated to the connection-level config that gets compared during reuse.
3. Where the engine is used during TLS handshake
When Handle A performs its TLS handshake and the key type is ENG, libcurl calls:
-
enginecheck()(lib/vtls/openssl.c:1037-1049):ENGINE_load_private_key(data->state.engine, key_file, ...)- loads the private key from Handle A's engine. -
engineload()(lib/vtls/openssl.c:1179-1197):ENGINE_ctrl_cmd(data->state.engine, "LOAD_CERT_CTRL", ...)- loads the client certificate from Handle A's engine.
These credentials are bound to the SSL context for this connection. The server performs mTLS verification and associates the connection with User A's identity.
4. Where the engine is NOT checked during connection reuse
After Handle A completes, the connection goes into the pool. When Handle B starts, libcurl searches for a reusable connection via url_match_conn() (lib/url.c:1047-1111), which calls url_match_ssl_config() (lib/url.c:883-894).
This calls Curl_ssl_conn_config_match() (lib/vtls/vtls_config.c:170-183), which calls match_ssl_primary_config() (lib/vtls/vtls_config.c:136-168). That function compares every field of struct ssl_primary_config:
version, version_max, ssl_options, verifypeer, verifyhost, verifystatus,
cert_blob, ca_info_blob, issuercert_blob, key_blob, CApath, CAfile,
issuercert, clientcert, cipher_list, cipher_list13, curves,
signature_algorithms, CRLfile, pinned_key, cert_type, key, key_type,
key_passwd
The engine is not in this list. It is not a field in struct ssl_primary_config (lib/vtls/vtls_config.h:32-59). It is not cloned by clone_ssl_primary_config() (lib/vtls/vtls_config.c:185-218). It is not compared by match_ssl_primary_config().
Handle B has key_type="ENG", key="slot0-key", cert_type="ENG", clientcert="slot0-cert" - all identical strings to Handle A. The match function returns TRUE. Handle B reuses Handle A's connection. The server sees Handle A's client certificate for Handle B's request.
5. What the CVE-2026-8932 fix missed
Commit 7541ae569d ("tls: fix incomplete mTLS config in conn reuse and session cache") promoted key, key_type, key_passwd, cert_type, and key_blob from ssl_config_data into ssl_primary_config so they would be compared during connection reuse. This fixed reuse across different PEM/DER key files.
But the engine that gives ENG-type identifiers their meaning was not added. The fix ensured key="slot0-key" is compared, but key="slot0-key" is just an opaque label whose resolution depends entirely on which engine interprets it. Without comparing the engine, identical label strings from different engines are treated as equivalent credentials.
Reproduction
Run ./poc.sh from this directory. The POC replicates the exact comparison logic from match_ssl_primary_config() and demonstrates:
Test 1: Both handles have identical ssl_primary_config
Engine A: "engine_alpha" Engine B: "engine_beta"
match_ssl_primary_config() = TRUE
Status: BUG CONFIRMED
Test 3: Handle B has key_type="PEM" vs Handle A's "ENG"
match_ssl_primary_config() = FALSE
Status: PASS (correctly prevents reuse)
Test 4: Handle B has key="other-key-id"
match_ssl_primary_config() = FALSE
Status: PASS (correctly prevents reuse)
The engine name is the only mTLS-relevant field where a change does not prevent connection reuse.
Impact
Summary:
A request configured for identity B is transmitted over a TLS connection authenticated as identity A. The server processes it under A's authorization context. This crosses an mTLS authorization boundary without any indication to either the application or the server.