Safeguard
Industry Analysis

Erlang/OTP atom exhaustion and deserialization risks in E...

How the elixir atom exhaustion vulnerability lets attackers crash BEAM nodes via unsafe binary_to_term calls, and how Safeguard catches the pattern before it ships.

Daniel Chen
Security Engineer
7 min read

On any given day, a BEAM VM node quietly enforces a hard limit that most Elixir engineers never think about: 1,048,576 atoms, allocated once and never freed for the lifetime of the process. That ceiling is the mechanism behind the elixir atom exhaustion vulnerability, a denial-of-service class that has circulated in Erlang security discussions for well over a decade and keeps resurfacing in Phoenix, Plug, and custom RPC endpoints that call :erlang.binary_to_term/1 on attacker-controlled data. The bug is not exotic — it is a single missing option, :safe, left off a deserialization call. Feed the endpoint a stream of unique binary payloads and each one silently mints a brand-new atom that the garbage collector can never reclaim. Do it enough times and the node runs out of atom table space and crashes. This post breaks down how erlang term deserialization creates the opening, what happens inside the BEAM VM when it fails, and how to close it before an attacker finds it first.

What Is the Elixir Atom Exhaustion Vulnerability?

The elixir atom exhaustion vulnerability is a denial-of-service condition where an attacker forces the BEAM virtual machine to create so many atoms that its fixed-size atom table fills up and the entire node crashes. Atoms — things like :ok, :error, or :my_custom_key — are the one data type in the BEAM that is never garbage collected. Every atom a program creates during its lifetime stays in a global table, permanently, until the node restarts. By default that table holds 1,048,576 (2^20) entries. Most applications never come close to that limit because developers write atoms as literals in source code, and the total number of distinct atoms in a codebase is small and fixed at compile time. The danger appears the moment attacker-supplied data gets turned into atoms at runtime, because now the number of atoms is bounded only by the number of unique inputs an attacker can send, not by anything the developer wrote.

Why Does Erlang Term Deserialization Make This So Easy to Trigger?

Erlang term deserialization is easy to abuse because the External Term Format (ETF) that Erlang and Elixir use to move data between nodes, store it in ETS, or persist it to disk encodes atoms as a first-class, self-describing value type — and the default decode path honors that encoding without question. When you call :erlang.binary_to_term(binary), the runtime walks the ETF tags in the binary and, whenever it hits an atom tag (ATOM_EXT, SMALL_ATOM_UTF8_EXT, and related variants), it calls the equivalent of String.to_atom/1 on whatever bytes follow — creating the atom if it doesn't already exist. There is no opt-out by default. Erlang/OTP added a :safe option specifically to close this gap: :erlang.binary_to_term(binary, [:safe]) will raise :badarg instead of creating a new atom, and also refuses to decode terms containing new atoms embedded deeper in tuples, lists, or maps. The option has existed for a long time precisely because the runtime maintainers recognized that any code path decoding untrusted ETF is a direct line to the atom table. The catch is that :safe isn't the default, and nothing in the language forces a developer to remember it.

What Actually Happens Inside the BEAM VM When the Atom Table Fills Up?

When the last free slot in the BEAM VM's atom table is used, the runtime doesn't degrade gracefully or shed load — it calls its fatal-error path and crashes the entire node with a "no more index entries in atom_tab" error, taking down every process the node is supervising, including completely unrelated tenants sharing that VM. This is a hard stop, not a recoverable exception, because the atom table is a core runtime structure, not application state a supervisor can restart around. The default limit of 1,048,576 atoms can be raised with the +t flag at boot, but that only delays the crash rather than preventing it, and it trades a known failure mode for a larger fixed memory commitment (each atom entry costs roughly 8–9 bytes of table overhead plus the atom's own string bytes, all held for the life of the node). The economics favor the attacker: a single endpoint that decodes attacker-controlled binaries into atoms at even a modest 200–500 requests per second can exhaust a default-sized table in well under an hour, and unlike memory exhaustion from binaries or processes, there is no backpressure mechanism, no GC pass, and no way to free the space short of a restart.

How Does the Elixir binary_to_term Risk Show Up in Real Phoenix and Plug Applications?

The elixir binary_to_term risk shows up wherever a Phoenix, Plug, or Cowboy application deserializes attacker-supplied binary data without the :safe option — most commonly in custom session or cookie handling, cache deserialization, and internal RPC or job-queue payloads that were serialized with :erlang.term_to_binary/1 upstream and get decoded again downstream. Signed and encrypted cookie handling in the Plug ecosystem has historically relied on term_to_binary/binary_to_term round-tripping for session data, which is why the Elixir core team and package maintainers have repeatedly hardened Plug.Crypto's message verifier and pushed the :safe option as the required default in newer releases. Beyond cookies, teams roll their own trouble: a :gen_tcp or :ranch-based internal protocol that accepts raw ETF from a "trusted" load balancer or partner integration, an ETS-backed cache that deserializes values pulled from Redis or Memcached (where a compromised cache node becomes an atom-exhaustion vector), or a background-job library storing job arguments as external term format that later gets decoded from a queue an attacker can write to. None of these require exotic access — they require one deserialization call that forgot a keyword-list argument.

How Can Teams Detect and Prevent Atom Exhaustion Before It Reaches Production?

Teams prevent atom exhaustion by never calling :erlang.binary_to_term/1 on untrusted input, always specifying the [:safe] option, and treating any endpoint that accepts serialized Erlang terms as an external trust boundary the same way they'd treat a Java deserialization or PHP unserialize() endpoint. In practice that means a handful of concrete controls: prefer JSON, Protobuf, or MessagePack for any data crossing a trust boundary, and reserve ETF for intra-cluster traffic already protected by Erlang distribution cookies and network isolation; audit hex.pm dependencies and internal code for binary_to_term calls missing :safe using mix xref or a static analysis pass across the umbrella app; instrument :erlang.system_info(:atom_count) against :erlang.system_info(:atom_limit) as a first-class metric and alert well before the ceiling, for example at 70% of the 1,048,576 default rather than waiting for the crash; and rate-limit and authenticate any route that touches deserialization logic at all. Keeping OTP itself current matters too — successive OTP releases have continued to harden BEAM VM security around ETF handling and distribution, and running an end-of-life OTP version means missing those fixes entirely.

How Safeguard Helps

Safeguard treats this the way we treat any other supply-chain risk: as something that should be caught in the pipeline, not discovered in an incident channel. For Elixir and Erlang codebases, our scanning identifies unsafe :erlang.binary_to_term calls missing the :safe option across your application and its Hex dependency tree, and flags the specific modules and endpoints where attacker-reachable input meets a deserialization call. We generate SBOMs that capture your full Hex/Mix dependency graph and the underlying Erlang/OTP runtime version in use, so you always know whether a service is running an OTP release with known BEAM VM hardening gaps, and we track hex.pm advisories continuously so a newly disclosed deserialization issue in a transitive dependency surfaces as a gated finding in CI rather than something you learn about from a mailing list. Policy gates in your pipeline can block merges that introduce a new binary_to_term call without :safe, and our monitoring integrations can watch atom-table utilization metrics in production and alert your team long before a node approaches the 1,048,576-atom ceiling. The goal isn't to slow Elixir teams down — it's to make sure the one runtime quirk that turns a missing keyword argument into a full node crash gets caught at review time, every time, across every service in the fleet.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.