How to DoS your Elixir application with Twitter and atoms

Atom memory chart in PryIn

Atoms in Elixir are “constants where their name is their own value”. Just like in Ruby, where a similar concept is called symbols, they are denoted by a : followed by their name: :an_atom.

The BEAM puts a limit on the number of existing atoms. By default that limit is 1,048,576. That’s probably enough for all the constants defined in your codebase, but if user input is transformed into atoms dynamically, this can easily lead to a DoS vector.

In this post, we’ll build a Phoenix application that is affected by the atom limit and will eventually crash. We’ll also see how to spot this problem in PryIn.

The application will serve as a Twitter monitor: We’ll build a website that allows entering a search phrase. Whenever a tweet from our company’s Twitter account matches that search phrase, it gets added to that site. We’ll use Twitter’s Streaming API and Phoenix channels for that.

Because our company’s Twitter username never changes, we’ll put that into our application config. And because writing one : is less effort than writing two "s, we’ll use an atom instead of a string:

config :tweet_feed, twitter_username: :pryin_io

The relevant code of the channel providing the main functionality is pretty easy.

When a user joins a channel, we start a process that connects to Twitter and forwards every tweet matching the search phrase to our channel:

  def join("tweets:track", %{"phrase" => phrase}, socket) do
    send(self(), :start_twitter)
    {:ok, assign(socket, :phrase, phrase)}

  def handle_info(:start_twitter, socket) do
    parent = self()

    pid = spawn_link(fn ->
      ExTwitter.stream_filter([track: socket.assigns.phrase], :infinity)
      |> Stream.each(fn tweet -> send(parent, {:new_tweet, tweet}) end)

    {:noreply, assign(socket, :stream_pid, pid)}

Because we’re only interested in tweets by our company, we compare the tweet author’s username to the username of our company:

  def handle_info({:new_tweet, tweet}, socket) do
    company_username = Application.get_env(:tweet_feed, :twitter_username)
    if String.to_atom(tweet.user.screen_name) == company_username do
      push socket, :new_tweet, %{text: tweet.text}

    {:noreply, socket}

You probably realized that String.to_atom(tweet.user.screen_name) is not a good idea here. For every user tweeting a new atom is created, which counts towards the atom limit and is never garbage collected.

With a popular enough search query (“trump,bieber,brexit,summer”) the app crashed after about 24 hours with:

no more index entries in atom_tab (max=1048576)

This example was obviously made up, but I’ve seen at least two real live cases that were vulnerable:

  1. A library transforming the Phoenix controller parameters map to have atom instead of string keys.
  2. An API client parsing the response body with Poison.decode!(body, keys: :atoms!).

Spotting a problem like this in PryIn is quite obvious. In the BEAM memory stats, there is an “Atoms” graph. Whenever this is growing steadily, you might have a problem. For our example app this is what that graph looked like:

Atom memory chart in PryIn

Head on over to PryIn and start your free trial.