HexBrain

curl · Report #4122227

wolfSSL TLS sessions retain native-CA trust after the application narrows trust

Informativestatus
lowseverity
5upvotes
26 Oct 2026disclosed

How it was found

Summary

In curl 8.23.0 with the wolfSSL backend, a TLS session authenticated while the build-default native CA store is active can be resumed after the application changes the same easy handle to a policy that disables native trust. The second transfer succeeds and exchanges HTTP data even though an otherwise identical full handshake under the new policy rejects the peer.

The TLS session-cache key contains the raw CURLOPT_SSL_OPTIONS mask and CA file/path/blob inputs, but not the resolved native_ca_store boolean. That boolean can differ while every keyed field remains identical. wolfSSL then applies the ineligible cached session and a resumed TLS 1.2 handshake does not repeat certificate-chain authentication.

The minimal reproducer uses one ordinary easy handle and documented public APIs. It forces a new TCP connection for every request, so this is not the connection-reuse problem fixed as CVE-2026-11564. The issue remains in 8.21.0 and later releases that are documented as fixed for that CVE.

I suggest Low severity and CWE-295 (Improper Certificate Validation). The prerequisites are uncommon, but the outcome is a real trust-policy bypass with confidentiality and integrity impact.

Affected scope

Confirmed end to end with curl's wolfSSL backend:

  • curl 8.19.0: affected
  • curl 8.20.0: affected
  • curl 8.21.0: affected, despite containing the CVE-2026-11564 fix
  • curl 8.22.0: affected
  • curl 8.23.0: affected
  • current master eaa9103225bff16bdf1bb2842f312897b1342e85: affected

The generic CURL_CA_NATIVE build option was introduced by 1730407b74f41cfd33f189348be54d0504b7c291, first present in curl 8.19.0. The session cache capable of this collision predates it.

For the distinct post-CVE regression, the important affected range is 8.21.0 through 8.23.0/current. Versions 8.19.0 and 8.20.0 also exhibit the result but were already affected by the broader sticky-state behavior assigned CVE-2026-11564.

I confirmed the security outcome only with wolfSSL 5.9.4 built with WOLFSSL_SYS_CA_CERTS. A current GnuTLS control tried the cached session but still rejected the peer under the restricted policy. I therefore do not claim GnuTLS as affected. Other backends should be evaluated separately.

Root cause

ssl_easy_config_compl_options() resolves the effective policy independently of the raw SSL option mask. With a CURL_CA_NATIVE build, native trust becomes the default only while no CA setting was explicitly selected:

primary->native_ca_store = !!(options & CURLSSLOPT_NATIVE_CA);
...
if(!sslc->custom_capath && !sslc->custom_cafile && !sslc->custom_cablob)
  ssl_origin->native_ca_store = TRUE;

match_ssl_primary_config() compares native_ca_store, so curl already recognizes this state as security-relevant when deciding whether a live connection can be reused. ssl_peer_key_build() does not. It adds ssl->ssl_options, CAfile, CApath, and CA blob content, but never adds ssl->native_ca_store.

For the curl 8.23.0 minimal path built with CURL_CA_NATIVE=ON, no compiled CA bundle, and no compiled CA path, the configurations are:

Statecustom CA selectedCA fileraw SSL optionsresolved native CA
WarmupnoNULL0true
Restrictedyes (CURLOPT_CAINFO=NULL)NULL0false

The peer keys are identical. Treating NULL as a real custom choice is intentional in 8.23.0: commit 9a6d4ffc1b6c184fb2ec7c6e4879c835cb367689 restored that behavior, and the CURLOPT_CAINFO documentation says NULL disables use of the option.

There is also a non-NULL collision that reproduces in every tested release from 8.19.0 through 8.23.0. Build curl with both CURL_CA_NATIVE=ON and an explicit CURL_CA_BUNDLE=/path/to/bundle.pem. The default transfer has native trust plus that bundle. Setting CURLOPT_CAINFO to the same bundle path for the second transfer disables native trust but leaves the keyed CA path unchanged. This is the practical policy transition of retaining a shipped CA bundle while excluding platform-added roots.

In wssl_apply_session(), curl deserializes the matching cached session and calls wolfSSL_set_session() without checking whether the effective native-CA policy matches the one that authenticated the session.

Relationship to CVE-2026-11564

CVE-2026-11564 covered an easy handle retaining default native trust after an application switched to custom CA material. Its fix, d69bfad3fa3daf5e72331f6870667607828d5891, recalculates native_ca_store for each transfer and prevents an old live connection from matching the new configuration.

That fix does not change lib/vtls/vtls_scache.c. The recalculated values are correct, but the TLS-session cache maps the two different values to the same key. The reproducer sets both CURLOPT_FRESH_CONNECT and CURLOPT_FORBID_REUSE; curl 8.21.0 through 8.23.0 still resume the old TLS session on a new TCP connection. This is therefore a separate incomplete-fix path crossing the same security boundary in released versions advertised as fixed.

