tryerlang.org is an interactive Erlang Shell which allows users to try the power of Erlang directly in a browser, without requiring them to install an Erlang runtime system on their machine. Even if intended for Erlang newbies, tryerlang.org has been subjected to a countless number of attacks conducted by Erlang experts who wanted to circumvent its sandboxing mechanism and to bring down the Erlang node running the application. I must admit that going through the tryerlang.org’s logs is being an highly interesting and constructive experience.
In this blog post I will present one of the most elaborated attacks performed on tryerlang.org. The attack, which exploits the Erlang External Term Representation, has been performed by a former Erlang Solutions’ employee who had access to the tryerlang.org source code. To understand how the attack works, we need to introduce the Erlang External Term Representation.
External Term Representation
In Distributed Erlang, terms can be transferred from an Erlang node to another one using the so-called binary format. Generic terms are encoded in binary from the sender using the built-in function
term_to_binary/1 and restored from the receiver using the complementary function
binary_to_term/2. A binary message looks like this:
Which, as you can see, represents the binary encoding of the atom
1> term_to_binary(pigeon). <<131,100,0,6,112,105,103,101,111,110>> 2> binary_to_term(<<131,100,0,6,112,105,103,101,111,110>>). pigeon
The External Term Representation of Erlang terms is extensively documented in the official Erlang Documentation. Let’s see how the attacker used this concept in his own interest.
Halting the Erlang Node
To stop the Erlang node running tryerlang.org, the attacker tries at first the following command:
This function, documented here, is supposed to halt an Erlang runtime system, indicating a normal exit to the calling environment. The function has been disabled in tryerlang.org for security reasons, so the only result the user get is the following annoying message:
"This functionality has been disabled for security reasons in tryerlang.org.".
So, the Erlang node is still up and attacker prepares himself a good cup of Swedish coffee. After a couple of minutes playing with the tryerlang.org shell, the attcker notices that tryerlang.org allows you to define custom funs. Then, the intuition. A fun, as any other Erlang term, can be encoded using the External Terms Representation. The encoded fun could then be executed. This could hopefully fool the sandboxing mechanism protecting the tryerlang.org and could open a world of possibilities to the attacker.
According to the documentation, the external representation of the fun (in the
fun M:F/A format) is the following:
113 | Module | Function | Arity
Function are atoms and
Arity is an integer.
Atoms themselves can be encoded using the ATOM_EXT format:
100 | Len | AtomName
Len is the length of
AtomName, expressed using two bytes.
For the atom
erlang, which is composed of 6 characters (the letters
g) we obtain:
100 | 0, 6 | 101, 114, 108, 97, 110, 103
Where the integers in the third section are the ASCII codes for each of the letters composing the word “erlang”.
Applying the same reasoning to the atom
halt, we obtain:
100 | 0, 4 | 104, 97, 108, 116
Finally, the arity (an integer) can be encoded using the SMALL_INTEGER_EXT format:
97 | Int
So, in our case (arity = 0) we obtain:
97 | 0
Putting all the pieces together and considering that, in the External Term Representation, the byte
131 needs to be prepended to the final term, we can encode the
erlang:halt/0 function into binary, obtaining:
Let’s verify that we didn’t do any mistake:
> binary_to_term(<<131,113,100,0,6,101,114,108,97,110,103,100,0,4,104,97,108,116,97,0>>). > #Fun<erlang.halt.0>
Since tryerlang.org doesn’t support copy-and-paste from the clipboard, we need to insert the sequence above by hand.
We can bind the binary to a new variable:
> B = <<131,113,100,0,6,101,114,108,97,110,103,100,0,4,104,97,108,116,97,0>>.
We now need to convert the binary into an Erlang term. Originally, tryerlang.org was allowing the binary_to_term function in safe mode. This function has been now completely disabled after this attack. If you want to try what follows you will need to do it in your own Erlang shell.
> F = binary_to_term(B, [safe]).
Let’s now try to launch the fun as:
Well, that didn’t work as expected. tryerlang.org actually realized that the
erlang:halt/0 function was going to be called and the sandboxing mechanism managed to block the execution of the command. We need to do something slightly different. For example, we might pass the newly defined fun as an argument (after all, Erlang is a functional language) to a function who would take care of executing it. As an example, we could use the library function
lists:map/2. There’s only a little tiny problem with that. The
list:map/2 function, in fact, requires that the fun passed as an argument receives exactly one argument. This is not the case of the
erlang:halt/0 function, which has arity equal to zero. Fortunately an alternative version of
erlang:halt/0 exists, taking exactly one argument. The external representation for the new function differs from the previous one by only the very last byte. Let’s forget the old value of the variable
B and let’s bind it to the new binary:
> f(B). > B = <<131,113,100,0,6,101,114,108,97,110,103,100,0,4,104,97,108,116,97,1>>.
We can now pass the new fun as an argument to the
> f(F). > F = binary_to_term(B, [safe]). >lists:map(F, ).
And the node dies. Well, in reality the node is almost immediately brought back by heart which is listening for heartbeats from the Erlang node itself but, hey, I have to pay a beer to this guy! :)
I wanted to share this experience with all of you. I consider it highly constructive, since it leads to reflect on several aspects of Erlang. Comments and feedback are more than welcome.