cmd + R(esearch)

JS Sandbox Fundamentals: Prototypes, Realms, and Why Sandboxing Is Hard

The previous post covered Proxy objects: traps, membranes, Reflect, invariants. That gives us the interception mechanism. This post covers the things we need to intercept and why.

JavaScript sandboxing is hard because of three things: prototype chains connect everything to everything, realms give you separate but structurally identical object graphs, and exceptions can smuggle objects across any boundary. Understanding these three is necessary before looking at how real sandboxes are built.

Prototype Chains

Every JavaScript object has an internal slot called [[Prototype]] that points to another object (or null). When the engine looks up a property that does not exist on an object, it follows [[Prototype]] to the next object and checks there, then follows that object's [[Prototype]], and so on until it either finds the property or reaches null.

const parent = { greeting: "hello" };
const child = Object.create(parent);

child.greeting;  // "hello" (found on parent via [[Prototype]])
child.x;         // undefined (not on child, not on parent, chain ends)

Object.create(parent) creates a new object whose [[Prototype]] is parent. Object.getPrototypeOf(child) returns parent. The legacy accessor __proto__ does the same thing: child.__proto__ === parent.

The default chain

Objects created with {} or new Object() have [[Prototype]] pointing to Object.prototype. Functions created with function or => have [[Prototype]] pointing to Function.prototype, which itself has [[Prototype]] pointing to Object.prototype.

myFunction  →  Function.prototype  →  Object.prototype  →  null
myObject    →  Object.prototype  →  null
myArray     →  Array.prototype  →  Object.prototype  →  null

This means Object.prototype is the root of almost every prototype chain. Any property set on Object.prototype is inherited by every object in the program. The same applies at each level: a property on Function.prototype is inherited by every function, a property on Array.prototype is inherited by every array.

constructor and the escape path

Every prototype has a constructor property pointing back to the function that created it:

Object.prototype.constructor === Object;     // true
Function.prototype.constructor === Function; // true
Array.prototype.constructor === Array;       // true

And every function is itself an object with [[Prototype]] = Function.prototype. Including Object:

Object.__proto__ === Function.prototype;   // true
Function.__proto__ === Function.prototype; // true

This circularity means that from any object, you can reach Function in at most two steps:

({}).constructor              // Object
   .constructor               // Function

And Function is dangerous because it is a code execution primitive:

Function("return this")();  // the global object
Function("return process.env")();  // Node.js environment variables

This is the fundamental problem for sandboxing: the prototype chain is a graph that connects every object to Function. Cutting the path from constructor to Function (as the safeWrap example in the previous post does) is necessary, but as we will see, there are many other paths.

__proto__ as a getter

__proto__ is not a regular property. It is defined as an accessor (getter/setter) on Object.prototype:

Object.getOwnPropertyDescriptor(Object.prototype, '__proto__');
// { get: [Function: get __proto__], set: [Function: set __proto__], ... }

The getter function, when called on any object, returns that object's [[Prototype]]. This is relevant for sandboxing because the getter itself is a function that can be extracted, bound, and called on arbitrary objects:

const getProto = Object.prototype.__lookupGetter__('__proto__');
getProto.call(someObject);  // same as someObject.__proto__

This technique appears in sandbox escapes. Instead of accessing .__proto__ through a proxy (where the get trap can block it), the attacker extracts the raw getter function and calls it directly on the underlying object.

Realms

A JavaScript realm is a self-contained environment with its own set of built-in objects: its own Object, Array, Function, Error, Promise, and so on. In a browser, every iframe has its own realm. In Node.js, vm.createContext() creates a new realm.

const vm = require('vm');
const context = vm.createContext();

const sandboxArray = vm.runInContext('[]', context);
const hostArray = [];

sandboxArray instanceof Array;        // false
hostArray instanceof Array;           // true
sandboxArray.constructor === Array;   // false

The sandbox's [] creates an array whose [[Prototype]] is the sandbox's Array.prototype, which is a different object from the host's Array.prototype. instanceof checks the prototype chain against the host's Array.prototype and does not find it, so it returns false.

This is both a security feature and a source of bugs:

Crossing the boundary

When a sandbox function returns an object to the host, or vice versa, the object keeps its original [[Prototype]]. A sandbox-created array still inherits from the sandbox's Array.prototype, even after the host receives it.

const vm = require('vm');
const context = vm.createContext({ callback: (val) => val });

const result = vm.runInContext('callback([1, 2, 3])', context);
// result is an array, but its prototype is the sandbox's Array.prototype

Array.isArray(result);                // true  (Array.isArray works cross-realm)
result instanceof Array;              // false (prototype chain mismatch)
Object.getPrototypeOf(result) === Array.prototype;  // false

This also means that if a host object leaks into the sandbox, the sandbox can walk its prototype chain to reach host-realm constructors:

// If "hostObj" is a host-realm object leaked into the sandbox:
hostObj.constructor.constructor  // host Function (not sandbox Function)

This is the cross-realm version of the constructor.constructor escape from the previous post. Separate realms do not prevent it; they just move the escape to the boundary where objects cross.

What vm.createContext does and does not do

vm.createContext() creates a new V8 context with separate built-in prototypes. It provides:

It does NOT provide:

This is why vm.createContext alone is not a sandbox. It gives you separate realms, but any object crossing the boundary is a potential escape vector. Something needs to sit at the boundary and intercept every crossing. That something is a Proxy membrane.

