CVE-2026-53423
Description
Membrane MP4 plugin converts each 4-byte box name to a permanent atom; a crafted 8MB file with ~1.1M distinct names exhausts the BEAM atom table, crashing the entire node.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Membrane MP4 plugin converts each 4-byte box name to a permanent atom; a crafted 8MB file with ~1.1M distinct names exhausts the BEAM atom table, crashing the entire node.
Vulnerability
The Membrane MP4 plugin (membrane_mp4_plugin) contains an allocation-of-resources vulnerability in the MP4 box header parser [1][3]. The function Membrane.MP4.Container.Header.parse_box_name/1 in lib/membrane_mp4/container/header.ex calls String.to_atom/1 on each 4-byte box name encountered while parsing an MP4 container [1][2][4]. BEAM atoms are never garbage-collected, so each unique attacker-controlled name becomes a permanent allocation. Affected versions are from 0.3.0 up to but not including 0.36.7 [1][3].
Exploitation
An unauthenticated attacker with the ability to supply a crafted MP4 file to any system using membrane_mp4_plugin can trigger the vulnerability [1][4]. No authentication or special privilege is required. A payload of approximately 8 MB containing roughly 1.1 million box headers with distinct 4-byte names (each header is 8 bytes: 4-byte size and 4-byte name) causes the parser to call String.to_atom/1 for each unique name [1][4]. Any code path that invokes Membrane.MP4.Container.parse/1 (or its bang variant) on the attacker-controlled bytes will consume a unique atom for every box [4].
Impact
A successful attack exhausts the BEAM atom table, whose default ceiling is around 1,048,576 atoms [1][3]. Since atoms are never reclaimed, the node reaches the limit permanently and aborts, terminating all Elixir/Erlang applications running on that BEAM instance [1][4]. The CIA outcome is a denial of service (availability loss) with no impact on confidentiality or integrity; the entire runtime crashes, taking down all hosted services [1].
Mitigation
The issue is fixed in version 0.36.7 [2][3]. The fix replaces String.to_atom/1 with String.to_existing_atom/1 and returns an :unknown_box error for unrecognized box names instead of creating a new atom [2][4]. Users should immediately update their dependency to {:membrane_mp4_plugin, "~> 0.36.7"} or later [2]. No workaround is available for older versions; upgrading is the sole recommended mitigation [4].
- Unauthenticated denial-of-service via BEAM atom table exhaustion in membrane_mp4_plugin
- Use String.to_exisiting_atom() instead of String.to_atom() and ignore… · membraneframework/membrane_mp4_plugin@56373d1
- OSV - Open Source Vulnerabilities
- Unauthenticated denial-of-service via BEAM atom table exhaustion in membrane_mp4_plugin
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=0.3.0, <0.36.7
Patches
156373d1ddc86Use String.to_exisiting_atom() instead of String.to_atom() and ignore box if it's name is not recognized. Release v0.36.7. (#135)
8 files changed · +84 −33
lib/membrane_mp4/container/header.ex+23 −8 modified@@ -25,7 +25,10 @@ defmodule Membrane.MP4.Container.Header do Returns the `t:t/0` and the leftover data. """ - @spec parse(binary()) :: {:ok, t, leftover :: binary()} | {:error, :not_enough_data} + @spec parse(binary()) :: + {:ok, t, leftover :: binary()} + | {:error, :not_enough_data} + | {:error, {:unknown_box, binary(), non_neg_integer(), non_neg_integer()}} def parse( <<compact_size::integer-size(@compact_size_size)-unit(8), name::binary-size(@name_size), rest::binary>> @@ -46,17 +49,29 @@ defmodule Membrane.MP4.Container.Header do {header_size, size - header_size, rest} end - {:ok, - %__MODULE__{ - name: parse_box_name(name), - content_size: content_size, - header_size: header_size - }, rest} + case parse_box_name(name) do + {:ok, atom_name} -> + {:ok, + %__MODULE__{ + name: atom_name, + content_size: content_size, + header_size: header_size + }, rest} + + :error -> + {:error, {:unknown_box, name, header_size, content_size}} + end end def parse(_data), do: {:error, :not_enough_data} defp parse_box_name(name) do - name |> String.trim_trailing(" ") |> String.to_atom() + trimmed_name = String.trim_trailing(name) + + try do + {:ok, String.to_existing_atom(trimmed_name)} + rescue + ArgumentError -> :error + end end end
lib/membrane_mp4/container/parse_helper.ex+18 −3 modified@@ -17,10 +17,10 @@ defmodule Membrane.MP4.Container.ParseHelper do end def parse_boxes(data, schema, context, acc) do - withl header_content: + withl header: {:ok, %{name: name, content_size: content_size, header_size: header_size}, rest} <- Header.parse(data), - header_content: <<content::binary-size(content_size), data::binary>> <- rest, + content: <<content::binary-size(content_size), data::binary>> <- rest, do: box_schema = schema[name], known?: true <- box_schema && not box_schema.black_box?, try: @@ -31,7 +31,22 @@ defmodule Membrane.MP4.Container.ParseHelper do box = %{fields: fields, children: children, size: content_size, header_size: header_size} parse_boxes(data, schema, context, [{name, box} | acc]) else - header_content: _error -> + header: {:error, {:unknown_box, name, header_size, content_size}} -> + case data do + <<_header::binary-size(header_size), content::binary-size(content_size), + remaining::binary>> -> + box = %{name: name, content: content, size: content_size, header_size: header_size} + parse_boxes(remaining, schema, context, [{:unknown, box} | acc]) + + _not_enough -> + {:ok, Enum.reverse(acc), data, context} + end + + header: _error -> + # more data needed + {:ok, Enum.reverse(acc), data, context} + + content: _error -> # more data needed {:ok, Enum.reverse(acc), data, context}
lib/membrane_mp4/container/serialize_helper.ex+8 −0 modified@@ -25,6 +25,14 @@ defmodule Membrane.MP4.Container.SerializeHelper do end end + defp serialize_box(:unknown, %{name: name, content: content}, _schema, context) do + header = + <<@box_header_size + byte_size(content)::integer-size(@box_size_size)-unit(8), + name::binary>> + + {{:ok, [header, content]}, context} + end + defp serialize_box(box_name, %{content: content}, _schema, context) do header = serialize_header(box_name, byte_size(content)) {{:ok, [header, content]}, context}
lib/membrane_mp4/demuxer/cmaf/engine.ex+1 −1 modified@@ -21,7 +21,7 @@ defmodule Membrane.MP4.Demuxer.CMAF.Engine do :pending_emsg ] - @ignored_boxes [:free, :skip] + @ignored_boxes [:free, :skip, :unknown] @opaque t() :: %__MODULE__{}
lib/membrane_mp4/demuxer/isom/engine.ex+18 −13 modified@@ -139,19 +139,24 @@ defmodule Membrane.MP4.Demuxer.ISOM.Engine do state.provide_data_cb.(state.cursor, @max_header_size, state.provider_state) state = put_in(state.provider_state, provider_state) - {:ok, header, _rest} = Container.Header.parse(data) - - if header.name == box_name do - state = - update_in( - state.box_positions, - &Map.put(&1, box_name, {state.cursor, header.header_size, header.content_size}) - ) - - %{state | cursor: 0} - else - update_in(state.cursor, &(&1 + header.header_size + header.content_size)) - |> find_box(box_name) + + case Container.Header.parse(data) do + {:ok, %{name: ^box_name} = header, _rest} -> + state = + update_in( + state.box_positions, + &Map.put(&1, box_name, {state.cursor, header.header_size, header.content_size}) + ) + + %{state | cursor: 0} + + {:ok, header, _rest} -> + update_in(state.cursor, &(&1 + header.header_size + header.content_size)) + |> find_box(box_name) + + {:error, {:unknown_box, _name, header_size, content_size}} -> + update_in(state.cursor, &(&1 + header_size + content_size)) + |> find_box(box_name) end end
mix.exs+1 −1 modified@@ -1,7 +1,7 @@ defmodule Membrane.MP4.Plugin.MixProject do use Mix.Project - @version "0.36.6" + @version "0.36.7" @github_url "https://github.com/membraneframework/membrane_mp4_plugin" def project do
README.md+1 −1 modified@@ -12,7 +12,7 @@ The package can be installed by adding `membrane_mp4_plugin` to your list of dep ```elixir defp deps do [ - {:membrane_mp4_plugin, "~> 0.36.6"} + {:membrane_mp4_plugin, "~> 0.36.7"} ] end ```
test/membrane_mp4/container_test.exs+14 −6 modified@@ -52,13 +52,21 @@ defmodule Membrane.MP4.ContainerTest do assert Keyword.has_key?(boxes, :skip) end - test "unknown box" do - <<size::4-binary, "styp", rest::binary>> = - @cmaf_fixtures |> Path.join("ref_audio_segment1.m4s") |> File.read!() + test "box with unknown name is stored as :unknown and round-trips through serialization" do + unknown_name = "zz9z" + unknown_content = <<0, 1, 2, 3>> - data = <<size::4-binary, "abcd", rest::binary>> - assert {boxes, <<>>} = data |> Container.parse!() - assert boxes |> Container.serialize!() == data + unknown_box = + <<8 + byte_size(unknown_content)::32, unknown_name::binary, unknown_content::binary>> + + known_data = @cmaf_fixtures |> Path.join("ref_audio_segment1.m4s") |> File.read!() + data = unknown_box <> known_data + + {boxes, <<>>} = Container.parse!(data) + + assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_name) end + assert [{:unknown, %{name: ^unknown_name, content: ^unknown_content}} | _rest] = boxes + assert Container.serialize!(boxes) == data end test "parse error" do
Vulnerability mechanics
Root cause
"Missing input validation in MP4 box header parser uses String.to_atom/1 to intern every 4-byte box name as a BEAM atom without an allow-list, causing permanent atom table allocations that are never garbage-collected."
Attack vector
An attacker crafts an MP4 binary containing roughly 1.1 million minimal box headers (each 8 bytes: 4-byte size + 4-byte unique name) for a total of ~8 MB [ref_id=2]. When any code path calls `Membrane.MP4.Container.parse/1` or its bang variant on this payload, `parse_box_name/1` in `lib/membrane_mp4/container/header.ex` passes each unique 4-byte name through `String.to_atom/1`, which unconditionally creates a new BEAM atom [ref_id=2]. BEAM atoms are never garbage-collected, so each unique name is a permanent allocation; once the runtime ceiling (~1,048,576 atoms) is reached, the entire BEAM node aborts, taking down all applications running on it [ref_id=2]. No authentication or user interaction is required beyond delivering the file to any processing path that calls the parser [ref_id=2].
Affected code
The vulnerable code is `parse_box_name/1` in `lib/membrane_mp4/container/header.ex`, which calls `String.to_atom/1` on the 4-byte box name without any allow-list validation [patch_id=5593506]. This function is invoked by `Membrane.MP4.Container.Header.parse/1` for every box encountered while walking the input stream [ref_id=2]. The patch also touches `lib/membrane_mp4/container/parse_helper.ex`, `lib/membrane_mp4/container/serialize_helper.ex`, `lib/membrane_mp4/demuxer/isom/engine.ex`, and `lib/membrane_mp4/demuxer/cmaf/engine.ex` to propagate the new error handling [patch_id=5593506].
What the fix does
The patch replaces `String.to_atom/1` with `String.to_existing_atom/1` inside `parse_box_name/1` in `lib/membrane_mp4/container/header.ex` [patch_id=5593506]. When the trimmed box name does not already exist as an atom, the `ArgumentError` is rescued and `:error` is returned instead of creating a new atom [patch_id=5593506]. Downstream callers in `parse_helper.ex`, `serialize_helper.ex`, and the ISOM/CMAF demuxer engines now handle the `{:error, {:unknown_box, name, header_size, content_size}}` tuple by treating the box as `:unknown` and skipping it, rather than crashing or creating a new atom [patch_id=5593506]. The `:unknown` atom is added to the `@ignored_boxes` list in the CMAF demuxer engine so unknown boxes are safely skipped during demuxing [patch_id=5593506].
Preconditions
- inputAttacker must be able to supply a crafted MP4 binary to any code path that calls Membrane.MP4.Container.parse/1 or its bang variant
- networkNo authentication or network position required — file delivery to the processing pipeline is sufficient
- configThe target BEAM node must have the default atom table ceiling (~1,048,576 atoms) or lower
Reproduction
Generate an MP4-shaped payload of roughly 1.1 million minimal box headers, each 8 bytes long (4-byte size of 8, followed by a unique 4-byte ASCII name produced by enumerating combinations in the printable range). Concatenate them into a single binary (~8 MB total). Call `Membrane.MP4.Container.parse!/1` on the binary inside any process running under the target BEAM node. The node aborts once the atom table ceiling is reached. [ref_id=2]
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.