How to evaluate a string of code in Erlang at runtime

How to evaluate a string of code in Erlang at runtime

Erlang has the ability to read in a string, representing a line of code to execute, at runtime.

It can parse it out, evaluate it and return the value.

Evaluating Simple Expressions

At its most basic, we can just read any expression passed in and execute it.

-module(parser).
 
-export([
    evaluate_expression/1
]).
 
-spec evaluate_expression(string) -> any().
evaluate_expression(Expression) ->
    {ok, Tokens, _} = erl_scan:string(Expression),    % scan the code into tokens
    {ok, Parsed} = erl_parse:parse_exprs(Tokens),     % parse the tokens into an abstract form
    {value, Result, _} = erl_eval:exprs(Parsed, []),  % evaluate the expression, return the value
    Result.

Try passing in some simple arithmetic expressions. Easy-peasy right?

Remember that statements end in commas, and functions with a period, so your strings need to include those punctuations.

> c(parser).
{ok,parser}
 
> parser:evaluate_expression("4 > 2.").
true
> parser:evaluate_expression("4+2.").
6
> parser:evaluate_expression("A=7+2,A-4.").
5

If that’s all you need, awesome.

If you have a few minutes, you might want to read about the potential security floodgates this opens up.

Security Considerations

There are some serious pits to fall into if you allow just anyone to execute an arbitrary line of code, but more on that in a minute.

First, something to draw a comparison…

What’s an SQL injection attack? (bear with me)

I’m gonna switch gears for a minute and talk about SQL injection attacks.

Let’s say we’ve created a web page where a user can just type in their username to see information about themselves. Behind the scenes, we just take the username as they entered it, and plug it into a query that looks like this:

"select * from user_table where user_name = " + username

When it’s evaluated, it looks something like this, and it returns the record for the user to the page:

"select * from user_table where user_name = " + "gwinney"

As long as the user plays nicely, everything okay. But what if they enter their name as “gwinney; delete * from user_table”? Now the query that’s run ends up looking like this:

"select * from user_table where user_name = " + "gwinney; delete * from user_table"

The solution to this (and if you post code like that to StackOverflow, at least half a dozen people will yell at you) is to sanitize the input. We should check it to make sure it’s doing what we intended, and with SQL that usually means parameterizing the query.

I don’t want to get into all of that here, but if we’ve done things the *right *way the query looks more like this, which will fail because that crazy username doesn’t exist.

"select * from user_table where user_name 'gwinney; delete * from user_table'"

What’s that have to do with Erlang?!

Similarly, we can run into security issues with our expression code.

We’re allowed to include a call to any function – local functions as well as BIFs (Erlang’s built-in functions) and exported functions in other modules you’ve created – and it’ll parse them and attempt to execute them.

If we make the above function accessible to the outside world, even indirectly, and the input isn’t sanitized, then we’ve handed over the ability for someone to directly call all kinds of functions they have no business calling. Oops.

So how do we prevent that…?

Intercepting Local Function Calls

We can supply a function to erl_eval:exprs through which all calls to local functions will be passed, and that’s where we can take additional actions.

Local functions are those in the same module, which can be called without specifying the module name. (Though some BIFs don’t require a module name, like list_to_binary, it’s because they’re auto-imported by the system – they’re still considered non-local.)

There’s some new stuff in the code below – a function called handle_local_function and a local function called get_random_number (thanks xkcd). The handler function outputs an informational message and then handles the passed-in function name.

-module(parser).
 
-export([
    evaluate_expression/1
]).
 
-spec evaluate_expression(string) -> any().
evaluate_expression(Expression) ->
    {ok, Tokens, _} = erl_scan:string(Expression),
    {ok, Parsed} = erl_parse:parse_exprs(Tokens),
    {value, Result, _} = erl_eval:exprs(Parsed, [],
                                        {value, fun handle_local_function/2}),
    Result.
 
-spec handle_local_function(atom(), list()) -> any().
handle_local_function(FunctionName, Arguments) ->
    io:format("Local call to ~p with ~p~n", [FunctionName, Arguments]),
    case FunctionName of
        get_random_number -> get_random_number();
        what_time_is_it -> calendar:universal_time();
        are_we_there_yet -> "no";
        _ -> "uh uh uh. you didn't say the magic word!"
    end.
 
-spec get_random_number() -> integer().
get_random_number() ->
    4.  % chosen by fair dice roll; guaranteed to be random

Now run the module again and pass in some new expressions.

We can intercept those local functions (which may not really exist, but the expression evaluator doesn’t know that) and redirect them as we please… or just spit out a message if the user tries to do something invalid.