Official prior advisory: https://curl.se/docs/CVE-2026-11564.html

Minimal reproducer

The attachment contains repro-null.c and run.sh. Requirements are curl 8.23.0 source, CMake, a C compiler, libidn2 development files, and wolfSSL 5.9.4 built with curl support and WOLFSSL_SYS_CA_CERTS.

Run:

./run.sh /path/to/curl-8.23.0 /path/to/wolfssl-prefix \
  /tmp/curl-native-session-build https://example.com/

The URL can be replaced with any public HTTPS endpoint that is trusted by the machine's native store and accepts TLS 1.2 session resumption.

Expected vulnerable output:

control=77(Problem with the SSL CA cert (path? access rights?)) warmup=0 trigger=0 trigger_reused=1 trigger_body=577

The response size can vary. The program exits zero only if:

  1. a new handle with the restricted policy and its session cache disabled rejects the peer;
  2. the warmup succeeds under native trust;
  3. the same easy handle succeeds after the restrictive option change;
  4. curl emits its SSL reusing session diagnostic; and
  5. response body bytes are received.

Every transfer forces fresh TCP and forbids retaining the connection. TLS 1.2 is forced to make ticket reuse deterministic. Peer and hostname verification remain enabled.

I obtained the same vulnerable output in three consecutive ASan+LSan runs on curl 8.23.0 with no sanitizer diagnostics.

Version and policy controls

The attachment also includes repro-same-easy.c, which uses a valid unrelated CA bundle rather than NULL. With a build whose compiled bundle is that same file, the exact result was stable on all five released tags:

control=77 warmup=0 trigger=0 trigger_reused=1

Tested commits:

  • 8.19.0: 8c908d2d0a6d32abdedda2c52e90bd56ec76c24d
  • 8.20.0: a05f34973e6c4bb629d018f7cb51487be1c904d8
  • 8.21.0: 68720b4837284335b2d63cb358f8f6ce65f5bc55
  • 8.22.0: 01346829096c61b372692f6dc43ffa778c6caccd
  • 8.23.0: 3163ec065643ba20709f190111f42c3e1fe95273

A separate key-separation control performs the warmup with an explicit CURLSSLOPT_NATIVE_CA bit. That changes the raw option mask and therefore the existing peer key. The restrictive request then fails and no session is reused:

warmup=0 control=77 trigger=0 trigger_reused=1 separated_warmup=0 separated=77 separated_reused=0

This demonstrates that the success depends on the cache-key collision rather than connection reuse or a generally ineffective CA policy.

Patched control

fix.patch adds the resolved native-store state to the peer key when peer verification is active and adds a unit3304 regression test. With that patch, the same ASan build produces:

control=77(Problem with the SSL CA cert (path? access rights?)) warmup=0 trigger=77 trigger_reused=0 trigger_body=0

The new unit test passes. git diff --check is clean.

Suggested fix

Include the resolved effective state in ssl_peer_key_build(), for example:

if(ssl->verifypeer && ssl->native_ca_store) {
  result = curlx_dyn_add(&buf, ":NATIVE-CA");
  if(result)
    goto out;
}

The effective boolean is the correct discriminator. Keying only on the raw CURLSSLOPT_NATIVE_CA bit is insufficient because build defaults resolve the same raw mask differently depending on whether custom CA inputs were chosen.

Duplicate review

I searched curl's public issues, pull requests, discussions, advisory list, and the history of vtls_scache.c for native-CA/session combinations. I found no report of this cache-key omission. PR #22235 concerns preserving successful Apple SecTrust verification inside an OpenSSL session; it does not separate sessions after the effective native-CA policy changes. The public fix for CVE-2026-11564 likewise does not touch the session cache.

Relevant design documentation says different TLS configurations must produce different ssl_peer_key values: https://github.com/curl/curl/blob/master/docs/internals/TLS-SESSIONS.md

Suggested classification

  • Severity: Low
  • CWE: CWE-295, Improper Certificate Validation
  • Component: libcurl TLS session cache / wolfSSL
  • Impact: confidentiality and integrity after a documented CA trust-policy transition

Impact

An application can first make a non-sensitive request to an origin using the platform trust store, then deliberately narrow trust before a later sensitive request to the same origin. If the remote endpoint resumes the earlier TLS session, curl with wolfSSL continues the transfer under the old trust decision. The endpoint can receive credentials or request content and control the response even though a new handshake under the application's current policy would reject it.

This matters when custom CA configuration is used to exclude platform roots, including locally or administratively installed roots. An endpoint accepted under that broader native policy can retain access for the lifetime of its cached session after the application withdraws that trust.

The attacker must control the endpoint that established the original session, or otherwise possess its session-resumption state. A generic unrelated MitM cannot resume another server's ticket. The attack also requires the same origin, a NativeCA+wolfSSL build, and a server that accepts resumption. These constraints make Low appropriate, but do not remove the trust boundary. The same trust transition and impact were classified Low/CWE-295 in CVE-2026-11564.

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