curl · Report #3788984
CVE-2026-11564: Native CA trust persist
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