cmd + R(esearch)

JS Sandbox Fundamentals: How VM2 Builds a JavaScript Sandbox

Architecture Overview

vm2's sandbox is built from three mechanisms working together:

  1. V8 context isolation via Node's vm module.
  2. A bidirectional Proxy membrane (the "bridge") that wraps every object crossing the host/sandbox boundary.
  3. AST instrumentation that patches catch, with, import(), and eval/Function at 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:

  1. Primitives (string, number, boolean, undefined, symbol, bigint, null) pass through unchanged. Proxies can only wrap objects and functions.
  2. Dangerous constructors (Function, AsyncFunction, GeneratorFunction, AsyncGeneratorFunction) are blocked and replaced with an empty frozen object.
  3. 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).
  4. 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's Array.prototype), the proxy is created with the corresponding host-side prototype, so instanceof checks work correctly in both realms.
  5. 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:

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:

  1. The code passes through the compiler (identity for JS, optional CoffeeScript/TypeScript).
  2. The transformer parses with Acorn and injects handleException into catch blocks, wrapWith into with statements, and redirects import().
  3. The instrumented code is compiled into a vm.Script.
  4. The script runs inside the sandbox context via script.runInContext(_context).
  5. The return value passes through bridge.from(), which wraps it in a Proxy if it is a non-primitive.
  6. 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