HexBrain

curl · Report #3788984

CVE-2026-11564: Native CA trust persist

Validstatus
lowseverity
7upvotes
24 Jun 2026disclosed

How it was found

(Issue found and reported by Trail of Bits, forwarded here for better tracking)

Title

Native CA trust can persist after an easy handle switches to custom CA material

Severity

Medium

Difficulty

High

Type

Cryptography

Target

lib/vtls/vtls_config.c, lib/setopt.c

Description

An easy handle that first uses default native CA trust can continue trusting the native platform store after the application switches that same handle to custom CA material for a later transfer.

On builds with USE_APPLE_SECTRUST or CURL_CA_NATIVE, libcurl automatically enables the easy handle's native_ca_store flag when a transfer uses default TLS trust material:

#if defined(USE_APPLE_SECTRUST) || defined(CURL_CA_NATIVE)
  if(!sslc->custom_capath && !sslc->custom_cafile && !sslc->custom_cablob)
    sslc->native_ca_store = TRUE;
#endif

Figure X.1: Default TLS trust enables native_ca_store (lib/vtls/vtls_config.c).

The same function does not reset native_ca_store before recomputing TLS configuration for a later transfer on the same easy handle. The setters for custom trust material only mark the corresponding custom-CA flag and store the new value:

case CURLOPT_CAINFO:
  s->ssl.custom_cafile = TRUE;
  return Curl_setstropt(&s->str[STRING_SSL_CAFILE], ptr);
...
case CURLOPT_CAPATH:
  s->ssl.custom_capath = TRUE;
  return Curl_setstropt(&s->str[STRING_SSL_CAPATH], ptr);
...
case CURLOPT_CAINFO_BLOB:
  s->ssl.custom_cablob = TRUE;
  return Curl_setblobopt(&s->blobs[BLOB_CAINFO], blob);

Figure X.2: Custom CA options do not clear native_ca_store (lib/setopt.c).

As a result, an easy handle that first completes a transfer with default CA configuration can keep ssl.native_ca_store = TRUE after the application later sets CURLOPT_CAINFO, CURLOPT_CAPATH, or CURLOPT_CAINFO_BLOB to narrow trust. On backend/platform combinations that implement native CA lookup, TLS backends then continue to consult native trust when preparing the next handshake. For example, OpenSSL consults native trust on the platforms where that path is implemented, while Rustls selects the platform verifier instead of the configured CA verifier when the flag is set:

if(ssl_config->native_ca_store) {
  ...
}

Figure X.3: OpenSSL uses the stale native_ca_store flag while loading trust anchors (lib/vtls/openssl.c).

else if(ssl_config->native_ca_store) {
  result = init_config_builder_platform_verifier(data, config_builder);
  ...
}
else if(ca_info_blob || ssl_cafile) {
  result = init_config_builder_verifier(data, config_builder,
                                        conn_config, ca_info_blob,
                                        ssl_cafile);
  ...
}

Figure X.4: Rustls prefers the platform verifier over custom CA material when native_ca_store is set (lib/vtls/rustls.c).

For Rustls, explicit CURLSSLOPT_NATIVE_CA has documented special behavior: when that option is used, only the native CA store is consulted. The stale-state issue here is different: two handles can reach the same final public option state, with CURLOPT_CAINFO_BLOB set and no explicit CURLSSLOPT_NATIVE_CA, but use different trust stores depending only on handle history.

/* Fresh handle: custom CA is present before TLS config is completed. */
CURL *fresh = curl_easy_init();
curl_easy_setopt(fresh, CURLOPT_CAINFO_BLOB, &private_ca);
curl_easy_perform(fresh); /* native_ca_store is not auto-enabled */

/* Reused handle: an earlier default-trust transfer set native_ca_store. */
CURL *reused = curl_easy_init();
curl_easy_setopt(reused, CURLOPT_URL, "https://public.example/");
curl_easy_perform(reused); /* native_ca_store becomes TRUE */

curl_easy_setopt(reused, CURLOPT_URL, "https://api.example/");
curl_easy_setopt(reused, CURLOPT_CAINFO_BLOB, &private_ca);
curl_easy_perform(reused); /* native_ca_store can remain TRUE */

A fresh easy handle that sets CURLOPT_CAINFO_BLOB before its first transfer does not auto-enable native_ca_store, because Curl_ssl_easy_config_complete only auto-enables native trust when no custom CA file, path, or blob is present. The reused handle retains native_ca_store = TRUE solely because an earlier transfer used default trust. That history-dependent difference is the trust-policy confusion.

This weakens the documented effect of the custom CA options. This does not require changing options during an active transfer: libcurl explicitly supports and encourages reusing an easy handle across sequential transfers and changing options between curl_easy_perform() calls. An application can reasonably reuse an easy handle, perform one ordinary HTTPS request, and then set CURLOPT_CAINFO or CURLOPT_CAINFO_BLOB for a subsequent request that is supposed to trust only the supplied CA material. On affected builds, that subsequent request can still accept certificates trusted by the native platform store if it performs a new TLS handshake through a backend that actually consults native_ca_store. Schannel is not affected by this specific automatic native-CA branch; other backends differ in whether stale native trust broadens the configured trust set or, as with Rustls, selects platform verification instead of the custom verifier.

