✓ProveIt
Examples / Report #3752888 Saved analysis

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

curl · 2026-05-2115.6s analysis · $0.022 in model calls

What the report claims

The report claims that curl's HTTP/3 early data implementation incorrectly sends request data before TLS handshake verification completes when reusing a session that supports early data, leading to potential disclosure of sensitive request material to an attacker controlling the server certificate.

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2026-9545 exposing HTTP/3 early data

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Code supports the claim

The code supports the reported flaw. A maintainer should review its impact and decide what to do next.

Why this finding?
  • cf_ngtcp2_send() only checks ctx->tls_vrfy_result (pre-existing error) but not ctx->tls_handshake_complete before sending data
  • cf_ngtcp2_on_session_reuse() sets cf->connected=TRUE and ctx->use_earlydata=TRUE before the new handshake completes
  • Lines 1812-1813 explicitly track earlydata_skip when !tls_handshake_complete && use_earlydata, confirming data is sent before verification
  • The PoC demonstrates real exploit: Authorization header sent as early data to attacker-controlled server before certificate verification fails
  • This violates the security expectation that early data (0-RTT) is only safe when resuming a session with the same authenticated server
How this analysis ran 5 claims · 16 citations verified

Checked against curl permalink f2692b54f74b (f2692b54f74b). Test evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
grounded
no model · 11 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 3604 tokens · $0.0003
3 · Evidence
5 code snippets
real curl source at f2692b54f74b · 6 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
16818 tokens · 11.3s · $0.0221
5 · Receipts
16 of 16 citations verified
every citation must point at code the judge was shown
Review a draft reply to the reporter

Edit and verify before sending.

Thank you for the detailed report and PoC. The code confirms that cf_ngtcp2_send() sends early data (including request headers/body) before the new TLS handshake completes and peer verification passes. The session reuse path sets cf->connected=TRUE and use_earlydata=TRUE prematurely. This allows a MITM to capture sensitive request data when the server certificate changes. We will investigate a fix requiring tls_handshake_complete before flushing early data.

Inspect all claims and source code 5 claims · 5 code excerpts

Report claims

C1flaw supported

cf_ngtcp2_send() does not require successful peer verification before opening an HTTP/3 stream, buffering request bytes, or flushing egress

Read the reasoning

The code in cf_ngtcp2_send() only rejects sending if ctx->tls_vrfy_result is already set (a prior verification failure). It does not check ctx->tls_handshake_complete before opening streams, buffering request data, or flushing egress. Lines 1812-1813 explicitly acknowledge that data is written when the handshake is not complete and early data is enabled.

C2location supported

in curl_ngtcp2.c function cf_ngtcp2_send() around lines 1732-1824

Read the reasoning

The function cf_ngtcp2_send is defined at lines 1732-1825 in lib/vquic/curl_ngtcp2.c, matching the report's location claim.

C3trigger supported

reusing an ngtcp2 HTTP/3 session that advertises early data support after the server's certificate changes

Read the reasoning

cf_ngtcp2_on_session_reuse() initializes the HTTP/3 connection (init_ngh3_conn), sets use_earlydata=TRUE, and marks the filter as connected (cf->connected=TRUE) at lines 2581-2584, all before the new QUIC/TLS handshake completes. This enables the early data path in cf_ngtcp2_send().

C4impact supported

sensitive request material such as cookies, bearer tokens, or request body can be disclosed to an attacker

Read the reasoning

The PoC demonstrates that the Authorization header and request body are sent as early data (visible in server log) before certificate verification fails. The code path in cf_ngtcp2_send() processes headers via h3_stream_open() and writes request bytes to the send buffer, which are then flushed via cf_progress_egress() before handshake completion.

C5reproduction supported

perform a valid HTTP/3 request to cache an H3 TLS session, restart the server with a distinct ticket key and evil.example.com certificate, then perform a second request that triggers early data send before verification

Read the reasoning

The PoC steps align with the code behavior: first request caches session with early data; second request reuses session, triggering cf_ngtcp2_on_session_reuse which sets use_earlydata and connected; cf_ngtcp2_send then sends request data (including Authorization header) before the new handshake completes and verification fails.

Read the original report (excerpt)

Full report on HackerOne ↗

## 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. ```c /* 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 ```bash 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 `htt …

Source code

