Ash, the Elixir framework for building resources and APIs, has a critical vulnerability in its built-in :module type handler. Attackers can feed it unique strings starting with "Elixir.", forcing the creation of new Erlang atoms without checks. These atoms pile up until the BEAM VM’s atom table hits its 1,048,576-entry limit, crashing the entire process. Any Phoenix app or service using Ash with public :module-typed fields faces full denial of service from a simple request loop.
This isn’t theoretical. The bug lives in Ash.Type.Module.cast_input/2 (lines 105-113 in lib/ash/type/module.ex). It blindly calls Module.concat([value]) on user input like "Elixir.Attack123", creating a permanent atom :"Elixir.Attack123". Only afterward does it check if the module loads via Code.ensure_loaded?. By then, the atom sticks around forever—Erlang doesn’t garbage-collect them.
The Vulnerable Code Path
Here’s the offender:
def cast_input("Elixir." <> _ = value, _) do
module = Module.concat([value]) # Creates new atom unconditionally
if Code.ensure_loaded?(module) do
{:ok, module}
else
:error # Atom already created and persists
end
end
The safer path for non-"Elixir." strings uses String.to_existing_atom/1, which errors out if the atom doesn’t exist—no new atom created. Same issue repeats in cast_stored/2 (line 141), hittable during database reads if attackers previously wrote junk to the column.
Ash documents :module types for attributes like custom handlers, making this a supported pattern. Consider a resource like this:
defmodule MyApp.Widget do
use Ash.Resource, domain: MyApp, data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :handler_module, :module, public?: true
end
actions do
defaults [:read, :destroy]
create :create do
accept [:handler_module]
end
end
end
Public ?: true exposes it via API. Attackers hammer Ash.create/2 with unique payloads.
Exploitation in Practice
Loop this 1.1 million times via script or HTTP flood:
for i <- 1..1_100_000 do
Ash.Changeset.for_create(MyApp.Widget, :create, %{
handler_module: "Elixir.Attack#{i}"
})
|> Ash.create()
end
Each call spawns a new atom. Around iteration 1,048,576, BEAM throws system_limit and dies. No supervision tree saves you—the VM is toast. Restart required.
BEAM atoms power everything: PIDs, module names, binaries under 256 bytes. Default limit: 1,048,576. You can bump it with +t flag (e.g., +t 2M), but that’s no fix—attackers just need more requests. Past vulns like Cowboy’s atom leak (CVE-2019-9111) hit similar notes; Elixir devs know this risk.
Ash powers real apps—think admin panels, CMS, event sourcing. If your API accepts :module inputs without validation, you’re exposed. Even read-only endpoints trigger via cast_stored if DB holds attacker data.
Impact and What to Do
Full VM crash means downtime for all services on that node. In clusters, one compromised endpoint takes one instance offline; scale suffers. No data loss usually—persisted safely—but availability tanks. CVSS? High: 7.5+ for network DoS.
Fair to Ash: They support dynamic modules legitimately. But skipping to_existing_atom here is sloppy. Fix: Swap Module.concat for String.to_existing_atom(value) in a try/rescue ArgumentError, mirroring the other path. Audit all :module fields; restrict to allowlists. Set public?: false unless needed. Monitor atom count via :erlang.system_info(:atom_count) and :atom_limit.
Broader lesson: User input touching atoms is poison in BEAM land. Frameworks must gate it hard. Check your Ash version—pre-fix exposes you. Update fast, or firewall those fields. This matters because Elixir’s uptime rep takes hits from DoS like this.