Description
## Summary
When an application sets `CURLOPT_SSL_VERIFYPEER=0` while keeping
`CURLOPT_SSL_VERIFYHOST=2` (the default), the mbedTLS, wolfSSL, and rustls
TLS backends silently skip the hostname-vs-certificate check. The OpenSSL,
GnuTLS, and Schannel backends correctly preserve hostname checking under
the same configuration.
`docs/libcurl/opts/CURLOPT_SSL_VERIFYPEER.md` lines 57-59 explicitly state:
> *"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**."*
Result: an MITM holding any valid certificate (the CN does not need to
match the requested host) can intercept TLS sessions to any HTTPS target
when an application disables peer chain verification — a common pattern
with self-signed / internal-CA deployments. `Authorization`, `Cookie`, and
POST bodies all leak to the attacker.
## Affected backends
```
$ src/curl --version # all four built from the same source tree
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV OpenSSL/3.5.6 ← rc=60 (correct)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV mbedTLS/3.6.5 ← rc=0 (BUG)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV wolfSSL/5.7.2 ← rc=0 (BUG)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV rustls-ffi/0.15.3 ← rc=0 (BUG)
```
Affected versions: **all curl releases through HEAD master** for the
mbedTLS, wolfSSL, and rustls backends. The mbedTLS code path traces to
commit `88cae145509` (2024-08-17, "mbedtls: add more informative logging"),
shipped in 8.10.0 and unchanged through current master.
The relevant code sites:
- **mbedTLS** — `lib/vtls/mbedtls.c::mbed_verify_cb`:
```c
if(!conn_config->verifypeer)
*flags = 0; /* clears CN_MISMATCH along with chain flags */
else if(!conn_config->verifyhost)
*flags &= ~MBEDTLS_X509_BADCERT_CN_MISMATCH;
```
`*flags = 0` zeroes the entire mbedTLS verify-flag bitmap, including
`MBEDTLS_X509_BADCERT_CN_MISMATCH`. mbedTLS uses
`MBEDTLS_SSL_VERIFY_REQUIRED` and trusts whatever flags this callback
returns.
- **wolfSSL** — `lib/vtls/wolfssl.c::Curl_wssl_ctx_init`:
```c
wolfSSL_CTX_set_verify(wctx->ssl_ctx, conn_config->verifypeer ?
WOLFSSL_VERIFY_PEER : WOLFSSL_VERIFY_NONE, NULL);
```
When `verifypeer=0`, `WOLFSSL_VERIFY_NONE` makes wolfSSL skip the entire
certificate-validation pipeline including the hostname check registered
by `wolfSSL_check_domain_name` further down.
- **rustls** — `lib/vtls/rustls.c::cr_init_backend` (line 1048-1050):
```c
if(!conn_config->verifypeer) {
rustls_client_config_builder_dangerous_set_certificate_verifier(
config_builder, cr_verify_none);
}
```
`cr_verify_none` (line 389) is a callback that returns `RUSTLS_RESULT_OK`
unconditionally. It replaces rustls's default `ServerCertVerifier`, which
in rustls performs both chain validation **and** hostname matching against
the SNI value passed to `rustls_client_connection_new`. Replacing it
disables both at once.
## Steps to reproduce
A self-contained Docker reproduction package is attached
(`verifyhost-repro.tar.gz`). It pulls the latest curl source from
`github.com/curl/curl.git`, builds four libcurls from the same source tree
(OpenSSL, mbedTLS, wolfSSL, rustls backends), runs a TLS server presenting
a wrong-CN certificate, and drives a libcurl client at all four backends.
```sh
tar xzf verifyhost-repro.tar.gz
cd docker
./build_and_run.sh
```
(Pass a tag/branch/SHA as arg to pin: `./build_and_run.sh curl-8_21_0`.)
Expected output:
```
================================================================
curl source commit: <SHA from official curl/curl repo>
OpenSSL curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV OpenSSL/3.5.6 ...
mbedTLS curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV mbedTLS/3.6.5 ...
wolfSSL curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV wolfSSL/5.7.2 ...
rustls curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV rustls-ffi/0.15.3 ...
================================================================
============== OpenSSL backend (control) — expect rc=60 ==============
* SSL: certificate subject name 'wrong.example.com' does not match target hostname 'target.example.com'
=== RESULT: rc=60 (SSL peer certificate or SSH remote key was not OK) ===
============== mbedTLS backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
< Content-Length: 7
=== RESULT: rc=0 (No error) ===
============== wolfSSL backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
=== RESULT: rc=0 (No error) ===
============== rustls backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
=== RESULT: rc=0 (No error) ===
```
The OpenSSL run is the control: same source tree, same flags, same PoC
code — only the TLS backend differs. Its `rc=60` confirms that the PoC
correctly enforces `verifyhost=2` and that the `rc=0` on the other three
backends is a backend defect, not a PoC mistake.
## Files in the package
- `Dockerfile` — Debian trixie + mbedTLS 3.6.5 + OpenSSL 3.x + wolfSSL 5.7.2 + rustls-ffi 0.15.3
- `build_and_run.sh` — wrapper: `docker build` then `docker run`
- `run.sh` — entrypoint inside container; drives all four scenarios
- `poc.c` — minimal libcurl PoC
- `poc_server.py` — Python TLS server presenting CN=wrong.example.com
## Credits
Reported by: b1gtang on hackerone (SecBuddyF @ Tencent KeenLab)
## Impact
## Summary:
An MITM on the network path can fully impersonate the intended HTTPS
server using any certificate (no need to match the requested hostname),
decrypting and modifying the entire TLS session. The application has
opted out of chain verification but explicitly kept hostname verification
on per the documented contract; libcurl silently disables both.
When an application sets `CURLOPT_SSL_VERIFYPEER=0` while keeping
`CURLOPT_SSL_VERIFYHOST=2` (the default), the mbedTLS, wolfSSL, and rustls
TLS backends silently skip the hostname-vs-certificate check. The OpenSSL,
GnuTLS, and Schannel backends correctly preserve hostname checking under
the same configuration.
`docs/libcurl/opts/CURLOPT_SSL_VERIFYPEER.md` lines 57-59 explicitly state:
> *"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**."*
Result: an MITM holding any valid certificate (the CN does not need to
match the requested host) can intercept TLS sessions to any HTTPS target
when an application disables peer chain verification — a common pattern
with self-signed / internal-CA deployments. `Authorization`, `Cookie`, and
POST bodies all leak to the attacker.
## Affected backends
```
$ src/curl --version # all four built from the same source tree
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV OpenSSL/3.5.6 ← rc=60 (correct)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV mbedTLS/3.6.5 ← rc=0 (BUG)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV wolfSSL/5.7.2 ← rc=0 (BUG)
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV rustls-ffi/0.15.3 ← rc=0 (BUG)
```
Affected versions: **all curl releases through HEAD master** for the
mbedTLS, wolfSSL, and rustls backends. The mbedTLS code path traces to
commit `88cae145509` (2024-08-17, "mbedtls: add more informative logging"),
shipped in 8.10.0 and unchanged through current master.
The relevant code sites:
- **mbedTLS** — `lib/vtls/mbedtls.c::mbed_verify_cb`:
```c
if(!conn_config->verifypeer)
*flags = 0; /* clears CN_MISMATCH along with chain flags */
else if(!conn_config->verifyhost)
*flags &= ~MBEDTLS_X509_BADCERT_CN_MISMATCH;
```
`*flags = 0` zeroes the entire mbedTLS verify-flag bitmap, including
`MBEDTLS_X509_BADCERT_CN_MISMATCH`. mbedTLS uses
`MBEDTLS_SSL_VERIFY_REQUIRED` and trusts whatever flags this callback
returns.
- **wolfSSL** — `lib/vtls/wolfssl.c::Curl_wssl_ctx_init`:
```c
wolfSSL_CTX_set_verify(wctx->ssl_ctx, conn_config->verifypeer ?
WOLFSSL_VERIFY_PEER : WOLFSSL_VERIFY_NONE, NULL);
```
When `verifypeer=0`, `WOLFSSL_VERIFY_NONE` makes wolfSSL skip the entire
certificate-validation pipeline including the hostname check registered
by `wolfSSL_check_domain_name` further down.
- **rustls** — `lib/vtls/rustls.c::cr_init_backend` (line 1048-1050):
```c
if(!conn_config->verifypeer) {
rustls_client_config_builder_dangerous_set_certificate_verifier(
config_builder, cr_verify_none);
}
```
`cr_verify_none` (line 389) is a callback that returns `RUSTLS_RESULT_OK`
unconditionally. It replaces rustls's default `ServerCertVerifier`, which
in rustls performs both chain validation **and** hostname matching against
the SNI value passed to `rustls_client_connection_new`. Replacing it
disables both at once.
## Steps to reproduce
A self-contained Docker reproduction package is attached
(`verifyhost-repro.tar.gz`). It pulls the latest curl source from
`github.com/curl/curl.git`, builds four libcurls from the same source tree
(OpenSSL, mbedTLS, wolfSSL, rustls backends), runs a TLS server presenting
a wrong-CN certificate, and drives a libcurl client at all four backends.
```sh
tar xzf verifyhost-repro.tar.gz
cd docker
./build_and_run.sh
```
(Pass a tag/branch/SHA as arg to pin: `./build_and_run.sh curl-8_21_0`.)
Expected output:
```
================================================================
curl source commit: <SHA from official curl/curl repo>
OpenSSL curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV OpenSSL/3.5.6 ...
mbedTLS curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV mbedTLS/3.6.5 ...
wolfSSL curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV wolfSSL/5.7.2 ...
rustls curl --version: curl 8.21.1-DEV libcurl/8.21.1-DEV rustls-ffi/0.15.3 ...
================================================================
============== OpenSSL backend (control) — expect rc=60 ==============
* SSL: certificate subject name 'wrong.example.com' does not match target hostname 'target.example.com'
=== RESULT: rc=60 (SSL peer certificate or SSH remote key was not OK) ===
============== mbedTLS backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
< Content-Length: 7
=== RESULT: rc=0 (No error) ===
============== wolfSSL backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
=== RESULT: rc=0 (No error) ===
============== rustls backend — expect rc=0 (BUG) ==============
< HTTP/1.1 200 OK
=== RESULT: rc=0 (No error) ===
```
The OpenSSL run is the control: same source tree, same flags, same PoC
code — only the TLS backend differs. Its `rc=60` confirms that the PoC
correctly enforces `verifyhost=2` and that the `rc=0` on the other three
backends is a backend defect, not a PoC mistake.
## Files in the package
- `Dockerfile` — Debian trixie + mbedTLS 3.6.5 + OpenSSL 3.x + wolfSSL 5.7.2 + rustls-ffi 0.15.3
- `build_and_run.sh` — wrapper: `docker build` then `docker run`
- `run.sh` — entrypoint inside container; drives all four scenarios
- `poc.c` — minimal libcurl PoC
- `poc_server.py` — Python TLS server presenting CN=wrong.example.com
## Credits
Reported by: b1gtang on hackerone (SecBuddyF @ Tencent KeenLab)
## Impact
## Summary:
An MITM on the network path can fully impersonate the intended HTTPS
server using any certificate (no need to match the requested hostname),
decrypting and modifying the entire TLS session. The application has
opted out of chain verification but explicitly kept hostname verification
on per the documented contract; libcurl silently disables both.
Basic Information
ID
H1:3826199
Published
Jun 26, 2026 at 08:40
Modified
Jun 26, 2026 at 14:34