curl · Report #3734935
rustls backend silently ignores CURLOPT_CRLFILE when native CA store is active
How it was found
Hi all,
When the rustls backend is configured to use the OS native CA store (--ca-native / CURLSSLOPT_NATIVE_CA), any CRL file supplied via --crlfile / CURLOPT_CRLFILE is silently ignored. The option is accepted — CURLE_OK from curl_easy_setopt, exit 0 from the command line — and revoked certificates pass TLS verification without any error or warning at any verbosity level.
The short version: Curl_ssl_rustls advertises SSLSUPP_CRLFILE unconditionally, so setopt accepts the path and stores it in conn_config->CRLfile on every rustls handle regardless of which verifier will actually be used. By the time cr_init_backend() runs, that path has been accepted by the library and there is nothing left to complain about. The verifier-selection ladder then silently discards it.
The ladder at lib/vtls/rustls.c:1040-1061 has three mutually exclusive arms. When verifypeer is off, a no-op verifier is installed. When ssl_config->native_ca_store is set, init_config_builder_platform_verifier() is called and the function returns — conn_config->CRLfile is never examined on this path. The else if(ca_info_blob || ssl_cafile) branch is where init_config_builder_verifier() lives, and that function checks conn_config->CRLfile at lines 752-758 and calls through to rustls_web_pki_server_cert_verifier_builder_add_crl(). But the native-CA and explicit-CA branches are mutually exclusive; you can't be on both at once.
else if(ssl_config->native_ca_store) { /* branch A — CRLfile never touched */
result = init_config_builder_platform_verifier(data, config_builder);
if(result != CURLE_OK) {
rustls_client_config_builder_free(config_builder);
return result;
}
}
else if(ca_info_blob || ssl_cafile) { /* branch B — CRL wiring is here */
result = init_config_builder_verifier(data, config_builder,
conn_config, ca_info_blob, ssl_cafile);
...
}
The reason branch A has no CRL wiring isn't an oversight in the curl code so much as a hard limitation in rustls-ffi: rustls_platform_server_cert_verifier() returns a fully-constructed opaque rustls_server_cert_verifier * with no subsequent mutation API. The webpki path has a builder pattern — rustls_web_pki_server_cert_verifier_builder_new(), then _add_crl(), then _build() — but there is no equivalent rustls_platform_server_cert_verifier_builder_* family. You cannot attach a CRL to the platform verifier at the rustls-ffi 0.15 level, full stop. The curl-side problem is that this upstream limitation is invisible to the caller: the API says yes, the transfer succeeds, and a revoked certificate walks right through.
Reproduction — tested against curl 8.21.0-DEV libcurl/8.21.0-DEV rustls-ffi/0.15.0/rustls/0.23.25/aws-lc-rs:
CURL=curl/bld-rustls/src/curl
WORKDIR=/tmp/f28-poc
mkdir -p $WORKDIR/ca/{newcerts,private}
touch $WORKDIR/ca/index.txt
printf '01\n' > $WORKDIR/ca/serial
printf '01\n' > $WORKDIR/ca/crlnumber
# generate CA
openssl genrsa -out $WORKDIR/ca/private/cakey.pem 4096 2>/dev/null
openssl req -new -x509 -days 3650 \
-key $WORKDIR/ca/private/cakey.pem \
-out $WORKDIR/ca/cacert.pem \
-subj "/CN=F28 Test CA/O=PoC/C=AU"
# issue server cert with SAN (platform verifier requires it)
cat > $WORKDIR/san.ext <<'EOF'
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF
openssl genrsa -out $WORKDIR/server.key 2048 2>/dev/null
openssl req -new -key $WORKDIR/server.key -out $WORKDIR/server.csr \
-subj "/CN=localhost/O=PoC/C=AU" -config $WORKDIR/san.ext
openssl x509 -req -in $WORKDIR/server.csr \
-CA $WORKDIR/ca/cacert.pem -CAkey $WORKDIR/ca/private/cakey.pem \
-CAcreateserial -out $WORKDIR/server.crt -days 365 \
-extensions v3_req -extfile $WORKDIR/san.ext
# revoke it and generate CRL
cat > $WORKDIR/ca/openssl.cnf <<'EOF'
[ca]
default_ca = CA_default
[CA_default]
dir = /tmp/f28-poc
database = /tmp/f28-poc/ca/index.txt
new_certs_dir = /tmp/f28-poc/ca/newcerts
certificate = /tmp/f28-poc/ca/cacert.pem
serial = /tmp/f28-poc/ca/serial
private_key = /tmp/f28-poc/ca/private/cakey.pem
crlnumber = /tmp/f28-poc/ca/crlnumber
default_crl_days = 30
default_md = sha256
policy = policy_anything
copy_extensions = copy
[policy_anything]
countryName = optional
commonName = supplied
EOF
openssl ca -config $WORKDIR/ca/openssl.cnf -revoke $WORKDIR/server.crt 2>/dev/null
openssl ca -config $WORKDIR/ca/openssl.cnf -gencrl -out $WORKDIR/revoked.crl 2>/dev/null
# install CA into OS trust store
sudo cp $WORKDIR/ca/cacert.pem /usr/local/share/ca-certificates/f28-test-ca.crt
sudo update-ca-certificates
# start server presenting the revoked cert
openssl s_server \
-cert $WORKDIR/server.crt -key $WORKDIR/server.key \
-CAfile $WORKDIR/ca/cacert.pem \
-accept 18443 -www -quiet &
Test A — --cacert + --crlfile (branch B, CRL enforced):
$ $CURL -s --cacert /tmp/f28-poc/ca/cacert.pem \
--crlfile /tmp/f28-poc/revoked.crl \
https://localhost:18443/
* rustls_connection_process_new_packets: invalid peer certificate: Revoked
curl: (60) rustls_connection_process_new_packets: invalid peer certificate: Revoked
$ echo $?
60
Test B — --ca-native + --crlfile (branch A, CRL silently ignored):
$ $CURL -s --ca-native \
--crlfile /tmp/f28-poc/revoked.crl \
https://localhost:18443/
<HTML><BODY BGCOLOR="#ffffff">...
$ echo $?
0
The certificate, the CRL file, and the server are identical across both runs. The only difference is which verifier branch cr_init_backend() takes. Test A exits 60 with a clear rejection message. Test B exits 0 with the HTTP body and no indication whatsoever that revocation was not checked.
The immediate fix is a three-line guard at the top of the native_ca_store branch:
--- a/lib/vtls/rustls.c
+++ b/lib/vtls/rustls.c
@@ -1043,6 +1043,13 @@
else if(ssl_config->native_ca_store) {
+ if(conn_config->CRLfile) {
+ failf(data, "rustls: --crlfile is not supported with --ca-native; "
+ "the platform verifier has no CRL attachment API");
+ rustls_client_config_builder_free(config_builder);
+ return CURLE_NOT_BUILT_IN;
+ }
result = init_config_builder_platform_verifier(data, config_builder);
That converts silent non-enforcement into a clear, actionable error — CURLE_NOT_BUILT_IN (77) plus a descriptive message, the same result as setting CURLOPT_CRLFILE on a backend that declares no support for it. The unconditional SSLSUPP_CRLFILE in Curl_ssl_rustls is also a candidate for removal or conditionalization so the rejection happens at setopt time rather than connection time, but the in-cr_init_backend() guard is the safe immediate path. The longer-term path is a rustls-ffi upstream feature request for a builder pattern around the platform verifier; if rustls_platform_server_cert_verifier_builder_add_crl() ever exists, the guard can be relaxed and actual CRL enforcement on the native-CA path becomes possible.
Thank you, AISLE Research Team
Impact
when curl uses the rustls native OS verifier path with --ca-native, a supplied --crlfile can be accepted by the API but never applied, so a certificate that should be rejected as revoked may still verify successfully and the HTTPS connection proceeds as trusted. In current curl source, CRL handling is wired into the webpki verifier builder path, while the native verifier path constructs an opaque platform verifier directly, and curl's public docs still say CURLOPT_CRLFILE works with Rustls, so this is best described as a real silent-security-failure / policy-bypass bug rather than intended behavior.