HexBrain

curl · Report #3968853

OpenSSL ENGINE selection omitted from mTLS connection matching

Informativestatus
lowseverity
5upvotes
14 Sept 2026disclosed

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.

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