Elixir/Phoenix Security: Denial of Service Due to Atom Exhaustion

Michael Lubas, 2023-01-03

In Elixir, the atom is a basic type, a constant whose value is its own name. Atoms are often hard-coded, meaning the atom :red appears in source code, and is not dynamically created at runtime or compile time. However, it is possible for your application to accept user input, then create a new atom based on that input, for example:

  1. Via string interpolation, :"red_#{animal}"
  2. Via a function call String.to_atom/1, List.to_atom/1, or Module.concat/1,2

Atoms in Elixir (and Erlang) are not garbage collected, your system has a hard limit on the number of atoms that can exist, the default is 1_048_576. There is a good discussion on the Erlang Forums about why atoms are not garbage collected. Crashing an iex session due to atom exhaustion can be done in one line:

iex(1)> Enum.map(1..1048576, fn x -> String.to_atom(Integer.to_string(x)) end)
no more index entries in atom_tab (max=1048576)

Crash dump is being written to: erl_crash.dump...done

When a Phoenix application is vulnerable to atom exhaustion, what does that code look like? How would an attacker trigger the crash? Let’s start with an example application, and examine the answers to these questions.

Felis, an App for Cats

Our example application, Felis, is a simple Phoenix application with exactly one route. It accepts a color from users, such as ?cat=brown, and returns a cat that matches the color:

Look at the page controller code:

defmodule FelisWeb.PageController do
  use FelisWeb, :controller

  def index(conn, %{"cat" => color}) do
    c =
      %{
        brown: "/images/brown.jpeg",
        white: "/images/white.jpeg",
        orange: "/images/orange.jpeg",
      }
    key = String.to_atom(color)
    render(conn, "index.html", img_path: c[key])
  end
end

When a user visits http://localhost:4000/?cat=brown, the string “brown” is provided by an external user, and gets converted to an atom. This is the source of the vulnerability, the call to String.to_atom/1. How would an attacker actually exploit this though? The website is not open source, and what motivation is there to attack a site that just serves cat pictures?

The problem is many bots on the internet are not intelligent, and do not always act in a rational way. For example, consider a bot that sees a url ending in /?cat=brown, scraped from social media or a mailing list. The bot decides to send thousands of requests, such as /?cat=1, 2, 3 etc. The coder of this bot could be searching for hidden pages, or simply made a programming error.

How many requests would a real attacker have to send to trigger the crash? Let’s find out:

defmodule Felis do
  def do_dos() do
    1..1_048_576
    |> Task.async_stream(fn x ->
        s = Integer.to_string(x)
        HTTPoison.get("http://localhost:4000/?cat=#{s}")
    end, max_concurrency: 200)
    |> Enum.to_list()
  end
end

The reason the output numbers are not sequential is because we are sending HTTP requests concurrently. An attacker would need to send approximately 1 million requests to crash your application. This assumes one HTTP request creates one new atom. While the risk may seem low, some Elixir applications serve millions or billions of requests while online, so keeping a potential DoS vector open is a bad idea. An application where one HTTP request results in many atoms being created is at even greater risk.

Preventing Atom DoS in Your Application

In the Felis project we can fix the vulnerability with minimal code changes:

defmodule FelisWeb.PageController do
  use FelisWeb, :controller

  def index(conn, %{"cat" => color}) do
    c =
      %{
        brown: "/images/brown.jpeg",
        white: "/images/white.jpeg",
        orange: "/images/orange.jpeg",
      }
    # old code, vulnerable: key = String.to_atom(color)
    key = String.to_existing_atom(color)
    render(conn, "index.html", img_path: c[key])
  end
end

The comment here displays the previous vulnerable code, with String.to_atom(color). This line results in a new atom being created from external user input. With String.to_existing_atom/1, the user input is converted to an atom that already exists, meaning no new atoms will be created due to user input.

The EEF Security WG’s Secure Coding and Deployment Hardening Guidelines goes into detail on how to prevent atom exhaustion in Elixir and Erlang:

To check your own projects for this vulnerability, Sobelow, a static analysis security tool for Phoenix, is the best tool. Here is the output for the example shown today:

---
DOS.StringToAtom: Unsafe `String.to_atom` - High Confidence
File: lib/felis_web/controllers/page_controller.ex
Line: 12
Function: index:4
Variable: color
---

Converting user input to atoms is a DoS vector that every Elixir developer should be aware of. If your monitoring system reports a spike in the number of atoms being created, or unexpected crashes, it may be due to external input causing new atoms to be created. Avoid this pitfall to keep your system online and running smoothly.


Paraxial.io stops data breaches by securing your Elixir and Phoenix apps. Detect and fix critical security issues today. Attending ElixirConf EU (April 17th) in Lisbon? Paraxial.io founder Michael Lubas is giving the training Elixir Application Security and will be speaking at the conference. Hope to see you there!

Subscribe to stay up to date on new posts.