Play AppSec WarGames
Want to skill-up in secure coding and AppSec? Try SecDim Wargames to learn how to find, hack and fix security vulnerabilities inspired by real-world incidents.
The maintainers of vm2 have been honest about the limitations of vm2. They have been explicit that new bypasses are likely to occur and that vm2 should not be relied on as the only security control. This is great and I would like to see more security-mature maintainers openly call out the security limitations of their projects.
Although vm2 is quick to adopt and can create a sense of security, I want to discuss the architecturally insecure design of most in-process sandboxes, why they should not be relied upon, and why engineering effort is better spent on sandboxing approaches that provide real isolation.
This post is specifically about running untrusted or semi-trusted code (plugins, user scripts, multi-tenant workloads). If you fully trust the code you execute, you may not need a sandbox.
To being, let’s look at the patch for a recent sandbox escape vulnerability for vm2 (CVE-2026-22709), where it was possible to execute code on the host process via Promises handling.
There were two releases that attempted to address the vulnerability: v3.10.1, and v3.10.2.
In v3.10.1, the implementation of Promise.prototype.then and Promise.prototype.catch in lib/setup-sandbox.js was patched to ensure both global and proxied Promise handlers sanitise callback arguments before invoking them. Specifically, wrappers were added so the value or error delivered to callback functions is passed through ensureThis().
const globalPromiseCatch = globalPromise.prototype.catch;
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
resetPromiseSpecies(this);
if (typeof onFulfilled === 'function') {
const origOnFulfilled = onFulfilled;
onFulfilled = function onFulfilled(value) {
value = ensureThis(value); // Sanitisation is applied using ensureThis()
return apply(origOnFulfilled, this, [value]);
};
}
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error);
return apply(origOnRejected, this, [error]);
};
}
return globalPromiseThen.call(this, onFulfilled, onRejected);
};
globalPromise.prototype.catch = function _catch(onRejected) {
resetPromiseSpecies(this);
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error); // Sanitisation is applied using ensureThis()
return apply(origOnRejected, this, [error]);
};
}
return globalPromiseCatch.call(this, onRejected);
};
In v3.10.2, further hardening was applied. The patch replaces calls to Function.prototype.call with Reflect.apply(). This prevents sandboxed callback invocation from being hijacked by an adversary who overwrites or proxies Function.prototype.call.
// return globalPromiseThen.call(this, onFulfilled, onRejected); -- REMOVED
return apply(globalPromiseThen, this, [onFulfilled, onRejected]);
This patch works for this specific exploit, but the way it works exposes a deeper architectural problem.
The core flaw is that vm2 attempts to enforce isolation inside the same language runtime, which has no concept of privilege separation.
The approach taken to patch the vulnerability is to wrap a finite set of intrinsics and methods (e.g. Promise) that could lead to a sandbox escape. This design decision has the same failure mode as a blacklist. We disallow a finite set of items that we know can cause harm. If we miss an item, type, path, or introduce a new object, a sandbox bypass can emerge.
If application security is implemented merely by chasing exploitable scenarios, it shows a deep insecure design flaw. This approach does not guarantee safety and it remains open for future exploitation.
JavaScript was never designed to provide strong isolation boundaries within a single runtime. Unlike operating systems or hypervisors, JavaScript has no notion of privilege levels, no capability separation, and no immutable trust boundary once code is executing.
As a result, most JavaScript sandboxes end up as a whack-a-mole.
Almost any object in JavaScript can expose a path to arbitrary code execution. This is because objects expose a path to their constructor, and constructors ultimately lead to Function. Function is effectively a code interpreter at runtime: it executes whatever code is provided as input.
In JavaScript, reading a property can execute arbitrary code. It can invoke a getter, trigger toString or valueOf, or execute user-defined logic.
Consider the following object:
const obj = {
get secret() {
console.log("Getter executed");
return "sensitive value";
}
};
// Case 1
const value = obj.secret;
// Case 2
JSON.stringify(obj)
// Case 3
"" + obj
In all three cases, the property secret of obj is read. Each access executes the secret getter method, and any logic inside that getter runs immediately.
Furthermore, an adversary does not even need direct control over obj. They can mutate any shared prototype and overwrite getters. This exploitation technique is commonly used in prototype pollution vulnerabilities.
If you still believe it is possible to build a secure JavaScript sandbox, let’s look at some famous sandboxes that were repeatedly bypassed and eventually decommissioned.
AngularJS used a sandbox to stop injected Angular expressions from reaching dangerous capabilities. Over the years, researchers repeatedly found sandbox escapes, and Angular ultimately removed the sandbox in version 1.6 because people treated it as a security boundary when it wasn’t. Angular’s own security guide explicitly states that the sandbox was removed because it was always possible to access arbitrary JavaScript, and developers relied on it incorrectly.
Ultimately, I believe this is a good outcome. AngularJS sandbox created a false sense of security and the fix for it is to remove it.
Caja was an open-source security project by Google designed to run untrusted JavaScript code. It was a JavaScript sanitiser and sandbox that rewrote HTML, CSS, and JavaScript from untrusted sources into a “safe” subset that could run inside a page.
Unlike vm2, Caja was not a runtime sandbox. It was a static transformer that analysed, parsed, and rewrote untrusted JavaScript into a restricted subset.
Caja’s static rewriting approach introduced semantic gaps where the transformed code behaved slightly differently from the original. The order of evaluation changed, and other side effects were introduced. These gaps resulted in a number of bypasses, such as constructor escapes and prototype pollution.
For example, Caja attempted to hide Function and block exploits such as: Function("alert(1)")(); However, there were multiple ways to achieve the same result:
var f = someAllowedObject.toString;
var FunctionCtor = f.constructor;
FunctionCtor("alert(1)")();
Caja repeatedly attempted to block access to .constructor, but each attempt either broke compatibility or missed another exploitable path. This is similar to the situation of vm2 today.
By 2021 Google archived the project, explicitly stating it couldn’t keep up with modern web security research, and had multiple reported vulnerabilities.
JavaScript is a mutable nightmare. Built-ins (Object, Function, Promise, prototypes, and others) can be modified at runtime unless aggressively frozen. Blocking some of them is not enough. Even then, new features, APIs, and Symbols continue to appear, increasing the maintenance cost.
Ultimately, I believe the real harm is the false sense of security. When developers hear something is called sandbox, they stop doing the security, even when the documentation explicitly warns about its limitations.
Trying to contain untrusted code using code that runs within the same process, with the same privileges, is architecturally insecure and open to exploitation.
It is better to spend time and effort on security controls that place a real isolation boundary below the language runtime. From a design perspective, a sandbox should act like a hypervisor: it must run with higher privileges than the untrusted process and strictly limit API access, system calls, capabilities, and I/O for the untrusted code.
Let’s look at some sandbox implementations that follow this model.
A sandbox can be implemented as a virtual machine. This provides strong isolation. Firecracker and Kata Containers are two examples of using lightweight virtual machines as sandboxes. Even if an adversary gains full control of the virtual machine, a hypervisor escape is required, which makes exploitation extremely difficult.
gVisor is an application kernel that sits between the untrusted code (for example, a container) and the host operating system. gVisor does not have the full overhead of VMs. It intercepts application system calls and acts as a guest kernel.
seccomp, SELinux, AppArmor, and similar tools use kernel features to enforce fine-grained security policies for an application. While these tools can reduce the attack surface, in practice it is difficult to reliably define a correct policy for unknown or highly dynamic applications.
A sandbox can create a false sense of security if it is not implemented correctly.
If your application executes untrusted or semi-trusted code, the most important decision is where you draw the security boundary.
You should not rely on a JavaScript sandbox if:
In these cases, an in-process sandbox is defence-in-depth at best, not a security boundary.
A robust sandbox places the isolation boundary below the language runtime, not inside it.
Virtual machines (for example, Firecracker) provide strong isolation through hardware virtualisation. For container- or Kubernetes-friendly environments, Kata Containers or gVisor can be good choices, depending on performance constraints. OS-level jails (seccomp, AppArmor, SELinux) are useful for constrained, well-understood workloads, but they are difficult to apply safely to highly dynamic code.
As a simple rule of thumb: if your sandbox shares a process, memory space, and language runtime with the host, assume it will eventually be escaped.
Want to skill-up in secure coding and AppSec? Try SecDim Wargames to learn how to find, hack and fix security vulnerabilities inspired by real-world incidents.
Join our secure coding and AppSec community. A discussion board to share and discuss all aspects of secure programming, AppSec, DevSecOps, fuzzing, cloudsec, AIsec code review, and more.
Read more