logo

Database

Need

Decoding of untrusted input with a data-only format instead of the Erlang term format

Context

• Usage of Elixir 1.15+ for building application services

• Usage of Plug.Router for handling HTTP requests

• Usage of Jason for JSON decoding

Description

1. Non compliant code

defmodule MyAppWeb.ImportRouter do
  use Plug.Router

  plug :match
  plug :dispatch

  post "/import" do
    {:ok, body, conn} = read_body(conn)...

The `/import` route below reads the request body and decodes it with `:erlang.binary_to_term/1`. The Erlang external term format can describe any term, including atoms and anonymous functions. Atoms are never garbage collected and the atom table has a fixed size, about one million entries by default, so a client that sends payloads with many new atoms eventually fills it and crashes the whole virtual machine, taking every process of the node down with it. Decoded terms can also contain functions and references that the application may later call or compare, which lets the client inject behavior. Because the attack costs the client a few requests and brings down the entire node, it is a denial of service against every user of the application.

2. Steps

• Never call `:erlang.binary_to_term/1` on data received from clients, files uploaded by users or other untrusted sources.

• Exchange data with clients as JSON decoded with `Jason.decode/1`, which creates no atoms.

• Between trusted services, use `Plug.Crypto.non_executable_binary_to_term/2` with the `[:safe]` option.

• Never convert user input to atoms with `String.to_atom/1`; use `String.to_existing_atom/1` or keep the values as strings.

• Limit the size of request bodies with the `length` option of `read_body/2` or `Plug.Parsers`.

3. Secure code example

defmodule MyAppWeb.ImportRouter do
  use Plug.Router

  @max_body_bytes 64_000

  plug :match
  plug :dispatch
...

The corrected route accepts JSON and decodes it with `Jason.decode/1`. JSON can only describe maps, lists, strings, numbers, booleans and `nil`, and Jason returns object keys as strings, so no new atoms are created and no functions can be sent. A payload that is not valid JSON is answered with `400 Bad Request`. `read_body/2` is also called with a `length` limit, so a single request cannot make the process buffer an unbounded body. When the Erlang term format is truly required between trusted services, `Plug.Crypto.non_executable_binary_to_term(body, [:safe])` rejects executable terms and atoms that do not already exist, but untrusted clients should always use a data-only format.