HexBrain

curl · Report #4123050

CVE-2026-11586 fix is incomplete: auto-PONG memory grows without limit through curl_ws_recv() (CONNECT_ONLY)

Informativestatus
lowseverity
6upvotes
21 Oct 2026disclosed

How it was found

Summary:

The CVE-2026-11586 fix (849317ff5c, "ws: make pong sending lazy") adds the automatic PONG only when the WebSocket send buffer has room, but it put that check in ws_flush() (lib/ws.c:1712). curl_ws_recv(), the receive call of the documented CONNECT_ONLY model, still calls ws_enc_add_pending() without it at the end of every call that returns a frame (lib/ws.c:1694-1698 in 8.23.0, unchanged in master). Once the server stops reading and the 128 KB send buffer is full, that call appends the 6-byte PONG for an empty PING (ws.c:973) and returns CURLE_AGAIN before it clears ws->pending, because ws_enc_write_payload() refuses even a 0-byte payload when the buffer is full (ws.c:1010), so the next call appends the same PONG again. From then on every frame the application receives adds 6 bytes that are neither sent nor freed while the server does not read, even if the server sends no more PINGs, which is the unbounded growth CVE-2026-11586 describes, reached through the other documented API. Against the same server, a CONNECT_ONLY client with default options kept growing until the end of every run on 8.23.0 and master on Linux, and on 8.23.0 and 8.21.0 natively on macOS (about 345 MB of heap in 30 seconds), while CURLWS_NOAUTOPONG, the 8.23.0 curl tool (callback mode, which the fix covers) and 8.23.0 with the fix below stayed flat. The call in curl_ws_recv() came with 0b09132877, the advisory's Introduced-in commit, and the fix's test (test_20_11_crazy_pings) runs the curl tool, so only the callback path was checked.

Affected version

8.21.0 to 8.23.0 and master, which is every release with the CVE-2026-11586 fix. ws_enc_add_pending(), ws_enc_add_frame() and ws_enc_write_payload() are byte-identical in 8.21.0, 8.22.0 and 8.23.0, and all three end curl_ws_recv() with the unguarded call (39b06935be added !ws->using_reader to its condition in 8.23.0, but using_reader is never set in CONNECT_ONLY mode, ws.c:1469-1486). lib/ws.c and lib/bufq.c are unchanged from 8.23.0 to today's master 59d2537b40. Earlier versions are covered by CVE-2026-11586.

Tested with release builds (no sanitizers): on Linux, curl-8_23_0, curl-8_23_0 with the fix below, and master 629646d9b7 (same lib/ws.c and lib/bufq.c as 59d2537b40), with OpenSSL in a Debian 12 container over TCP on 127.0.0.1 (amd64 under emulation on an Apple silicon Mac); natively on macOS 27.0.1, curl-8_23_0 and curl-8_21_0 over a Unix socket.

curl 8.23.0-DEV (Linux) libcurl/8.23.0-DEV OpenSSL/3.0.22 zlib/1.2.13
curl 8.23.1-DEV (Linux) libcurl/8.23.1-DEV OpenSSL/3.0.22 zlib/1.2.13   (master 629646d9b7)
curl 8.23.0-DEV (Darwin) libcurl/8.23.0-DEV OpenSSL/3.6.4 zlib/1.2.12
curl 8.21.0-DEV (Darwin) libcurl/8.21.0-DEV zlib/1.2.12 mit-krb5/1.22.2

Steps To Reproduce:

Linux with glibc 2.33 or later (the client uses mallinfo2()), everything on 127.0.0.1, files from the attached zip; linux/*.sh have the exact commands behind the numbers below.

  1. Build a static libcurl without optional dependencies, so the client links with OpenSSL and zlib only: cmake -S curl -B build -DCMAKE_BUILD_TYPE=Release -DCURL_USE_OPENSSL=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF -DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DCURL_USE_LIBPSL=OFF -DUSE_LIBIDN2=OFF -DCURL_USE_LIBSSH2=OFF -DUSE_NGHTTP2=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF -DCURL_DISABLE_LDAP=ON && cmake --build build
  2. cc -O2 poc/ws_recv_client.c -Icurl/include build/lib/libcurl.a -lssl -lcrypto -lz -lpthread -o ws_recv_client. A plain CONNECT_ONLY client: curl_ws_recv() in a loop with poll() on CURLE_AGAIN, as curl_ws_recv(3) says. It never sends anything itself and prints RSS and heap in use every second.
  3. python3 poc/ws_noread_server.py 25001 185 &. The server completes the handshake, never reads again, and sends empty PING and empty TEXT frames.
  4. ./ws_recv_client ws://127.0.0.1:25001/ 180. At first the PONGs only fill the TCP send path (about 30 seconds in my emulated container); after that heap and RSS grow by about 6 bytes per frame until the end.
  5. Controls, each against a fresh server: ./ws_recv_client ws://127.0.0.1:25002/ 150 noautopong and the curl tool, build/src/curl -s -o /dev/null ws://127.0.0.1:25003/. Both stay flat.

On macOS the same two files work over a Unix socket, where the growth starts at once: python3 poc/ws_noread_server.py /tmp/ws.sock 35 & and UNIX_SOCKET=/tmp/ws.sock ./ws_recv_client ws://localhost/ 30 (link line in macos/run_macos.sh).

Linux, TCP, same server:

runframes returnedheap in useRSSgrowth per frame
8.23.0, default options, 180 s14.2 million0.4 to 79.7 MB17.8 to 98.8 MB6.0 bytes
master, default options, 150 s12.3 million0.4 to 68.5 MB17.6 to 87.2 MB6.0 bytes
8.23.0, only empty TEXT frames after 40 s, 210 s10.4 million0.4 to 52.6 MB17.5 to 70.7 MB6.0 bytes per 2-byte frame
8.23.0, CURLWS_NOAUTOPONG, 150 s250.9 million (PINGs included)0.3 to 8.6 MB, no trend16.8 to 25.5 MB, flat after 68 snone
8.23.0 curl tool, 150 s18.1 to 18.3 MBnone
8.23.0 with the fix below, 150 s24.3 million0.4 to 10.2 MB, no trend16.6 to 26.5 MB, flat after 34 snone

Natively on macOS (Unix socket, 30 s each, about 1.6 million frames per second), 8.23.0 went from 0.7 to 343.4 MB of heap and 8.21.0 from 0.3 to 346.0 MB, while CURLWS_NOAUTOPONG stayed at 0.6 MB and the curl tool peaked at a 2.9 MB footprint; there it is 7.5 bytes of heap per frame, because malloc rounds each 65,575-byte buffer chunk up to 80 KB. In every Linux run, the controls included, heap in use also jumps by up to about 10 MB and falls back, because curl_ws_recv() reads the socket backlog into its receive buffer in one call; the growth per frame is measured between those low points (README.txt).

Raw output in evidence/: 01b-linux-tcp-long.txt (8.23.0, master), 05-linux-tcp-text-only.txt, 04-linux-tcp-controls-150s.txt (CURLWS_NOAUTOPONG, curl tool), 02-fix-check.txt (fix) and 03-macos-unix-socket.txt (macOS).

Fix

In curl_ws_recv(), call ws_flush() instead of ws_enc_add_pending(): ws_flush() adds the pending PONG only when the send buffer has room, as the CVE-2026-11586 fix intended (fix/ws-recv-pong-guard.diff applies to 8.23.0 and master; 02-fix-check.txt is 8.23.0 with it).

AI disclosure: I used an AI assistant (Claude) for code review, test setup and drafting. I reproduced every result myself on curl built from the curl-8_23_0 and curl-8_21_0 tags and from master, on Linux and macOS; the attached logs are the raw output.

Impact

A malicious WebSocket server can make any libcurl application that uses the documented CONNECT_ONLY model (curl_ws_recv()) with default options keep allocating memory, without limit, for as long as the connection is open, which is what the CVE-2026-11586 fix was meant to stop. The server completes a normal handshake, stops reading and sends small frames: once the TCP send path is full, every frame the application receives costs 6 bytes that stay allocated, three times the 2 bytes of an empty TEXT frame, and after the first stuck PONG the server does not need to send any more PINGs. The growth only depends on how many frames the server sends and the application reads; natively on macOS that was about 345 MB in 30 seconds. In #3788931 jimfuller2024 named long-lived libcurl applications with WebSocket connections to untrusted peers, such as services that proxy or aggregate WebSocket feeds, as the main concern; when they use curl_ws_recv(), the fix does not protect them. The curl tool is not affected because it uses the callback model that the fix covers, and CURLWS_NOAUTOPONG avoids the problem.

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