lib/vquic/curl_ngtcp2.clines 1598–1849 · lines 1732-1824 cited by the report; definition of h3_stream_open
1598static CURLcode h3_stream_open(struct Curl_cfilter *cf,
1599 struct Curl_easy *data,
1600 const uint8_t *buf, size_t len,
1601 size_t *pnwritten)
1602{
1603 struct cf_ngtcp2_ctx *ctx = cf->ctx;
1604 struct h3_stream_ctx *stream = NULL;
1605 int64_t sid;
1606 struct dynhds h2_headers;
1607 size_t nheader;
1608 nghttp3_nv *nva = NULL;
1609 int rc = 0;
1610 unsigned int i;
1611 nghttp3_data_reader reader;
1612 nghttp3_data_reader *preader = NULL;
1613 CURLcode result;
1614
1615 *pnwritten = 0;
1616 Curl_dynhds_init(&h2_headers, 0, DYN_HTTP_REQUEST);
1617
1618 result = h3_data_setup(cf, data);
1619 if(result)
1620 goto out;
1621 stream = H3_STREAM_CTX(ctx, data);
1622 DEBUGASSERT(stream);
1623 if(!stream) {
1624 result = CURLE_FAILED_INIT;
1625 goto out;
1626 }
1627
1628 result = Curl_h1_req_parse_read(&stream->h1, buf, len, NULL,
1629 !data->state.http_ignorecustom ?
1630 data->set.str[STRING_CUSTOMREQUEST] : NULL,
1631 0, pnwritten);
1632 if(result)
1633 goto out;
1634 if(!stream->h1.done) {
1635 /* need more data */
1636 goto out;
1637 }
1638 DEBUGASSERT(stream->h1.req);
1639
1640 result = Curl_http_req_to_h2(&h2_headers, stream->h1.req, data);
1641 if(result)
1642 goto out;
1643
1644 /* no longer needed */
1645 Curl_h1_req_parse_free(&stream->h1);
1646
1647 nheader = Curl_dynhds_count(&h2_headers);
1648 nva = curlx_malloc(sizeof(nghttp3_nv) * nheader);
1649 if(!nva) {
1650 result = CURLE_OUT_OF_MEMORY;
1651 goto out;
1652 }
1653
1654 for(i = 0; i < nheader; ++i) {
1655 struct dynhds_entry *e = Curl_dynhds_getn(&h2_headers, i);
1656 nva[i].name = (unsigned char *)e->name;
1657 nva[i].namelen = e->namelen;
1658 nva[i].value = (unsigned char *)e->value;
1659 nva[i].valuelen = e->valuelen;
1660 nva[i].flags = NGHTTP3_NV_FLAG_NONE;
1661 }
1662
1663 rc = ngtcp2_conn_open_bidi_stream(ctx->qconn, &sid, data);
1664 if(rc) {
1665 failf(data, "can get bidi streams");
1666 result = CURLE_SEND_ERROR;
1667 goto out;
1668 }
1669 stream->id = sid;
1670 ++ctx->used_bidi_streams;
1671
1672 switch(data->state.httpreq) {
1673 case HTTPREQ_POST:
1674 case HTTPREQ_POST_FORM:
1675 case HTTPREQ_POST_MIME:
1676 case HTTPREQ_PUT:
1677 /* known request body size or -1 */
1678 if(data->state.infilesize != -1)
1679 stream->upload_left = data->state.infilesize;
1680 else
1681 /* data sending without specifying the data amount up front */
1682 stream->upload_left = -1; /* unknown */
1683 break;
1684 default:
1685 /* there is not request body */
1686 stream->upload_left = 0; /* no request body */
1687 break;
1688 }
1689
1690 stream->send_closed = (stream->upload_left == 0);
1691 if(!stream->send_closed) {
1692 reader.read_data = cb_h3_read_req_body;
1693 preader = &reader;
1694 }
1695
1696 rc = nghttp3_conn_submit_request(ctx->h3conn, stream->id,
1697 nva, nheader, preader, data);
1698 if(rc) {
1699 switch(rc) {
1700 case NGHTTP3_ERR_CONN_CLOSING:
1701 CURL_TRC_CF(data, cf, "h3sid[%" PRId64 "] failed to send, "
1702 "connection is closing", stream->id);
1703 break;
1704 default:
1705 CURL_TRC_CF(data, cf, "h3sid[%" PRId64 "] failed to send -> "
1706 "%d (%s)", stream->id, rc, nghttp3_strerror(rc));
1707 break;
1708 }
1709 cf_ngtcp2_stream_close(cf, data, stream);
1710 result = CURLE_SEND_ERROR;
1711 goto out;
1712 }
1713
1714 cf_ngtcp2_upd_rx_win(cf, data, stream);
1715
1716 if(Curl_trc_is_verbose(data)) {
1717 infof(data, "[HTTP/3] [%" PRId64 "] OPENED stream for %s",
1718 stream->id, Curl_bufref_ptr(&data->state.url));
1719 for(i = 0; i < nheader; ++i) {
1720 infof(data, "[HTTP/3] [%" PRId64 "] [%.*s: %.*s]", stream->id,
1721 (int)nva[i].namelen, nva[i].name,
1722 (int)nva[i].valuelen, nva[i].value);
1723 }
1724 }
1725
1726out:
1727 curlx_free(nva);
1728 Curl_dynhds_free(&h2_headers);
1729 return result;
1730}
1731
1732static CURLcode cf_ngtcp2_send(struct Curl_cfilter *cf, struct Curl_easy *data,
C2: cf_ngtcp2_send function definition starts at line 1732
1733 const uint8_t *buf, size_t len, bool eos,
1734 size_t *pnwritten)
1735{
1736 struct cf_ngtcp2_ctx *ctx = cf->ctx;
1737 struct h3_stream_ctx *stream = H3_STREAM_CTX(ctx, data);
1738 struct cf_call_data save;
1739 struct pkt_io_ctx pktx;
1740 CURLcode result = CURLE_OK;
1741
1742 CF_DATA_SAVE(save, cf, data);
1743 DEBUGASSERT(cf->connected);
1744 DEBUGASSERT(ctx->qconn);
1745 DEBUGASSERT(ctx->h3conn);
1746 pktx_init(&pktx, cf, data);
1747 *pnwritten = 0;
1748
1749 /* handshake verification failed in callback, do not send anything */
1750 if(ctx->tls_vrfy_result) {
C1: Only checks ctx->tls_vrfy_result (pre-existing verification error), not ctx->tls_handshake_complete
C5: Only tls_vrfy_result checked, not tls_handshake_complete
1751 result = ctx->tls_vrfy_result;
1752 goto denied;
1753 }
1754
1755 (void)eos; /* use for stream EOF and block handling */
1756 result = cf_progress_ingress(cf, data, &pktx);
1757 if(result)
1758 goto out;
1759
1760 if(!stream || stream->id < 0) {
C1: Opens HTTP/3 stream via h3_stream_open() without requiring tls_handshake_complete
1761 if(ctx->shutdown_started) {
1762 CURL_TRC_CF(data, cf, "cannot open stream on closed connection");
1763 result = CURLE_SEND_ERROR;
1764 goto out;
1765 }
1766 result = h3_stream_open(cf, data, buf, len, pnwritten);
C4: h3_stream_open() processes request headers including Authorization header
1767 if(result) {
1768 CURL_TRC_CF(data, cf, "failed to open stream -> %d", result);
1769 goto out;
1770 }
1771 VERBOSE(stream = H3_STREAM_CTX(ctx, data));
1772 }
1773 else if(stream->xfer_result) {
1774 CURL_TRC_CF(data, cf, "[%" PRId64 "] xfer write failed", stream->id);
1775 cf_ngtcp2_stream_close(cf, data, stream);
1776 result = stream->xfer_result;
1777 goto out;
1778 }
1779 else if(stream->closed) {
1780 if(stream->resp_hds_complete) {
1781 /* Server decided to close the stream after having sent us a final
1782 * response. This is valid if it is not interested in the request
1783 * body. This happens on 30x or 40x responses.
1784 * We silently discard the data sent, since this is not a transport
1785 * error situation. */
1786 CURL_TRC_CF(data, cf, "[%" PRId64 "] discarding data"
1787 "on closed stream with response", stream->id);
1788 result = CURLE_OK;
1789 *pnwritten = len;
1790 goto out;
1791 }
1792 CURL_TRC_CF(data, cf, "[%" PRId64 "] send_body(len=%zu) "
1793 "-> stream closed", stream->id, len);
1794 result = CURLE_HTTP3;
1795 goto out;
1796 }
1797 else if(ctx->shutdown_started) {
1798 CURL_TRC_CF(data, cf, "cannot send on closed connection");
1799 result = CURLE_SEND_ERROR;
1800 goto out;
1801 }
1802 else {
1803 result = Curl_bufq_write(&stream->sendbuf, buf, len, pnwritten);
C1: Writes request bytes to stream buffer via Curl_bufq_write() without handshake completion check
C4: Request body and headers written to send buffer via Curl_bufq_write()
1804 CURL_TRC_CF(data, cf, "[%" PRId64 "] cf_send, add to "
1805 "sendbuf(len=%zu) -> %d, %zu",
1806 stream->id, len, result, *pnwritten);
1807 if(result)
1808 goto out;
1809 (void)nghttp3_conn_resume_stream(ctx->h3conn, stream->id);
1810 }
1811
1812 if(*pnwritten > 0 && !ctx->tls_handshake_complete && ctx->use_earlydata)
C1: Explicitly tracks earlydata_skip when !ctx->tls_handshake_complete && ctx->use_earlydata, confirming data is sent before handshake completes
C4: Early data including request bytes tracked in earlydata_skip before handshake completes
C5: Early data sent when !tls_handshake_complete && use_earlydata
1813 ctx->earlydata_skip += *pnwritten;
1814
1815 DEBUGASSERT(!result);
1816 result = cf_progress_egress(cf, data, &pktx);
C1: Calls cf_progress_egress() to flush egress without requiring successful peer verification
1817
1818out:
1819 result = Curl_1st_fatal(result, check_and_set_expiry(cf, data, &pktx));
1820denied:
1821 CURL_TRC_CF(data, cf, "[%" PRId64 "] cf_send(len=%zu) -> %d, %zu",
1822 stream ? stream->id : -1, len, result, *pnwritten);
1823 CF_DATA_RESTORE(cf, save);
1824 return result;
1825}
C2: cf_ngtcp2_send function ends at line 1825
1826
1827struct cf_ngtcp2_recv_ctx {
1828 struct pkt_io_ctx *pktx;
1829 size_t pkt_count;
1830};
1831
1832static CURLcode cf_ngtcp2_recv_pkts(const unsigned char *buf, size_t buflen,
1833 size_t gso_size,
1834 struct sockaddr_storage *remote_addr,
1835 socklen_t remote_addrlen, int ecn,
1836 void *userp)
1837{
1838 struct cf_ngtcp2_recv_ctx *rctx = userp;
1839 struct pkt_io_ctx *pktx = rctx->pktx;
1840 struct cf_ngtcp2_ctx *ctx = pktx->cf->ctx;
1841 ngtcp2_pkt_info pi;
1842 ngtcp2_path path;
1843 size_t offset, pktlen;
1844 int rv;
1845
1846 if(!rctx->pkt_count) {
1847 pktx_update_time(pktx->data, pktx, pktx->cf);
1848 ngtcp2_path_storage_zero(&pktx->ps);
1849 }
lib/vquic/curl_ngtcp2.clines 2536–2596 · definition of cf_ngtcp2_on_session_reuse
2536static CURLcode cf_ngtcp2_on_session_reuse(struct Curl_cfilter *cf,
2537 struct Curl_easy *data,
2538 struct alpn_spec *alpns,
2539 struct Curl_ssl_session *scs,
2540 bool *do_early_data)
2541{
2542 struct cf_ngtcp2_ctx *ctx = cf->ctx;
2543 CURLcode result = CURLE_OK;
2544
2545 *do_early_data = FALSE;
2546#if defined(USE_OPENSSL) && defined(HAVE_OPENSSL_EARLYDATA)
2547 ctx->earlydata_max = scs->earlydata_max;
2548#endif
2549#ifdef USE_GNUTLS
2550 ctx->earlydata_max =
2551 gnutls_record_get_max_early_data_size(ctx->tls.gtls.session);
2552#endif
2553#ifdef USE_WOLFSSL
2554#ifdef WOLFSSL_EARLY_DATA
2555 ctx->earlydata_max = scs->earlydata_max;
2556#else
2557 ctx->earlydata_max = 0;
2558#endif /* WOLFSSL_EARLY_DATA */
2559#endif
2560#if defined(USE_GNUTLS) || defined(USE_WOLFSSL) || \
2561 (defined(USE_OPENSSL) && defined(HAVE_OPENSSL_EARLYDATA))
2562 if(!ctx->earlydata_max) {
2563 CURL_TRC_CF(data, cf, "SSL session does not allow earlydata");
2564 }
2565 else if(!Curl_alpn_contains_proto(alpns, scs->alpn)) {
2566 CURL_TRC_CF(data, cf, "SSL session from different ALPN, no early data");
2567 }
2568 else if(!scs->quic_tp || !scs->quic_tp_len) {
2569 CURL_TRC_CF(data, cf, "no 0RTT transport parameters, no early data, ");
2570 }
2571 else {
2572 int rv;
2573 rv = ngtcp2_conn_decode_and_set_0rtt_transport_params(
2574 ctx->qconn, (const uint8_t *)scs->quic_tp, scs->quic_tp_len);
2575 if(rv)
2576 CURL_TRC_CF(data, cf, "no early data, failed to set 0RTT transport "
2577 "parameters: %s", ngtcp2_strerror(rv));
2578 else {
2579 infof(data, "SSL session allows %zu bytes of early data, "
2580 "reusing ALPN '%s'", ctx->earlydata_max, scs->alpn);
2581 result = init_ngh3_conn(cf, data);
C3: init_ngh3_conn() called during session reuse, initializing HTTP/3 connection state
2582 if(!result) {
2583 ctx->use_earlydata = TRUE;
C3: ctx->use_earlydata = TRUE set during session reuse
C5: Session reuse sets use_earlydata=TRUE enabling early data path
2584 cf->connected = TRUE;
C3: cf->connected = TRUE set during session reuse, before new handshake completes
2585 *do_early_data = TRUE;
2586 }
2587 }
2588 }
2589#else /* not supported in the TLS backend */
2590 (void)data;
2591 (void)ctx;
2592 (void)scs;
2593 (void)alpns;
2594#endif
2595 return result;
2596}
lib/bufq.clines 367–392 · definition of Curl_bufq_write
Show 26 lines (not cited by the judge)
367CURLcode Curl_bufq_write(struct bufq *q,
368 const uint8_t *buf, size_t len,
369 size_t *pnwritten)
370{
371 struct buf_chunk *tail;
372 size_t n;
373
374 DEBUGASSERT(q->max_chunks > 0);
375 *pnwritten = 0;
376 while(len) {
377 tail = get_non_full_tail(q);
378 if(!tail) {
379 if((q->chunk_count < q->max_chunks) || (q->opts & BUFQ_OPT_SOFT_LIMIT))
380 /* should have gotten a tail, but did not */
381 return CURLE_OUT_OF_MEMORY;
382 break;
383 }
384 n = chunk_append(tail, buf, len);
385 if(!n)
386 break;
387 *pnwritten += n;
388 buf += n;
389 len -= n;
390 }
391 return (!*pnwritten && len) ? CURLE_AGAIN : CURLE_OK;
392}
lib/vquic/curl_ngtcp2.clines 218–245 · definition of cf_progress_ingress
Show 28 lines (not cited by the judge)
218static CURLcode cf_progress_ingress(struct Curl_cfilter *cf,
219 struct Curl_easy *data,
220 struct pkt_io_ctx *pktx);
221static CURLcode cf_progress_egress(struct Curl_cfilter *cf,
222 struct Curl_easy *data,
223 struct pkt_io_ctx *pktx);
224
225/**
226 * All about the H3 internals of a stream
227 */
228struct h3_stream_ctx {
229 int64_t id; /* HTTP/3 protocol identifier */
230 struct bufq sendbuf; /* h3 request body */
231 struct h1_req_parser h1; /* h1 request parsing */
232 size_t sendbuf_len_in_flight; /* sendbuf amount "in flight" */
233 uint64_t error3; /* HTTP/3 stream error code */
234 curl_off_t upload_left; /* number of request bytes left to upload */
235 uint64_t rx_offset; /* current receive offset */
236 uint64_t rx_offset_max; /* allowed receive offset */
237 uint64_t window_size_max; /* max flow control window set for stream */
238 int status_code; /* HTTP status code */
239 CURLcode xfer_result; /* result from xfer_resp_write(_hd) */
240 BIT(resp_hds_complete); /* we have a complete, final response */
241 BIT(closed); /* TRUE on stream close */
242 BIT(reset); /* TRUE on stream reset */
243 BIT(send_closed); /* stream is local closed */
244 BIT(quic_flow_blocked); /* stream is blocked by QUIC flow control */
245};
lib/curl_setup.hlines 1079–1079 · definition of DEBUGASSERT
Show 1 lines (not cited by the judge)
1079#define DEBUGASSERT(x) CURL_DEBUGASSERT(x)