Why Naive Sandboxes Fail

The minimal sandbox from the previous post used a Proxy with has/get/set traps and a with statement to intercept global variable access. Here are the escape vectors it does not handle.

Escape 1: Exceptions from host functions

If the sandbox calls a host function and that function throws, the error object belongs to the host realm:

const sandbox = createSandboxGlobal(["JSON"]);

// Inside the sandbox:
try {
  JSON.parse("{invalid}");
} catch (e) {
  // e is a host-realm SyntaxError
  // e.constructor is host SyntaxError
  // e.constructor.constructor is host Function
  e.constructor.constructor("return this")();  // host global
}

The sandbox allowed JSON, and JSON.parse throws a host-realm SyntaxError on invalid input. The catch block receives a raw host object. From there, the constructor.constructor chain reaches the host's Function.

A real sandbox must intercept every exception that crosses the boundary and wrap it before the sandbox can access it.

Escape 2: Prototype chain walking through allowed objects

Even if the sandbox only exposes Math, parseInt, and JSON, these are host-realm objects with host-realm prototype chains:

const sandbox = createSandboxGlobal(["Math"]);

// Inside the sandbox:
Math.__proto__.constructor  // host Object
  .constructor              // host Function

Math is an ordinary host object. Its [[Prototype]] is Object.prototype (of the host realm). From there, .constructor gives Object, and .constructor again gives Function.

A real sandbox must either wrap every exposed object in a Proxy that blocks prototype traversal, or expose sandbox-realm copies instead of the host originals.

Escape 3: Symbol.species and V8 internal algorithms

Some JavaScript operations use V8-internal algorithms that read properties and create objects without going through Proxy traps. Symbol.species is the most important example.

When Array.prototype.map creates its result array, V8 reads this.constructor[Symbol.species] to determine which constructor to use. If an attacker controls constructor on a host array, they can redirect the species resolution to a function that returns an existing object. V8 then writes map callback results directly into that object at the C++ level, bypassing any Proxy.

This is not something a JavaScript-level sandbox can intercept with Proxy traps alone. It requires either patching the array before the operation or preventing the attacker from setting constructor in the first place.

Escape 4: The prototype of the global object

When vm.createContext() creates a new context, the sandbox's global object inherits from the host's Object.prototype. This is a V8 implementation detail. It means sandbox code can reach host objects by walking up from global:

// Inside a vm.createContext sandbox, before patching:
this.__proto__  // host Object.prototype
  .constructor  // host Object
  .constructor  // host Function

Real sandboxes must detach this link by replacing the global's prototype with the sandbox's own Object.prototype.

The Membrane Pattern

The previous post mentioned membranes briefly. A membrane is a Proxy layer that wraps every object crossing a boundary, and recursively wraps every object returned from that Proxy. It is the only pattern that provides full interposition between two object graphs.

Basic structure

const wrapCache = new WeakMap();

function wrap(raw) {
  if (typeof raw !== 'object' && typeof raw !== 'function') return raw;
  if (raw === null) return null;

  const cached = wrapCache.get(raw);
  if (cached) return cached;

  const proxy = new Proxy(raw, {
    get(target, key) {
      if (key === 'constructor') return undefined;
      const value = Reflect.get(target, key);
      return wrap(value);  // recursive wrapping
    },
    set(target, key, value) {
      return Reflect.set(target, key, unwrap(value));  // unwrap before writing
    },
    apply(target, thisArg, args) {
      const result = Reflect.apply(target, unwrap(thisArg), args.map(unwrap));
      return wrap(result);
    },
  });

  wrapCache.set(raw, proxy);
  return proxy;
}

Identity preservation

Without the WeakMap cache, wrapping the same object twice would produce two different proxies. This breaks identity comparisons:

const a = wrap(hostObj);
const b = wrap(hostObj);
a === b;  // false without cache, true with cache

Identity preservation is critical. If a sandbox function stores a reference and later compares it, === must work correctly. The WeakMap ensures that each raw object maps to exactly one proxy.

Unwrapping

When the sandbox passes an object back to a host function, the membrane needs to unwrap it from its proxy back to the raw host object. Otherwise the host function receives a Proxy instead of the real object, which can cause unexpected behavior or break internal type checks.

This requires a reverse mapping: proxy → raw object. A second WeakMap stores this direction:

const unwrapCache = new WeakMap();

// During proxy creation:
unwrapCache.set(proxy, raw);

// During unwrapping:
function unwrap(value) {
  return unwrapCache.get(value) || value;
}

This unwrap mechanism introduces its own risk. If the raw object is modified after the proxy was created and cached, the membrane will still unwrap the proxy to the (now-modified) raw object. The membrane has no way to detect that the raw object's contents have changed since the proxy was first created. This becomes relevant when we look at real sandbox escapes.

Bidirectional membranes

A full sandbox membrane is bidirectional: objects flowing from host to sandbox are wrapped by one set of handlers, and objects flowing from sandbox to host are wrapped by another. Each side maintains its own WeakMap caches. The bridge needs references to both sides' Reflect methods (cached at initialization time, to prevent tampering) and a mapping table for prototype equivalences so that instanceof works across the boundary.

This is a significant amount of infrastructure, and it is the core of what makes sandbox libraries like vm2 work. The next post examines how vm2 implements all of this.