JS Sandbox Fundamentals: How VM2 Builds a JavaScript Sandbox
Architecture Overview
vm2's sandbox is built from three mechanisms working together:
- V8 context isolation via Node's
vmmodule. - A bidirectional Proxy membrane (the "bridge") that wraps every object crossing the host/sandbox boundary.
- AST instrumentation that patches
catch,with,import(), andeval/Functionat the source level.
Each layer compensates for gaps in the others.
V8 Context Isolation
The VM class (lib/vm.js) starts by creating a new V8 context:
const _context = createContext(undefined, {
codeGeneration: {
strings: allowEval, // controls eval() and Function() at the V8 level
wasm: allowWasm
}
});
vm.createContext() produces a new realm: its own Object, Array, Function, Error, and so on. Code running inside this context cannot directly reference host globals because V8 gives it a separate set of built-ins.
However, context isolation alone is not enough. When objects cross the boundary (passed as arguments, returned from functions, thrown as exceptions), they carry references to their original realm's prototypes. If a host object leaks into the sandbox, its prototype chain leads to the host's Function, enabling the ({}).constructor.constructor escape across realms.
The Bridge: vm2's Proxy Membrane
lib/bridge.js implements a bidirectional Proxy membrane: every object that crosses the boundary in either direction is wrapped in a Proxy, and every value returned from that Proxy's traps is recursively wrapped as well.
Initialization and Reflect Caching
The bridge code is loaded in both realms. The host runs bridge.js normally, then runs it again inside the sandbox context via vm.Script.runInContext. Each side exchanges references:
Host Sandbox
──── ───────
createBridge(sandboxInit) ──→ receives host's Reflect.*, WeakMap, prototypes
←── returns sandbox's Reflect.*, WeakMap, prototypes
At initialization, the bridge destructures every Reflect method into local variables and caches them:
const {
getPrototypeOf: thisReflectGetPrototypeOf,
apply: thisReflectApply,
get: thisReflectGet,
set: thisReflectSet,
// ... all 13 methods
} = Reflect;
This is a critical security measure. If sandbox code overwrites Reflect.get or Object.getOwnPropertyDescriptor, the bridge still holds the original references captured at init time. The same is done for WeakMap.prototype.get, WeakMap.prototype.set, Function.prototype.bind, Array.isArray, and other primitives the bridge depends on. Every call to these functions goes through the cached reference, never through a potentially tampered global.
How Wrapping Works
The central function is thisFromOtherWithFactory. When an object from the other realm needs to cross the boundary:
- Primitives (
string,number,boolean,undefined,symbol,bigint,null) pass through unchanged. Proxies can only wrap objects and functions. - Dangerous constructors (
Function,AsyncFunction,GeneratorFunction,AsyncGeneratorFunction) are blocked and replaced with an empty frozen object. - Cache check: a
WeakMap(mappingOtherToThis) is consulted. If this object was already wrapped, the existing proxy is returned. This preserves identity:bridge.from(obj) === bridge.from(obj). - Prototype chain walk: the object's prototype chain is traversed using the other realm's
Reflect.getPrototypeOf(the cached reference). Each prototype is checked against a mapping table. When a match is found (e.g., the object's prototype is the sandbox'sArray.prototype), the proxy is created with the corresponding host-side prototype, soinstanceofchecks work correctly in both realms. - Proxy creation: a target object is created with the correct prototype, a handler is instantiated, and
new Proxy(target, handler)is returned.
The get Trap: Blocking Prototype Escapes
vm2's BaseHandler.get intercepts every property read on a proxied object:
get(target, key, receiver) {
const object = getHandlerObject(this); // retrieve wrapped object from WeakMap
switch (key) {
case 'constructor': {
const desc = otherSafeGetOwnPropertyDescriptor(object, key);
if (desc) {
if (desc.value && isDangerousFunctionConstructor(desc.value)) return {};
return thisDefaultGet(this, object, key, desc);
}
const proto = thisReflectGetPrototypeOf(target);
if (proto === null) return undefined;
const ctor = proto.constructor;
if (isThisDangerousFunctionConstructor(ctor)) return {};
return ctor;
}
case '__proto__': { /* controlled access */ }
case 'arguments':
case 'caller':
case 'callee': { /* throw if own property on function */ }
}
// Default path: get from other realm, wrap result recursively
let ret = otherReflectGet(object, key);
return handlerFromOtherWithContext(this, ret);
}
The constructor case checks whether the value is one of the dangerous function constructors (Function, AsyncFunction, GeneratorFunction, AsyncGeneratorFunction) and returns an empty object {} only for those. Other constructors (like Array or custom classes) pass through normally after being wrapped. There is also a defense-in-depth check for host-realm function constructors, guarding against cases where an attacker could invoke the handler's get method directly (e.g., via util.inspect with showProxy: true).
The last line is the membrane in action: handlerFromOtherWithContext calls thisFromOtherWithFactory, which wraps the return value in another Proxy. Every property access on a proxied object returns another proxied object, maintaining the membrane across the entire object graph.
The has Trap: Hiding Properties
vm2 uses the has trap to filter dangerous cross-realm symbols:
has(target, key) {
if (isDangerousCrossRealmSymbol(key)) return false;
const object = getHandlerObject(this);
return otherReflectHas(object, key) === true;
}
The symbols Symbol.for('nodejs.util.inspect.custom') and Symbol.for('nodejs.rejection') are hidden because Node.js internals call functions stored under these symbols with host context, which can be exploited for sandbox escapes.
The same filtering is applied in ownKeys (which controls Object.keys(), for...in, and spread) and getOwnPropertyDescriptor.
Three Handler Types: Default, Protected, and Read-Only
vm2 provides three handler classes with different permission levels:
| Handler | Behavior |
|---|---|
BaseHandler |
Full read/write proxy. Blocks Function-family constructors. All returned values are recursively wrapped. |
ProtectedHandler |
Extends BaseHandler. Allows setting primitive values but blocks setting functions or defining accessors on the proxied object. Prevents sandbox code from injecting callable code into host objects. |
ReadOnlyHandler |
All mutation traps (set, defineProperty, deleteProperty, setPrototypeOf) return false. Used for objects like Buffer that should be visible but immutable from the sandbox. |
Invariants in Practice
The ECMAScript spec enforces Proxy invariants that traps cannot violate. vm2 encounters this directly in its isExtensible and preventExtensions traps:
isExtensible(target) {
const object = getHandlerObject(this);
if (otherReflectIsExtensible(object)) return true;
// If the other-realm object is non-extensible, the proxy target
// must also be made non-extensible to satisfy the invariant.
if (thisReflectIsExtensible(target)) {
doPreventExtensions(this, target);
}
return false;
}
When the real object becomes non-extensible, the proxy cannot claim it is still extensible (the spec forbids this). So doPreventExtensions copies all non-configurable property descriptors from the real object onto the proxy target, wrapping their values through the bridge.
This is also why vm2 carefully constructs its proxy targets. thisCreateTargetObject creates a target with the right shape (function, array, or plain object) and the right prototype, giving the bridge control over what invariants will be enforced.
AST Instrumentation
Proxy traps intercept JavaScript-level operations, but V8's internal algorithms operate on raw objects and bypass traps entirely. When V8 internally throws a TypeError (e.g., from a failed operation), the error belongs to the realm where the operation occurred. If sandbox code catches a host-realm error, the raw error's prototype chain leads to the host's Error, Object, and Function.
lib/transformer.js uses Acorn to parse code and inject instrumentation that Proxies alone cannot provide.
Catch Clause Instrumentation
The transformer injects handleException at the start of every catch block:
// Before transformation:
try { ... } catch (e) { console.log(e); }
// After transformation:
try { ... } catch (e) {
e=VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL.handleException(e);
console.log(e);
}
handleException passes the caught value through ensureThis, which checks whether the object belongs to the other realm and wraps it in a proxy if so. This applies the membrane to exception flow, which the Proxy mechanism has no trap for.
With Statement Instrumentation
// Before:
with (obj) { ... }
// After:
with (VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL.wrapWith(obj)) { ... }
wrapWith returns a Proxy whose has trap returns false for the internal state variable name. This prevents sandbox code from shadowing the internal state through a with target's properties.
Function and eval Proxies
vm2 uses apply and construct traps to proxy the Function constructor, eval, and their async/generator variants:
const FunctionHandler = {
apply(target, thiz, args) {
return makeFunction(args, this.isAsync, this.isGenerator);
},
construct(target, args, newTarget) {
return makeFunction(args, this.isAsync, this.isGenerator);
}
};
Both apply (for Function("code")) and construct (for new Function("code")) route through makeFunction, which passes the code through host.transformAndCheck before execution. This ensures dynamically generated code is also instrumented with the catch/with/import rewrites. Without these traps, an attacker could bypass all AST instrumentation by constructing functions at runtime.
Sandbox Global Patching
lib/setup-sandbox.js runs inside the sandbox context after the bridge is initialized and patches globals to close escape vectors that neither the bridge nor the transformer can handle alone.
Promise Sanitization
Promises are a significant attack surface because V8's internal promise resolution machinery (PromiseResolveThenableJob, ArraySpeciesCreate) operates on raw objects, bypassing proxy traps.
vm2 addresses this with several patches:
- A
localPromisesubclass is created with controlledSymbol.speciesbehavior. Promise.prototype.thenand.catchare wrapped to pass fulfillment values throughensureThisand rejection values throughhandleException.resetPromiseSpeciesforcibly defines an ownconstructordata property (not accessor) on promise instances beforethen/catchexecute, preventingSymbol.speciesattacks via TOCTOU on getter-based constructors.- All
Promisestatic methods (all,race,any,resolve,reject,allSettled,withResolvers,try) are wrapped to always uselocalPromiseas the constructor, ignoringthis. - Both
PromiseandPromise.prototypeare frozen.
Symbol.for Override
Symbol.for is overridden to return sandbox-local symbols for 'nodejs.util.inspect.custom' and 'nodejs.rejection'. Additionally, Object.getOwnPropertySymbols, Reflect.ownKeys, Object.getOwnPropertyDescriptors, and Object.assign are all overridden to filter these symbols from their results. The separate overrides are necessary because some of these methods use V8-internal [[OwnPropertyKeys]] which bypasses the Reflect.ownKeys override.
This is a case where the Proxy membrane alone is not sufficient. The ownKeys trap on the bridge proxies does filter these symbols, but sandbox code can also call Object.getOwnPropertySymbols on host objects that were exposed before wrapping, or extract symbols through V8 internal paths that skip traps.
Context Prototype Detachment
Reflect.setPrototypeOf(context, localObject.prototype);
When vm.createContext() creates the sandbox global, its prototype is the host's Object.prototype (this is how V8 context creation works). Without this line, sandbox code could walk the prototype chain of global to reach host objects directly.
Proxy Re-enablement
The sandbox initially sets global.Proxy = undefined to prevent unsanitized use, then re-enables it as a wrapped version:
const proxyHandler = Object.freeze({
apply(target, thiz, args) {
return localReflectApply(target, thiz, wrapProxyHandler(args));
},
construct(target, args, newTarget) {
return localReflectConstruct(target, wrapProxyHandler(args), newTarget);
}
});
global.Proxy = new LocalProxy(LocalProxy, proxyHandler);
This wraps the Proxy constructor itself in a Proxy so that any handler created by sandbox code has its trap arguments passed through ensureThis. Without this, a sandbox-created Proxy could receive raw host objects as arguments to its traps, bypassing the membrane.
The Execution Flow
Putting it all together, here is what happens when vm.run(code) is called:
- The code passes through the compiler (identity for JS, optional CoffeeScript/TypeScript).
- The transformer parses with Acorn and injects
handleExceptionintocatchblocks,wrapWithintowithstatements, and redirectsimport(). - The instrumented code is compiled into a
vm.Script. - The script runs inside the sandbox context via
script.runInContext(_context). - The return value passes through
bridge.from(), which wraps it in a Proxy if it is a non-primitive. - Any exception thrown during execution is also passed through
bridge.from()before being re-thrown to the host.
Every object that crosses the boundary goes through the bridge's thisFromOtherWithFactory, gets its prototype chain mapped, and is wrapped in a Proxy with the appropriate handler. The WeakMap cache ensures this is idempotent.
Escape CVE PoC
A nice PoC for an old escape that allowed RCE with the primitive execute code inside the vm2 sandbox