This falls under curl's threat model because TLS certificates and handshake metadata are attacker-controlled network inputs, and the impact is a TLS authentication policy bypass. It is not excluded by the disclosure policy's experimental-feature rule: CURL_CA_NATIVE and Apple SecTrust are documented TLS trust-store features, not debug-only or experimental code.

The standalone local POC completes default TLS config on a native-CA-enabled build, then sets CURLOPT_CAINFO and completes TLS config again. It confirms that custom_cafile becomes true while native_ca_store remains true. The PoC proves the stale internal TLS policy state; the end-to-end certificate acceptance impact is inferred from the backend code paths that consume native_ca_store.

#if defined(USE_APPLE_SECTRUST) || defined(CURL_CA_NATIVE)
  fail_unless(((struct Curl_easy *)curl)->set.ssl.native_ca_store,
              "default TLS config should enable native CA store");
  curl_easy_setopt(curl, CURLOPT_CAINFO, "custom-ca.pem");
  if(Curl_ssl_easy_config_complete((struct Curl_easy *)curl, origin))
    goto unit_test_abort;
  fail_unless(((struct Curl_easy *)curl)->set.ssl.custom_cafile,
              "custom CAfile flag should be set");
  fail_unless(((struct Curl_easy *)curl)->set.ssl.native_ca_store,
              "custom CAfile after default config keeps native CA store");
#endif

Figure X.5: The standalone POC assertion confirms stale native-CA state.

Reproduction

Use a NativeCA-enabled build for this check:

cmake -S . -B build-review-native -G Ninja \
  -DENABLE_DEBUG=ON -DCURL_CA_NATIVE=ON \
  -DBUILD_TESTING=ON -DBUILD_CURL_EXE=OFF -DBUILD_EXAMPLES=OFF \
  -DCURL_USE_LIBPSL=OFF
cmake --build build-review-native --target curlu units -j8

Figure X.6: Build commands for a NativeCA-enabled review build.

The standalone local POC is _loose_review/valid/confirmed-valid/native-ca-state-persists-after-custom-ca/poc_native_ca_stale_after_custom_ca.c. It completes default TLS config on a CURL_CA_NATIVE=ON build, sets CURLOPT_CAINFO, then completes TLS config again and verifies that custom_cafile and native_ca_store are both true.

Build and run the standalone POC against the NativeCA-enabled build:

python3 _loose_review/valid/build_internal_poc.py build-review-native \
  _loose_review/valid/confirmed-valid/native-ca-state-persists-after-custom-ca/poc_native_ca_stale_after_custom_ca.c \
  build-review-native/poc_native_ca_stale_after_custom_ca
build-review-native/poc_native_ca_stale_after_custom_ca

The local run printed:

CONFIRMED: native_ca_store persists after switching the easy handle to custom CAINFO.

Exploit Scenario

A long-running application or handle pool uses one easy handle for multiple sequential HTTPS requests on a curl build where native_ca_store is auto-enabled and the selected TLS backend implements native CA lookup. For example, the first transfer performs an ordinary public HTTPS request using default TLS trust, which causes libcurl to set native_ca_store = TRUE. After that transfer completes, the application reuses the same easy handle, sets CURLOPT_CAINFO_BLOB to a private trust bundle for https://api.example, and expects the next transfer to trust only that supplied CA material.

If the second request performs a new TLS handshake, an attacker who can present a certificate for api.example that chains to the platform native store, but not to the application's custom bundle, can still complete the TLS handshake on affected backend/platform combinations. The application intended to narrow trust for the second request, but stale easy-handle state leaves native trust enabled.

The same stale-state pattern applies to HTTPS proxy TLS trust when CURLOPT_PROXY_CAINFO, CURLOPT_PROXY_CAPATH, or CURLOPT_PROXY_CAINFO_BLOB is set after a default proxy TLS configuration enabled native trust.

Recommendations

Short term, recompute native_ca_store from current options in Curl_ssl_easy_config_complete instead of only setting it true. Clear it when any custom CA file, CA path, or CA blob is present unless CURLSSLOPT_NATIVE_CA was explicitly requested.

Long term, separate explicit CURLSSLOPT_NATIVE_CA state from automatic default-native trust state, and add regression tests for easy-handle reuse that switches from default trust to CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_CAINFO_BLOB, and the corresponding proxy options.

Review

Decision: Accepted as a valid curl security issue.

Final severity: Medium. This is a TLS trust-policy state confusion issue on native-CA-enabled builds where the selected backend implements native trust lookup: an easy handle can retain automatic native trust after the application switches to custom CA material. Medium is appropriate because the effect can defeat an application's attempt to narrow trust, but exploitation requires a specific easy-handle reuse sequence, a new TLS handshake, an affected backend/build configuration, and an attacker certificate that chains to the native platform store but not to the intended custom trust bundle.

Technical notes: The finding correctly distinguishes automatic native trust from explicit CURLSSLOPT_NATIVE_CA. The stale-state problem is that Curl_ssl_easy_config_complete sets native_ca_store for default trust but does not recompute it back to false when later custom CA options are present. The Schannel path is not affected by this specific automatic-native branch because the code skips the auto-enable logic for Schannel.

Impact

See description

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