Summary
Uncontrolled recursion in the Elixir standard library lets an attacker who controls a list passed to inspect/1, List.to_string/1, or List.to_charlist/1 exhaust a BEAM node's memory. Inspect.List decides whether a list is a charlist by checking only a bounded prefix, then converts the entire list, so a list with a long printable prefix followed by a single invalid element is mis-classified and the conversion fails. The failure path renders the same list again, re-entering the same branch and looping without bound. Any code path that inspects or stringifies an attacker-influenced list is affected, with error and log formatting particularly exposed because the defect lives in the failure path itself.
Details
Inspect.List in lib/elixir/lib/inspect.ex decides whether a list is a charlist by checking only the first :printable_limit elements (4096 by default), then converts the whole list. A list with a longer printable prefix followed by an invalid element therefore passes the check but fails the conversion, raising an ArgumentError. Inspecting such a list directly, or passing it to List.to_string/1 or List.to_charlist/1, crashes for this reason.
The crash is what starts the loop. The rescue clause in lib/elixir/lib/list.ex builds its error message by inspecting the same list that just failed, which re-enters the same branch and raises again. The nested inspection is an argument to raise, so the recursion is not in tail position and every level is retained: the process stack grows monotonically while each cycle re-walks the list and allocates another message binary. Garbage collection reclaims the discarded binaries but cannot shrink the retained stack, so the process grows until it hits a max_heap_size limit or exhausts the node.
Below the printable limit there is no bug: the invalid element falls inside the counter, the list renders in ordinary bracket form, and a single ArgumentError propagates normally.
PoC
- Build a list whose first elements exceed the default printable limit and are all printable ASCII code points, for example 5000 copies of the
a code point.
- Append one element that is not a valid code point, such as an atom, an integer above the Unicode maximum, or an improper tail.
- Pass that list to
inspect/1, List.to_string/1, or List.to_charlist/1.
- Observe that the call never returns. Sampling the process shows stack size climbing monotonically and memory growing steadily.
- To bound the observation, run the call in a task with a timeout.
Impact
A single call on an attacker-influenced list consumes memory without bound until the calling process is killed by a heap limit or, by default, the entire BEAM node runs out of memory and takes down every process on it. The exposure is worst in error reporting and logging, where a malformed charlist silently hangs the reporting process and never produces a log entry, so the failure can go unattributed.
Workaround
For users who cannot immediately upgrade to a patched version, you can patch the existing behaviour by installing a global inspect function. It requires Elixir v1.13+ and it can be done at the top of your application's start callback:
Inspect.Opts.default_inspect_fun(fn term, opts ->
Inspect.inspect(term, %{opts | charlists: :as_lists})
end)
The charlist branch is then never entered, so the conversion cannot fail and the error path cannot recurse. The trade-off is global: charlists render in bracket form everywhere in the application, including logs and error reports, so ~c"abc" inspects as [97, 98, 99].
As a containment measure, setting a max_heap_size process flag on processes that may inspect untrusted terms caps the damage: the runtime kills the offending process once it crosses the limit, so a single call can no longer exhaust the node. This does not prevent the recursion and the affected process still dies without returning, so it is an alternative to the inspect-function change for those earlier than v1.13+ (although those users are advised to update immediately).
References
Summary
Uncontrolled recursion in the Elixir standard library lets an attacker who controls a list passed to
inspect/1,List.to_string/1, orList.to_charlist/1exhaust a BEAM node's memory.Inspect.Listdecides whether a list is a charlist by checking only a bounded prefix, then converts the entire list, so a list with a long printable prefix followed by a single invalid element is mis-classified and the conversion fails. The failure path renders the same list again, re-entering the same branch and looping without bound. Any code path that inspects or stringifies an attacker-influenced list is affected, with error and log formatting particularly exposed because the defect lives in the failure path itself.Details
Inspect.Listinlib/elixir/lib/inspect.exdecides whether a list is a charlist by checking only the first:printable_limitelements (4096 by default), then converts the whole list. A list with a longer printable prefix followed by an invalid element therefore passes the check but fails the conversion, raising anArgumentError. Inspecting such a list directly, or passing it toList.to_string/1orList.to_charlist/1, crashes for this reason.The crash is what starts the loop. The rescue clause in
lib/elixir/lib/list.exbuilds its error message by inspecting the same list that just failed, which re-enters the same branch and raises again. The nested inspection is an argument toraise, so the recursion is not in tail position and every level is retained: the process stack grows monotonically while each cycle re-walks the list and allocates another message binary. Garbage collection reclaims the discarded binaries but cannot shrink the retained stack, so the process grows until it hits amax_heap_sizelimit or exhausts the node.Below the printable limit there is no bug: the invalid element falls inside the counter, the list renders in ordinary bracket form, and a single
ArgumentErrorpropagates normally.PoC
acode point.inspect/1,List.to_string/1, orList.to_charlist/1.Impact
A single call on an attacker-influenced list consumes memory without bound until the calling process is killed by a heap limit or, by default, the entire BEAM node runs out of memory and takes down every process on it. The exposure is worst in error reporting and logging, where a malformed charlist silently hangs the reporting process and never produces a log entry, so the failure can go unattributed.
Workaround
For users who cannot immediately upgrade to a patched version, you can patch the existing behaviour by installing a global inspect function. It requires Elixir v1.13+ and it can be done at the top of your application's start callback:
The charlist branch is then never entered, so the conversion cannot fail and the error path cannot recurse. The trade-off is global: charlists render in bracket form everywhere in the application, including logs and error reports, so
~c"abc"inspects as[97, 98, 99].As a containment measure, setting a
max_heap_sizeprocess flag on processes that may inspect untrusted terms caps the damage: the runtime kills the offending process once it crosses the limit, so a single call can no longer exhaust the node. This does not prevent the recursion and the affected process still dies without returning, so it is an alternative to the inspect-function change for those earlier than v1.13+ (although those users are advised to update immediately).References
Inspect.Opts.default_inspect_fun/1): https://hexdocs.pm/elixir/Inspect.Opts.html