> c(parser).
{ok,parser}
 
> parser:evaluate_expression("get_random_number().").
Local call to get_random_number with []
4
 
> parser:evaluate_expression("what_time_is_it().").
Local call to what_time_is_it with []
{{2017,3,5},{15,21,53}}
 
> parser:evaluate_expression("are_we_there_yet().").
Local call to are_we_there_yet with []
"no"
 
parser:evaluate_expression("break_the_system().").
Local call to break_the_system with []
"uh uh uh. you didn't say the magic word!"

Intercepting Non-Local Function Calls

Similarly, we can supply a function to erl_eval:exprs through which all calls to ***non-***local functions will be passed. (Anything outside of the current module, including BIFs and even the operators used in comparisons.)

Here’s the code again, extended to handle non-local functions. Notice how we have to explicitly handle the > and < comparison operators that are part of the erlang module, how we can redirect non-existent functions to existing ones, and how we can display a message if a function is unsupported.

-module(parser).
 
-export([
    evaluate_expression/1
]).
 
-spec evaluate_expression(string) -> any().
evaluate_expression(Expression) ->
    {ok, Tokens, _} = erl_scan:string(Expression),
    {ok, Parsed} = erl_parse:parse_exprs(Tokens),
    {value, Result, _} = erl_eval:exprs(Parsed, [],
                                        {value, fun handle_local_function/2},
                                        {value, fun handle_non_local_function/2}),
    Result.
 
-spec handle_local_function(atom(), list()) -> any().
handle_local_function(FunctionName, Arguments) ->
    io:format("Local call to ~p with ~p~n", [FunctionName, Arguments]),
    case FunctionName of
        get_random_number -> get_random_number();
        what_time_is_it -> calendar:universal_time();
        are_we_there_yet -> "no";
        _ -> "uh uh uh. you didn't say the magic word!"
    end.
 
-spec handle_non_local_function(atom(), list()) -> any().
handle_non_local_function({ModuleName,FunctionName}, Arguments) ->
    io:format("Non-local call to ~p with ~p~n", [FunctionName, Arguments]),
    case ModuleName of
        erlang ->
            case FunctionName of
                '>' -> apply(ModuleName, FunctionName, Arguments);
                '<' -> apply(ModuleName, FunctionName, Arguments);
                list_to_binary -> apply(ModuleName, FunctionName, Arguments);
                _ -> "nope"
            end;
        calendar ->
            case FunctionName of
                universal_time -> calendar:universal_time();
                lets_pretend_this_returns_four -> 4;
                something_ridiculous -> "what calendar are you using??";
                _ -> "notgonnahappen"
            end;
        _ -> "don't think about it"
    end.
 
-spec get_random_number() -> integer().
get_random_number() ->
    4.  % chosen by fair dice roll; guaranteed to be random

Greater than and less than comparisons are allowed, but not equality… because. Some functions are allowed, some aren’t, and some are redirected. In the last example, an evil user tries exact their nefarious plan to take part of the system down. But is foiled. :p

> c(parser).
{ok,parser}
 
> parser:evaluate_expression("4 < 2.").
Non-local call to '<' with [4,2]
false
 
> parser:evaluate_expression("4 > 2.").
Non-local call to '>' with [4,2]
true
 
> parser:evaluate_expression("4 == 2.").
Non-local call to '==' with [4,2]
"nope"
 
> parser:evaluate_expression("list_to_binary(\"hi\").").
Non-local call to list_to_binary with ["hi"]
<<"hi">>
 
> parser:evaluate_expression("binary_to_list(<<\"hi\">>).").
Non-local call to binary_to_list with [<<"hi">>]
"nope"
 
> parser:evaluate_expression("calendar:universal_time().").
Non-local call to universal_time with []
{{2017,3,5},{21,4,42}}
 
> parser:evaluate_expression("calendar:local_time().").
Non-local call to local_time with []
"notgonnahappen"
 
> parser:evaluate_expression("calendar:lets_pretend_this_returns_four().").
Non-local call to lets_pretend_this_returns_four with []
4
 
> parser:evaluate_expression("calendar:something_ridiculous().").
Non-local call to something_ridiculous with []
"what calendar are you using??"
 
> parser:evaluate_expression("sys:terminate(some_process, \"buahaha\").").
Non-local call to terminate with [some_process,"buahaha"]
"don't think about it"

What else?

Good examples in Erlang can be hard to come by, and what you see here was a fair amount of trial and error. If you find yourself trying to parse code and execute it at runtime, maybe this’ll get you going.

Other resources to check out: