curl · Report #4099194
CVE-2026-107928: info leak uploading over WebSockets with GnuTLS
How it was found
Summary:
When curl is built with GnuTLS, a malicious WebSocket server can make curl -T <file> wss://... send memory past curl's WebSocket send buffer. This is due to an out of bounds read in gtls_send(), which doesn't handle if gnutls_record_send() returns a number of bytes sent greater than the number of bytes it was given to send. GnuTLS does that after a blocked send. It takes two sends from different buffers to trigger, so I added an ASCII illustration of the control flow in the appendix to make it easier to follow.
GnuTLS is required for this bug, It doesn't work on OpenSSL. It also requires an upload of a few MB or more to a wss:// URL on the attacker's server. So I'm submitting this as a Low.
As for how it happens, the server first stops reading, so curl's upload blocks on a full socket. The last gnutls_record_send() has by then encrypted a 16384 byte record that the socket doesn't take. GnuTLS keeps it and returns GNUTLS_E_AGAIN, and those bytes stay in curl's request buffer to be passed in again. The next gnutls_record_send() call sends the kept record and returns its length (16384) whatever buffer and length it was given. Normally that call is for the same bytes so it's ok. But if the server sends a WebSocket PING, curl answers with a PONG and ws_flush() sends it straight from the WebSocket send buffer, ahead of the request buffer. So the next call is gnutls_record_send(PONG, 10) and it returns 16384. There is a check here in gtls_send() but it's only a DEBUGASSERT. On a release build it just adds 16384 to the buffer pointer, subtracts it from the 10 bytes left, which wraps around and keeps looping. So then gnutls_record_send() encrypts and sends the next 16384 bytes of memory after the PONG until the socket is full again. The server can control how much it reads based on how much it reads before the PING. If the read gets to the end of the heap, curl segfaults.
For applications using curl as a library, if they call curl_easy_send() with a shorter buffer after CURLE_AGAIN, the same thing can happen. For example curl_easy_send(buf, 5, &n) can return CURLE_OK with n getting 131072.
Affected version
8.22.0
Steps To Reproduce:
The attached evil-server.py is a malicious server that triggers the bug. The script below should also reproduce the bug
wget 'https://curl.se/download/curl-8.22.0.tar.gz'
sha1sum curl-8.22.0.tar.gz # should be 284e444b67f676a281906cfc86e9672780555cd4
tar xf curl-8.22.0.tar.gz
mkdir build ; cd build
# build with GnuTLS instead of OpenSSL
CC=clang CFLAGS="-O2 -g" LDFLAGS="-Wl,-rpath,/opt/curl-gnutls/lib" \
../curl-8.22.0/configure --prefix=/opt/curl-gnutls \
--enable-static --enable-shared --disable-docs --disable-manual \
--with-gnutls --without-openssl --with-zlib --with-brotli --with-zstd --with-libidn2 \
--with-nghttp2 --with-libpsl --with-libssh2 --with-gssapi \
--enable-ldap --enable-ldaps --enable-websockets --enable-mqtt --enable-ipfs \
--enable-ntlm --enable-smb --enable-httpsig --enable-httpsrr --enable-ssls-export \
&& make -j"$(nproc)"
# a 32 MB file of one repeated byte, so that anything else the server receives is easy to spot
head -c 33554432 /dev/zero | tr '\0' A > As.bin
# start the malicious server, see attached evil-server.py (put it in this directory)
python3 evil-server.py & sleep 1
# we use --insecure because the server's cert is self signed, and -sS to keep the progress meter out of
# the server's output. Neither is necessary to trigger the bug.
./src/curl --insecure -sS -T As.bin wss://127.0.0.1:19239/
The PoC server looks at what it receives and if it prints if it gets something it wasn't supposed to (not part of the uploaded file).
[srv] upgraded to WebSocket, not reading, so that curl's send blocks
[srv] reading 131072 bytes, then sending PING number 151
[srv] PING number 151: 2359296 bytes into the upload the stream stops being WebSocket frames of 0x41
[srv] LEAK: at least 65536 bytes of curl's memory arrived instead
[srv] it starts with old data from curl's WebSocket send buffer; 49152 bytes further on comes this:
+00c000 00 00 00 00 00 00 00 31 00 01 00 00 00 00 00 00 .......1........
+00c010 00 00 00 00 00 00 00 ff ff 00 00 00 00 00 00 00 ................
+00c020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 89 ................
+00c030 04 70 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 .ping...........
...
curl: (56) Recv failure: Connection reset by peer
(Here the send buffer's 64 KB heap chunk ends at +00c000. So after that we can see the leaked malloc header and the struct buf_chunk of the next allocation, which holds the PING the server sent. With -v --trace-config ssl, curl shows the send as gtls_send(len=1304) -> 0, 114688)
I also attached a .tar.gz with a Dockerfile that builds a reproducer container.
Appendix
PHASE 1: upload runs, server isn't reading
Curl_sendrecv() transfer.c:354
└─ sendrecv_ul() -> Curl_req_send_more() transfer.c:375 -> request.c:458
├─ add_from_client() -> ... -> cr_ws_read() request.c:351 -> ws.c:1202 next 64 KB of the file
│ ├─ ws_enc_write_head(), ws_enc_write_payload() ws.c:1259, 1265 -> ws->sendbuf, as one masked BINARY frame
│ └─ Curl_bufq_cread(&ws->sendbuf, buf, blen) ws.c:1274 frame bytes -> req.sendbuf (65536 bytes)
└─ req_flush() -> req_send_buffer_flush() request.c:298 -> request.c:243
├─ xfer_send() -> Curl_xfer_send() request.c:251 -> transfer.c:806
│ └─ ... ssl_cf_send() vtls.c:1113
│ └─ gtls_send(buf=req.sendbuf, blen=65536) vtls.c:1156 -> gtls.c:2072
│ ├─ gnutls_record_send(buf, 65536) gtls.c:2090 = 16384, record 1 sent
│ ├─ gnutls_record_send(buf+16384, 49152) = 16384, record 2 sent
│ ├─ gnutls_record_send(buf+32768, 32768) encrypts record 3 (16384 bytes)
│ │ └─ gtls_push() -> send() gtls.c:85 EAGAIN, the socket is full
│ │ = GNUTLS_E_AGAIN <- record 3 stays queued inside GnuTLS
│ └─ return CURLE_OK, *pnwritten = 32768 gtls.c:2099 record 3's bytes are not counted
└─ Curl_bufq_skip(&req.sendbuf, 32768) request.c:255 so they stay in req.sendbuf for the retry
PHASE 2: server sends a PING
Curl_sendrecv() transfer.c:354
└─ sendrecv_dl() -> Curl_xfer_write_resp() transfer.c:368 -> transfer.c:297
└─ ... ws_cw_write() ws.c:709
├─ ws_dec_pass() -> ws_cw_dec_next() ws.c:747 -> ws.c:662 a PING
│ └─ ws_enc_add_cntrl(CURLWS_PONG) ws.c:689 PONG -> ws->pending
└─ ws_flush() ws.c:770 -> ws.c:1701
├─ ws_enc_add_pending() ws.c:1708 PONG (10 bytes) -> ws->sendbuf <- the short buffer
├─ Curl_bufq_peek(&ws->sendbuf, &out, &outlen) ws.c:1728 outlen = 1304 (the PONGs that queued up while the socket was full)
└─ Curl_xfer_send(data, out, outlen) ws.c:1744 straight to the connection, req.sendbuf has to wait
└─ ... gtls_send(buf=out, blen=1304) gtls.c:2072 remain = 1304
├─ gnutls_record_send(out, 1304) gtls.c:2090 sends the queued record 3, doesn't look at out
│ = 16384 <- more than remain
├─ DEBUGASSERT(nwritten <= remain) gtls.c:2094 (not in release builds)
├─ buf = out + 16384 gtls.c:2095 <- past the end of what is in ws->sendbuf
├─ remain = 1304 - 16384 gtls.c:2096 <- wraps to 0xffffffffffffc518
├─ gnutls_record_send(out+16384, remain) gtls.c:2090 = 16384 <- OUT OF BOUNDS READ, encrypted and sent
├─ gnutls_record_send(out+32768, remain) = 16384 (the chunk out is in ends before out+65536)
├─ ...
├─ gnutls_record_send(out+114688, remain) = GNUTLS_E_AGAIN, the socket is full again
└─ return CURLE_OK, *pnwritten = 114688 gtls.c:2099
Record 3's bytes are given to GnuTLS in phase 1 and are meant to be offered again, from req.sendbuf, by the next call. In phase 2 the PONG gets there first, GnuTLS answers for record 3, and gtls_send() applies that answer to the PONG's buffer.
Thanks for taking the time to read this report.
Impact
Disclosure of client process memory to a malicious wss:// server, for libcurl built with GnuTLS (default on Debian and Ubuntu?), while uploading a file to that server. The server can control how much to read