CVE-2026-9545: exposing HTTP/3 early data
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
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.
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
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.
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.
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().
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.
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)
Source code
| 1598 | static 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 | |
| 1726 | out: |
| 1727 | curlx_free(nva); |
| 1728 | Curl_dynhds_free(&h2_headers); |
| 1729 | return result; |
| 1730 | } |
| 1731 | |
| 1732 | static 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 | |
| 1818 | out: |
| 1819 | result = Curl_1st_fatal(result, check_and_set_expiry(cf, data, &pktx)); |
| 1820 | denied: |
| 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 | |
| 1827 | struct cf_ngtcp2_recv_ctx { |
| 1828 | struct pkt_io_ctx *pktx; |
| 1829 | size_t pkt_count; |
| 1830 | }; |
| 1831 | |
| 1832 | static 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 | } |
| 2536 | static 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 | } |
Show 26 lines (not cited by the judge)
| 367 | CURLcode 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 | } |
Show 28 lines (not cited by the judge)
| 218 | static CURLcode cf_progress_ingress(struct Curl_cfilter *cf, |
| 219 | struct Curl_easy *data, |
| 220 | struct pkt_io_ctx *pktx); |
| 221 | static 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 | */ |
| 228 | struct 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 | }; |
Show 1 lines (not cited by the judge)
| 1079 | #define DEBUGASSERT(x) CURL_DEBUGASSERT(x) |