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
npm install vm2Quick Examples
import { VM } from 'vm2';
const vm = new VM();
vm.run(`process.exit()`); // TypeError: process.exit is not a functionimport { 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:
- New bypasses will likely be discovered in the future. Check our security advisories for known vulnerabilities.
- You must keep vm2 updated to benefit from the latest security fixes. Subscribe to security advisories and update promptly.
- 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 indocs/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 bufferVMScriptfilename/lineOffset/columnOffsetmetadata is not
observable, because JSC's CallSite objects carry no methodsObject.freezeon a frozen host object with a non-configurable accessor
throws a proxy-invariantTypeErrorwhere V8 does not- some
Bufferoperations across the sandbox boundary are drastically slower β
a 64 MBallocUnsafetakes 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:
import { runInNewContext } from "node:vm";
runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('Never gets executed.');import { VM } from 'vm2';
new VM().run('this.constructor.constructor("return process")().exit()');
// Throws ReferenceError: process is not definedDocumentation
- VM
- NodeVM
- VMScript
- Error handling
- Debugging a sandboxed code
- Read-only objects
- Protected objects
- Cross-sandbox relationships
- CLI
- 2.x to 3.x changes
- 1.x and 2.x docs
- Contributing
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 withallowAsync=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 withtypeofand 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,coffeescriptor custom compiler function. The library expects you to have compiler pre-installed if the value is set totypescriptorcoffeescript.typescriptrequirestypescript@6or earlier β see Compilers.eval- If set tofalseany calls toevalor function constructors (Function,GeneratorFunction, etc.) will throw anEvalError(default:true).wasm- If set tofalseany attempt to compile a WebAssembly module will throw aWebAssembly.CompileError(default:true). Note:WebAssembly.JSTagis removed inside the sandbox for security reasons, so wasm code cannot catch JavaScript exceptions.allowAsync- If set tofalseany attempt to run code usingasyncwill throw aVMError(default:true).bufferAllocLimit- Maximum size in bytes for a singleBuffer.alloc/Buffer.allocUnsafe/Buffer.allocUnsafeSlow/Buffer(N)/new Buffer(N)request from inside the sandbox. Requests that exceed this cap throw aRangeErrorsynchronously 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 intotimeout. 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.
import { VM } from 'vm2';
const vm = new VM({
timeout: 1000,
allowAsync: false,
sandbox: {},
});
vm.run('process.exit()'); // throws ReferenceError: process is not definedYou can also retrieve values from VM.
let number = vm.run('1337'); // returns 1337TIP: 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-inheritto enable console,redirectto redirect to events,offto disable console (default:inherit).sandbox- VM's global object.compiler-javascript(default),typescript,coffeescriptor 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 totypescriptorcoffeescript.typescriptrequirestypescript@6or earlier β see Compilers.eval- If set tofalseany calls toevalor function constructors (Function,GeneratorFunction, etc.) will throw anEvalError(default:true).wasm- If set tofalseany attempt to compile a WebAssembly module will throw aWebAssembly.CompileError(default:true). Note:WebAssembly.JSTagis removed inside the sandbox for security reasons, so wasm code cannot catch JavaScript exceptions.bufferAllocLimit- Same semantics as onVMβ maximum size in bytes for a singleBuffer.allocfamily 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 enablerequiremethod (default:false).require.external- Values can betrue, 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.sandboxto 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 forevents, 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 therequirefunction to load modules from the host.require.strict-falseto 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.trueto enable VMs nesting (default:false).wrapper-commonjs(default) to wrap script into CommonJS wrapper,noneto retrieve value returned by the script.argv- Array to be passed toprocess.argv.env- Object to be passed toprocess.env.strict-trueto 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.
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.
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.
vm.run('require("foobar")', '/data/myvmscript.js');If the script you are running is a VMScript, the path is given in the VMScript constructor.
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.
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.
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.
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(...):
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:
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.
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
debuggerkeyword. - You can use step-in to step inside the code running in the sandbox.
Example
/tmp/main.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
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:
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 0Example with using freeze:
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 2IMPORTANT: 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
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.
vm2 ./script.jsHardening 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:
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:
// 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:
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:
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
typescriptcompiler does not work with TypeScript 7 or newer, which removed thetranspileModule()API vm2 uses. Usetypescript@6or 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:
- Check if any code (including test fixtures, examples, or "utility" helpers) matches the patterns in the family files.
- Pay special attention to code that accesses
.constructor,__proto__,Symbol.species,Proxy,Reflect, or overrides built-in prototype methods. - Any code that attempts to obtain a reference to host-realm
Function,process,require, orchild_processfrom within sandbox context is an escape attempt. - The ultimate goal of almost every escape is: get a reference to the host
Functionconstructor, then callFunction("return process")()to access Node.jsprocessand from thererequire("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 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 asGHSA-xxxx (dup of GHSA-yyyy). Writenonefor categories that predate the advisory process.**Tests**:β required. Paths to the regression tests:test/ghsa//directories, and suite tests cited astest/vm.js ("<describe title>").**Uses**:β present on most techniques and compounds (a linked list of the prerequisite categories the attack composes); theKindcolumn 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:
- 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.
- Add its row to the category index below.
- Add a row to Summary β How The Bridge Defends and, for a compound, an entry to Summary β Compound Attack Patterns.
- Run
npm test;test/docs-catalog.jschecks numbering, links, and metadata. - Add a one-line entry to
CHANGELOG.mdunder 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:
catchblocks: Wrapped so thathandleException(e)is called on every caught value. This sanitizes host-realm errors that V8 might throw (e.g., TypeError from type coercion failures).withstatements: 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:
const e = new Error();
e.name = Symbol(); // Symbol cannot be converted to string
e.stack; // V8's C++ FormatStackTrace calls e.toString() -> TypeErrorWhen 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:
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.
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/ensureThisis the single chokepoint; the WeakMap caches preserve identity.All caught exceptions are sanitized. Every value entering a
catchclause passes throughhandleException. Paths that bypass JS-levelcatchinstrumentation (Wasmtry_table, host-realmPromise.thenrejection) are closed at the bridge.Cross-realm error containers are recursively sanitized.
SuppressedError,AggregateError, andError.causemay carry host references in nested fields.handleExceptionwalks the structure with cycle detection.V8 internal algorithms cannot read attacker-controlled
constructoron host objects. ArraySpeciesCreate, PromiseResolveThenableJob, and similar C++ paths bypass proxy traps. The bridge neutralises rawconstructorslots on host arrays before every host-side call (neutralizeArraySpeciesBatch, vianeutralizeArraySpeciesOn) and pre-setsPromise.constructoras an own data property before.then/.catch(resetPromiseSpecies).Error.prepareStackTracealways resolves to a sandbox-realm safe default. V8 must never fall back to the host'sprepareStackTraceCallback. SettingError.prepareStackTrace = undefinedin the sandbox restores the safe default rather than removing it.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).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.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.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 atecmaVersion: 2022). Defenses must hold even when no transformer instrumentation runs over the relevant scope.Dynamic code compilation paths cannot reach an unwrapped host realm.
Function,evalwith host references, and dynamicimport()are blocked or proxied.import()throwsVMErrorunconditionally.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.applyover cachedWeakMap.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 onArray.prototype[N]orObject.prototype.<key>can capture or mutate the bridge's raw saved state.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.prototypein its[[Prototype]]chain β so the sandbox-side.then/.catchoverrides apply β or (b) a bridge proxy of a host-realm Promise β so the bridgeapply-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/.finallylookup walks across realms to host native methods directly,Object.defineProperty(p, 'constructor', ...)writes onto the raw object, and V8's host-realmSpeciesConstructordispatches 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.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 writesbuiltinβ wildcard, explicit name, object syntax, low-levelmakeBuiltins. The check is family-prefix andnode:-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 throughSPECIAL_MODULES,mocks, oroverridesβ i.e. the embedder must consciously opt into a stub that is not the raw host module.allowAsync: falseis a closed synchronous boundary. When async is disabled, no sandbox-reachable operation may schedule code into a later microtask β there is nothing to run afterrun()returns. BlockingPromise.prototype.thenis 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 viaObject.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:
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).
- Prototype Pollution + Proxy Trap [Categories 2, 6]: Pollute
Object.prototypeto inject trap handlers, then trigger the trap via bridge operations. - Symbol.species + Async Error [Categories 3, 7]: Set
Symbol.speciesto custom class, trigger host error in async path, receive unsanitized error in custom class constructor. - Built-in Override + Type Coercion [Categories 10, 11]: Override
ArrayorObject.create, then pass object withvalueOf()toBuffer.from()to trigger the override. - Monkey-patch + Promise [Categories 7, 11]: Override
Function.prototype.call, then triggerPromise.then()to intercept internal callback dispatch. - Object.defineProperty disable + Species Attack [Categories 3, 11]: Override
Object.definePropertyto no-op, preventing species reset, then exploit unprotected species. - Symbol Extraction + Array Monkey-patch [Categories 8, 11]: Override
Array.prototype.splice/pushto no-op, then callObject.getOwnPropertySymbols(hostObj)hoping the filter uses array methods. - Internal [[OwnPropertyKeys]] + Proxy Trap [Categories 6, 8]: Call
Object.assign(proxyTarget, hostObj)whereproxyTargetis a Proxy with asettrap to leak real symbols. - Constructor Accessor TOCTOU + Species Attack [Categories 3, 7]: Define a getter on
p.constructorthat returnsPromiseon first read (passes check) but returns maliciousSymbol.specieson subsequent reads. - Prototype Mutation + Species TOCTOU [Categories 2, 7]: Access
globalPromise.prototypeand replaceconstructordata property with accessor. Own-property checks miss inherited accessors. - Symbol.hasInstance + Species Attack [Categories 3, 7]: Override
Symbol.hasInstanceonglobalPromisesoinstanceoffails, causingresetPromiseSpeciesto skip. - 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.
- Reflect.construct instanceof bypass + Species [Categories 3, 7]: Use
Reflect.construct(Promise, [...], FakePromise)to bypassinstanceofguard, combined withFakePromise[Symbol.species] = FakePromise. - Duck-typing + showProxy + Handler Exposure [Categories 8, 9]: Create Buffer duck-type, invoke inspect with
showProxy: true, probe exposed handler. - Duck-typing + showProxy + fromOtherWithContext [Categories 8, 9]: Same as 13 but specifically exploits
fromOtherWithContextmethod. Variant:doPreventExtensions. - Host prepareStackTrace + Array.fromAsync + SuppressedError [Categories 4, 7, 16, 19]: Walk prototype chain to get host Array, call
Array.fromAsyncfor host promise, useusing+ Error Generation Primitive to create SuppressedError with host TypeError, catch via host promise's unsanitized.catch(). - Duck-typing + showProxy + handler.get() with forged target [Categories 1, 9]: Obtain handler via showProxy, use host-side
Array.reduceto callhandler.get(rawHostFunction, 'constructor'), leaking raw hostFunctionvia prototype chain fallback. - Descriptor Extraction + Object.entries + Array Unwrap [Categories 1, 15]: Get property descriptor for
Function.prototype.constructor, build nesting via chainedgetOwnPropertyDescriptors, extract withObject.entries(). Defense: recursivecontainsDangerousConstructorwith cycle detection. - SuppressedError + Symbol Name Trick [Categories 4, 16]:
DisposableStack.defer()orusingwith dispose method triggering Error Generation Primitive.SuppressedErrorwraps unsanitized host TypeError. Also works withAsyncDisposableStack. - 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/catchcatches and returns unsanitized externref. - Array Species Self-Return + Object.assign [Categories 3, 10, 18]: Create host array, set up self-referential species constructor, inject via
Object.assign(bypasses proxysettrap), callr.map(f)for raw host values. Chaincwucalls to extract hostFunction. - Host Built-in Identity Leak via Proto Walk [Categories 1, 2, 8]: Walk the prototype chain via
({}).__lookupGetter__('__proto__')composed withBuffer.apply(or any host-bound__proto__getter) to terminate at hostObject.prototype, then read.constructorto obtain a reference to hostObjectwhose identity is disjoint from sandboxObject. The original symbol-filter patch (commit67bc511) 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 bythisAddIdentityMappinginlib/bridge.js(see Category 8 mitigation). - Async Generator yield-Return Thenable + Stack-Overflow Realm Skew* [Categories 4, 7, 29]: Use
yield*to a no-returninner async iterator, then.return(thenable)where the thenable's.thensynchronously throws via deep recursion. V8'sPromiseResolveThenableJobcaptures the throw and the yield* continuation surfaces it as{ value, done: false }β bypassing both the transformer's user-catchinstrumentation and theglobalPromise.prototype.thenrejection sanitiser. Binary-search the recursion depth where the overflow originates inside V8's host C++ code so theRangeErroris host-realm, thene.constructor.constructor("return process")(). Closed by wrapping%AsyncGeneratorPrototype%.next/.return/.throwto route iterator-result.valueand rejections throughhandleException, plus replacing every thenable arg with a sandbox-realm wrapper whose.thenis a fixedsafeThenand always-shadowing the non-function branch so V8's re-read of.thencannot observe attacker-controlled values. - Host Prototype Mutation via Apply-Trap Indirection + WebAssembly Rejection [Categories 2, 4, 7, 30]: Resolve host
Object.prototype.__proto__setter viaBuffer.call.call({}.__lookupSetter__, Buffer, "__proto__")(theconnect()-aliased sandbox__lookupSetter__walks back to host). Trigger a host-realmTypeError(e.g.,await WebAssembly.compileStreaming()). Insidecatch(e), callsetProto.call(getProto.call(e), null)β the apply trap unwrapscontextand forwards to the host setter, severing hostTypeError.prototype.[[Prototype]]without any write trap firing. The next hostTypeErrorfromawait 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.constructoris then hostFunction. 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 forFunction.prototype.{call,apply,bind}andReflect.{apply,construct}; (B) cache-check onmappingOtherToThisbefore the proto-walk inthisEnsureThisso any previously-bridged host value returns the existing proxy even with a tampered proto chain. - 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 insidelocalPromiseto a sandbox-controlledct.ctrebinds V8's internal(resolve, reject)capability to a sandbox collector; trigger a host-realmRangeErrorviae.stackafter deep recursion (binary-searched depth) inside the downstream chain; V8'sPromiseResolveThenableJobdelivers the raw host Error to the collector βex.constructor.constructor("return process")()then yields RCE. Closed by addingresetPromiseSpecies(this)immediately before the swallow-tailapply(globalPromisePrototypeThen, this, ...)call so the species protocol always resolves tolocalPromiseregardless of the user's subclassSymbol.speciesoverride. - WebAssembly JSPI Cross-Realm Promise + Species Hijack [Categories 3, 7, 33]: JSPI returns a sandbox-realm Promise with host-realm
Promise.prototypein its[[Prototype]]chain β bypassing both the sandbox-side.then/.catchoverrides and the bridgeapply-trap callback wrapping. InstallObject.defineProperty(p, 'constructor', {get(){return F}})directly on the raw object;p.finally(()=>{})calls hostPromise.prototype.finally, whose internal SpeciesConstructor reads F and dispatches the eventual host-realm rejection (hostTypeErrorfromWebAssembly.compileStreaming(Promise.resolve(0))) through F's reject closure with no bridge wrapping.e.constructor.constructor("return process")()evaluates in host realm becauseFunction.[[Realm]]is host β RCE. Closed by deletingWebAssembly.promisingandWebAssembly.Suspendingat sandbox bootstrap, mirroring theWebAssembly.JSTagremoval. - 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/catchoverrides were installed by plain assignment, which left thePromiseThenLookupChainprotector valid;p.finally()on an ordinary(async () => 1)()Promise took theInvokeThenfast path to the nativethenand never entered vm2's wrapper, soresetPromiseSpeciesnever ran. An ownconstructorwithSymbol.speciespointing at a sandbox class then received the native reaction's resolve/reject; driving that reaction into a calibrated stack overflow delivered a raw host-realmRangeErrorto the attacker's reject closure βe.constructor.constructorβ hostFunctionβ hostprocess, witheval: falseandwasm: false. Closed by installing the wrappers viaReflect.defineProperty(which invalidates the protector) and by wrappingPromise.prototype.finallyto runresetPromiseSpecies(this)before the cached native call. - 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, butObject.getOwnPropertyDescriptor(cfg, 'level').set(or__lookupSetter__,Reflect.getOwnPropertyDescriptor,Object.getOwnPropertyDescriptors) returns a live bridge-wrapped host setter.desc.set.call(cfg, value)routes throughBaseHandler.applyonto the unwrapped host object, mutating host state through a read-only view. Closed by overridingReadOnlyHandler.getOwnPropertyDescriptorDescto strip thesetaccessor before it is wrapped, leaving the getter operative β a single hook that closes all four descriptor-read channels. - External Allowlist Substring Collision + Subpath Traversal [Categories 21, 24, 45]: With
require: {external: ['left-pad'], resolve, context: 'host'}, theexternalCachepre-check inLegacyResolver.customResolveused unanchored regexes, sorequire('evil-left-pad')passed by substring containment; the embedder's custom resolver then located the colliding package in the application's dependency directory,customResolveappended the resolved path tothis.externals, and hostrequire()ran its top-level code in host context (hostchild_processfrom a sandbox configured withbuiltin: []). Anchoring the matcher to^(?:)(?:[\\/].*)?$closed the collision but not the second stage: the permitted subpath tail accepts..segments, soleft-pad/../evil-packageandleft-pad/sub/../../evil-packagewalked 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 (realpathwould erase the..evidence). - 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 viaObject.getPrototypeOf(Promise)β so V8'sPromiseResolveThenableJobruns the attacker.thenin a microtask afterrun()returns, outsidetimeout. Closed by guarding every resolve capability (TOCTOU-safe, refuses object/function without reading.then), throwing the static methods, neutralizingArray.fromAsync, and interposing a construct-guard Proxy onlocalPromise's prototype so the native base is un-constructable from the sandbox β all gated toallowAsync: false. - 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.SpeciesConstructorreads 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:neutralizeHostPromiseSpeciesOnshadows the host promise'sconstructoracross the call. - 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 forthis, and whatever the function returns or stashes fromthisis the host global βprocess.getBuiltinModule('child_process')follows. Defense: the host global is refused at every hostβsandbox coercion chokepoint, delivered asundefined.
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:
- Does this change expose any new return path for host objects? Every return value from proxy traps and bridge functions must be sanitized.
- 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.
- Does this method accept parameters that could be attacker-controlled? Parameters like
target,receiver, or callback arguments may be forged. - Are all Reflect. calls using cached references?* Sandbox-side
Reflectoverrides must not affect bridge internals. - Could this path be triggered by V8 internal algorithms (bypassing proxy traps)? V8 C++ code like ArraySpeciesCreate, FormatStackTrace, and PromiseResolveThenableJob operate on raw objects.
- 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.
- 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 theObject.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: falseon 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 removedProxyfrom Node 8 sandboxes
once already. Reflect.definePropertycannot substitute for the plain assignment: on Node 8
it returnstrueand stores nothing.
test/vm.js carries an AST-based guard that fails if any write to the sealedError / 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
thisFromOtheralways re-wraps values crossing the boundary, so a weak reference cannot leak a raw host object. However,FinalizationRegistryopened a separate timeout-bypass / DoS surface (its cleanup callback runs afterrun()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
vmcontext globals. Even if available,structuredClonestrips 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,TypedArrayandWebAssembly.Memoryby Category 36.Error.cause on sandbox-created errors: Set by sandbox code on sandbox-realm errors, so
ensureThishandles it through normal property access on proxied errors.Error.causeon 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 useArraySpeciesCreate. 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 setsconstructor = undefinedon 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.custominstalled on host-side proxy targets (issue #566, 3.11.5; the handler-exposure surfaceutil.inspectopened before is Category 9): To restore correctutil.inspectoutput on Node 26+ (which reads the symbol directly off the raw[[ProxyTarget]]slot, bypassing thegettrap),lib/bridge.jsinstalls a host-realm function underSymbol.for('nodejs.util.inspect.custom')on every host-side proxy target. The function walksthisvia cachedReflect.{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 existingisDangerousCrossRealmSymbolfilter inget/ownKeys/getOwnPropertyDescriptortraps returnsundefinedfor the host's marker symbol β confirmed across 10 extraction vectors includinggetOwnPropertyDescriptors,__lookupGetter__,Object.assign, spread, prototype-chain walks; (2) sandboxSymbol.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 asconfigurable: trueso the trap returningundefineddoes not violate proxy invariants; (4) the install uses module-cachedthisReflectDefineProperty, immune to runtime poisoning ofReflect.defineProperty; (5) the function'scatchfallback returns a static literal β no host reference is reachable; (6) aWeakMap(thisInspectInFlight) bounds recursion on self-referential graphs and clears viafinally, 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.jsandevents.jsreach the sandbox realm as string literals from the generatedlib/sources.js(built byscripts/build-sources.js) instead offs.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, soisDangerousCrossRealmSymbolmust 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
usingin ES2024) must be evaluated for transformer coverage. The transformer'secmaVersion: 2022limitation 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 theawait usingform 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
- Create a Proxy with a trap handler (e.g.,
getOwnPropertyDescriptor,getPrototypeOf,construct). - Pass the Proxy to a host function (e.g.,
Buffer.from()) that the bridge will inspect. - When the bridge inspects the Proxy's properties, the trap executes.
- The trap either throws a function (which receives host references as arguments in the catch block) or modifies
Object.prototypeto inject further traps.
Canonical Examples
// 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
- Duck-typing exploit:
Buffer.prototype.sliceinternally callsthis.subarray(start, end). By settingsubarraytoBuffer.prototype.inspect, callingsliceinvokesinspectwith attacker-controlled arguments. - Inspect options access:
Buffer.prototype.inspectaccepts inspect options including callbacks likestylize. Inside these callbacks,thisrefers to the internal inspect context. - Handler exposure via showProxy: When
showProxy: trueis set, the inspect context'sseenarray can contain proxy handler objects. - Handler probing: Attacker enumerates handler properties/methods looking for anything that returns raw unwrapped host objects.
- Constructor chain escape: With access to a raw host function, the attacker climbs
func.constructor.constructor("return process")()to escape.
Canonical Examples
// 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 injectionWhy 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:
- Construction token (GHSA-v37h-5mfm-c47c):
createBridge()captures an unforgeable module-localSymbol('vm2 bridge handler construction')in closure. EveryBaseHandler/ProtectedHandler/ReadOnlyHandler/ReadOnlyMockHandlerconstructor requires this token as its first argument and throwsVMError(OPNA)otherwise. All legitimate construction sites (defaultFactory,protectedFactory,readonlyFactory, and the closure-scopedcreateReadOnlyMockHandler/newBufferHandlerhelpers used bysetup-sandbox.js) inject the token from closure. Subclass construction viaclass X extends pp.constructor { constructor(o){super(o);} }fails becausesuper(o)seestoken = orather than the real sentinel.Reflect.construct(Handler, [s])andReflect.construct(Handler, [s], altNewTarget)fail identically. getHandlerObjectWeakMap guard (GHSA-v37h-5mfm-c47c): the closure-scopedgetHandlerObject(handler)now explicitly checkshandlerToObject.has(handler)and throwsVMError(OPNA)if not β so trap methods invoked on a sandbox-forged receiver (Object.setPrototypeOf({}, pp),pp.set.call(forged, ...)) refuse to operate rather than returningundefineddeeper into the trap body.- Constructor-property sentinel rebind (GHSA-v37h-5mfm-c47c): the
.constructorproperty on every handler prototype (BaseHandler.prototype,ProtectedHandler.prototype,ReadOnlyHandler.prototype,ReadOnlyMockHandler.prototype, plus subclasses defined insetup-sandbox.jslikeBufferHandlervia therebindHandlerConstructorhelper) is replaced with ablockedHandlerConstructorfunction that unconditionally throwsVMError(OPNA). Prototype-chain walks from any leaked handler never reach a callable form of the real class. validateHandlerTarget(GHSA-qcp4-v2jj-fjx8): a closure-scopedhandlerToTargetWeakMap pairs every handler with its canonical proxy target at construction time. Every trap method onBaseHandler(16),ProtectedHandler(2 overrides),ReadOnlyHandler(6 overrides), andReadOnlyMockHandler(1 override) callsvalidateHandlerTarget(this, target)at entry, which rejects (a) handlers not in the WeakMap (forgedthis) and (b) trap calls whosetargetargument doesn't strict-equal the canonical target paired with that handler at construction (forgedtarget). Both reject withVMError(OPNA). The Proxy machinery always supplies the canonical target, so legitimate dispatch is unchanged. This closes the showProxyseen[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.inspectassigned to object properties -- duck-typing setup.Buffer.prototype.slicecalled on non-Buffer objects.showProxy: truein inspect options.this.seenaccess instylizeor 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-sideArray.reduceto 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 viagetHandlerObjectWeakMap guard).handler.getPrototypeOf(Buffer)/handler.set(Buffer, key, val)/ any trap with a forgedtarget-- a real registered handler invoked with an attacker-supplied first argument to walk host prototypes (now blocked viavalidateHandlerTargetstrict-equality check againsthandlerToTarget, 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
- Override
Function.prototype.call,.apply,.bind, orObject.definePropertyin the sandbox. - Trigger a bridge operation (e.g., passing a value across the boundary).
- The bridge's internal operations use the overridden methods, executing attacker code.
Canonical Examples
// 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.definePropertiesonObject.prototypeorFunction.prototype.- Assigning
nullto__proto__on fundamental prototypes. - Override of
Reflect.apply,Reflect.construct,Reflect.defineProperty, etc. - Override of
Object.definePropertyitself.
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
- Set
Object.prototype.has = function(target, key) { ... }. - Use the
inoperator on a bridge-proxied object:"property" in Buffer.from. - Since the proxy handler inherits from
Object.prototype, the pollutedhasbecomes the trap. - The trap receives
target(the raw unwrapped target) as its first argument.
Canonical Examples
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 onObject.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
- 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. - Or
Reflect.get(globalThis, 'β¦')/Object.getOwnPropertyDescriptor(globalThis, 'β¦')/'β¦' in globalThisβ same. - Or simply
Object.getOwnPropertyNames(globalThis).filter(n => n.startsWith('VM2_'))to discover the canonical name without hardcoding it, then read with bracket access. - 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
// (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 }// 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):
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:
- 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'slocalEval), 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. - 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 returnundefined/false/ no entry. - Persistent across
runInContextcalls in the same context. User scripts that legitimately rely on top-levellet x = β¦carrying over to a latervm.run(...)continue to work β those declarations land in the same record, and the bootstrap'sletis 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]orReflect.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 onglobalThisfrom 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
interanStateobject inlib/setup-sandbox.jsmust continue to be covered by the GlobalLexicalEnvironment binding; do not re-introduce adefinePropertyonglobal"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
letin 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 newlet.
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 aSyntaxErrorbecause top-levellets 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 viaglobalThis['VM2_β¦']from inside a Function body returnsundefinedfor 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_β¦"]')returnsundefined; 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 userwith()heads withwrapWith(), which wraps the head expression in a Proxy whosehastrap returnsfalseforINTERNAL_STATE_NAMEβ so even a dynamically-namedwithhead cannot expose the binding via the with-scope's identifier resolution path.- Multiple VMs sharing a process. Each
new VM()constructor creates its ownvm.Context, which has its own GlobalLexicalEnvironment. The bootstrap'sletis 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:
- Setter on Array.prototype[0]: sandbox code defines
Object.defineProperty(Array.prototype, 0, { set(value) { ... } }). - 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-realmFunctioninstance whoseconstructor.constructoris hostFunction. - Trigger the apply/construct trap:
new Buffer(a)(or any host function call with a sandbox array argument). The bridge runsargs = otherFromThisArguments(args)and thensavedSpecies = neutralizeArraySpeciesBatch(context, args). - Setter fires on the bridge's own state:
saved[saved.length] = cwalks the sandboxArray.prototypechain, hitsArray.prototype[0]'s setter, and handsc(containing the host-realm proxyarr) to attacker code. - Pivot through the leaked proxy:
value.arr.f.constructor.constructor("return process")()reaches hostprocess, thenmainModule.require('child_process').execSync(...)for arbitrary command execution.
Variant B β stack-trace formatter:
- 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). - Trigger the formatter: any access to
error.stackruns the wrappeddefaultSandboxPrepareStackTrace. The wrap is installed by theError.prepareStackTracesetter and routes the default through the sameCallSitewrapping path as user-provided formatters. - 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. - Read-side variant: even with the indexed writes hardened,
lines.join('\n')looks upArray.prototype.joinvia the prototype chain. A sandbox override receives the lines array and can rewrite the final stack string.
Canonical Examples
Variant A β RCE:
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:
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 orerror.stackread β classic shape of either variant; near-zero legitimate use case.Array.prototype.joinoverride followed by Error access β read-side variant of the stack-trace primitive.- Setter or getter on
Array.prototypenumeric 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 anArray.prototypesetter β capture-and-extract pattern aimed at bridge state.
Considered Attack Surfaces
saved.lengthwrite via sandboxArray.prototype.lengthgetter: writing tosaved[savedLen]readssaved.lengthonly via the local counter; even if reading were used, V8 services arraylengthfrom the magic own slot and never consultsArray.prototype.lengthfor instances.Object.prototypesetter on numeric keys: in Variant A,cis a{ __proto__: null, ... }literal, so reads onvalue.arrinside the captured record do not walkObject.prototypeeither; even if they did, the leak channel is theArray.prototype[N]setter, not the record's own access path.callSites[i]invoking sandboxtoString(Variant B):callSites[i]is a sandbox-realmCallSitewrapper instance whosetoStringis 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]triggeringSymbol.toPrimitive(Variant B): theCallSitewrapper does not defineSymbol.toPrimitive, so V8 invokes the standardtoString/valueOfpath. The wrapper'stoStringreturns a string, so the conversion never reachesSymbol.toPrimitiveeven if it were defined onObject.prototype(which the existing symbol-filter defenses would catch separately).result += '\n' + frameand host-realm primitives (Variant B): string+is a pure primitive operation on the V8 string type and does not dispatch throughObject.prototypeorString.prototypeaccessors.- Equivalent pattern elsewhere in the bridge: audited end-to-end.
thisFromOtherArguments,otherFromThisArguments, and every other index-write site inlib/bridge.jsusethisReflectDefinePropertyorotherReflectDefineProperty.neutralizeArraySpeciesBatch(Variant A) anddefaultSandboxPrepareStackTrace/makeCallSiteGetters(Variant B) were the remaining outliers; both are now fixed. The invariant is uniform acrosslib/bridge.jsandlib/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:
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 readerThe 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
- Embedder exposes a host object with an accessor property via
vm.freeze(cfg, 'cfg'), intending read-only access. - Sandbox reads the property descriptor through any channel that reaches the
getOwnPropertyDescriptortrap:Object.getOwnPropertyDescriptor,Reflect.getOwnPropertyDescriptor,Object.getOwnPropertyDescriptors, orObject.prototype.__lookupSetter__. - The returned descriptor's
setis a live bridge-wrapped function whose target is the raw host setter. - Sandbox calls
desc.set.call(cfg, value). The apply trap forwards to the host setter withthis= the unwrapped host object. - Host state mutates; every inert write trap was bypassed because no write trap was on the path.
Canonical Examples
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 documentedvm.freezeread behavior. - Data-property
writableflag β a data descriptor'swritable: trueis cosmetic under ReadOnly, not a channel: writingfrozen.k = vstill routes through the inertsettrap. Only the callablesetaccessor is a real write primitive, so only it is stripped. ReadOnlyMockHandlerβ extendsReadOnlyHandler, so it inherits the override; thereadonlyAPI 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
- Create an error and manipulate it (e.g., set
nameto a Symbol, or overrideprepareStackTrace). - Access
.stackto trigger V8's stack formatting, which may throw a host-realm TypeError. - Catch the TypeError (via catch block, promise rejection, or SuppressedError) to obtain a host-realm error object.
- Traverse the host error's constructor chain to reach host
Function.
Canonical Examples
// 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.prepareStackTraceassignment -- accessing V8 stack internals.- Recursive functions designed to cause stack overflow -- intentional
RangeErrorcreation. error.name = Symbol()-- forces TypeError during string coercion (Error Generation Primitive).- Accessing
.stackproperty on errors in conjunction with prepareStackTrace. - Errors with unusual prototype manipulation before throwing.
Error.cause-- set by user code (not V8 internals), soensureThishandles 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
- Create a disposable resource whose
Symbol.disposemethod triggers the Error Generation Primitive (e.name = Symbol(); e.stack), producing a host TypeError. - Use a
usingdeclaration orDisposableStack.defer()so that disposal happens automatically. - Arrange for a second error (e.g.,
throw null) so V8 wraps both in aSuppressedError. - Catch the
SuppressedError-- its.erroror.suppressedcontains the unsanitized host TypeError. - Traverse
.error.constructor.constructorto reach hostFunction.
Canonical Examples
// 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
DisposableStackchain, 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
.catchcallback rather than acatchclause: see test/vm.js ("SuppressedError escape via async rejection path"). - Host-realm
Promisedelivery, where the rejection value never passes the sandbox-sidethen/catchoverride: 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:
handleExceptionrecursion: detectsSuppressedError/AggregateErrorinstances by prototype check and recursively sanitizes.error/.suppressed/.errors[]viaensureThis.SuppressedErroris also added toerrorsListinbridge.js. Cycle detection via WeakMap prevents infinite recursion.- Sandbox-side
Promise.prototype.then/.catchoverrides route every callback throughhandleExceptionfor sandbox-realm promises (wrappedPromiseThen/wrappedPromiseCatch/wrappedPromiseFinally, installed onglobalPromise.prototypevialocalReflectDefinePropertyinsetup-sandbox.js). - 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-exposedasync () => {}whose returned promise is host-realm), the bridgeapplytrap recognizes the call (identity check against cachedotherGlobalPrototypes.Promisemethods) and wraps each sandbox-supplied callback with a sanitizing closure that pipes its argument throughhandleException(rejection) orensureThis(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 viabridge.setHostPromiseSanitizers(handleException, ensureThis)fromsetup-sandbox.js.
Detection Rules
new DisposableStack()ornew AsyncDisposableStack()withdefer().usingdeclarations ineval()-- triggersSymbol.disposeon scope exit.await usingdeclarations -- triggersSymbol.asyncDispose.e.suppressed.constructorore.error.constructorin catch blocks.SuppressedErrorcombined withe.name = Symbol().- Host-realm async function exposed via
{sandbox: {f: async () => {}}}chained with.finally/.catchto 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
- Error setup:
err.name = Symbol()causeserr.stackto throw a host TypeError (Error Generation Primitive). - Wasm module creation: A WebAssembly module imports a
triggerfunction that accesseserr.stackandWebAssembly.JSTagas a tag for catching JS exceptions. - Wasm exception catch: The module's exported function uses
try_table/catchwith JSTag. Whentrigger()throws the host TypeError, wasm catches it and returns the exception value as anexternref. - Bypass: The transformer only instruments JavaScript
catchblocks. Wasm exception handling is a separate mechanism -- the transformer has zero visibility into wasm bytecode. The caught host error is returned raw and unsanitized. - Escape:
hostError.constructor.constructor("return process")()-> RCE.
Canonical Examples
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.Instancewith import objects containingtag:properties.- Wasm modules with
try_table/catchinstructions. - 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
- 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. - Sandbox calls the function; host throws.
thisFromOtherForThrowwraps the hostErroras a bridge proxy. The proxy's underlying object is the raw host Error.- Sandbox catch block reads
e.cause. The proxygettrap fetches the host.cause(process) and wraps it throughthisFromOther, returning a bridge proxy ofprocessto sandbox code. - Sandbox code drives the wrapped
processthrough proxyget/applytraps:e.cause.mainModule.require('child_process').execSync('...'). Each step routes through host realm;execSyncruns with host privileges.
Canonical Examples
/* 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: falseclaiming 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:
Host-wrapped carrier (
e.isProxy === true) β overwrite host-side.causewithundefinedvialocalReflectDefinePropertyusing a sealed descriptor (writable: false, configurable: false). This routes through the proxydefinePropertytrap tootherReflectDefinePropertyand 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 lyingdefinePropertytrap (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). Agettrap that lies about the value triggersTypeError. So subsequent sandbox reads of.causeeither returnundefinedor throw β both safe.
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
Erroris substituted, preserving only the textual.message. Sandbox catch code sees a different error type but no pivot reference.Sandbox-realm carrier β recursively sanitize: read
e.causeonce, recurse viahandleException, then re-install the sanitized value as a data property to defeat sandbox-side accessor.causedefinitions. Cycle detection via the existingvisitedWeakMapprevents infinite recursion ona.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), besideLocalErrorand for the same reasonlocalStringStartsWithis captured for Category 8. The rebuild runs at exception-handling time, long after guest code has executed; reading the mutable sandbox globals there would letRangeError = 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
namestring, notinstanceof. A host-wrapped carrier's prototype chain terminates at the hostRangeError.prototype, never the sandbox's, soinstanceofagainst a sandbox constructor is false for every host error and the subclass would silently collapse to plainError. 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 plainErroron those runtimes.nameis 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 toModule,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.Xpatterns β 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/.pidin sandbox code where<anyKey>is not a spec-defined error slot β pivot through an arbitrary own or inherited carrier property.
Considered Variants
- Arbitrary
.foo = processon host-wrapped errors β now covered bysanitizeHostOwnProps. 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. .causeset by V8 internals β not currently a known path. If a future Node version starts populating.causeon internal errors with host references (analogous to how SuppressedError carries them), the fix already covers it because the recursion runs on every error reachinghandleException.- Read via
Reflect.get(err, 'cause')from a host-side script the sandbox can invoke β same path through the proxygettrap; 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
- 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. - Sandbox obtains the host-Promise proxy
pand registersonRejectedthroughcall/applyindirection:p.then.call(p, undefined, cb). - The apply trap fires with
object =hostFunction.prototype.call,context =the hostthen,args = [p, undefined, cb]. The direct-target checkisHostPromiseThen(object)is false β callbacks not wrapped. - Host Promise machinery (
PromiseReactionJob) runscbwith the raw host rejection value in a microtask afterrun()returns. cbreadse.detailβ a functional bridge proxy of hostprocessβ and drivese.detail.mainModule.require('child_process').execSync(...)to host RCE.
Canonical Examples
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/applyin a bounded loop. HostFunction.prototype.call/.applyare 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(wraponFulfilled+onRejected) orcatch(wraponRejected), the callbacks are wrapped through the samemakeSanitizedPromiseCallbackused for the direct call, so the Category 38 rebuild runs regardless of invocation shape. .applynested arrays are snapshotted into fresh, trap-owned, getter-free storage (copyPromiseArgArray,length > 65535bail) 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 throwsVMErrorrather than forwarding a possibly-unwrapped callback. Legitimate code never stackscall/applyanywhere 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.callchains terminating at a host Promise'sthen/catch. - A host
onRejected/onFulfilledreached via such indirection reading a non-messageown property of the received error (e.detail,e.originalError,e.context, β¦) and dereferencing it as an object/function. - A
call/applychain 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:bindre-enters the trap with the direct target when the bound function is called; sandboxReflect.applyinvokes the target directly (direct-gate wraps it);Reflect.construct(then)throws (Promise methods are not constructors). All asserted intest/ghsa/GHSA-647f-g98j-qq25/repro.js.- Descriptor extraction β
Object.getOwnPropertyDescriptor(proto, 'then').valuethen.callre-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 directp.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:
- Live bridge proxy β the sandbox reads
e.stack(orObject.getOwnPropertyDescriptor(e,'stack').value,Reflect.get(e,'stack')) through the bridgeget/getOwnPropertyDescriptortraps. handleExceptionrebuild (sanitizeHostOwnProps, GHSA-m283) β the carrier is rebuilt as a fresh sandbox-realm Error, and.stackis carried across as a primitive own property viav = 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: aBuffer.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:
lib/bridge.jsβget+getOwnPropertyDescriptortraps (redactHostStack). Gated byisOtherErrorObject, which ORs the[[ErrorData]]brand (hostObject.prototype.toStringβ"[object Error]", immune to prototype-chain severance) with a proto-walk to hostError.prototype(immune toSymbol.toStringTagspoofing) β 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 astackstring) are untouched β the embedder may legitimately expose such data. The descriptor trap must handle both V8 shapes: up to Node 20stackis an own data property (redactdesc.value), but from Node 22 (V8 12.x) it is an own accessor, and forwarding that getter would letObject.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.lib/setup-sandbox.jsβsanitizeHostOwnProps(x6m4RedactHostFramesFromStack). Redacts the.stackprimitive on the m283 rebuild path (delivery path 2), which the bridge cannot reach.lib/vm.jsβtransformAndCheck(defense in depth). Every transformer/eval/Functioncompile 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-levelVM.run/VMScriptcompile 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 astackdescriptor) off a value caught fromeval/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 vm2lib/*.jssource path.
Considered Attack Surfaces
Error.captureStackTrace/ customError.prepareStackTraceon 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 inadversarial.js.- Non-Error host object with a
.stackstring β out of scope: that is embedder-supplied data, not a V8-autopopulated error stack;isOtherErrorObjectexcludes 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 readfoo/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
- An embedder-exposed host function throws a host-wrapped
AggregateError/SuppressedErrorwhose graph references one node twice within a singlehandleExceptiontraversal β 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). - Sandbox
try { hostThrow() } catch (e) { β¦ }routesethroughhandleException. - The proto-walk dispatches to
sanitizeAggregateError; the host-wrapped branch reads.errorsand recurseshandleException(item, visited)on each element. - The second reference to the carrier hits the
visitedshort-circuit βreturn e(raw proxy) β pushed into the rebuilterrors[]. - Sandbox walks
e.errors[i].leakβ live hostprocessβ RCE.
Canonical Example(s)
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
SuppressedErrorsharing 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 aSuppressedErrorself-cycle through.suppressedwith 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 baretrue. - Two-phase rebuild for host-wrapped
AggregateError/SuppressedError: construct the empty sandbox-realm replacement, registervisited.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 vialocalReflectDefineProperty. Attacker own-properties are dropped by construction. - Memoize the
sanitizeHostOwnPropsrebuild too (a plain host error listed twice with a prototype-chain leak would otherwise return the raw carrier on the revisit). - Defense-in-depth:
_blockHostWrappedat every rebuild embed site replaces any element that is still_isHostWrappedafter the recursive call with a neutral sandboxErrorβ 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/seenWeakMap/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/.suppressedand 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.errorsaccessor failslocalArrayIsArrayand yields an empty replacement. Asserted inadversarial.js. .messageaccessor returning a host object β copied only whentypeof === 'string', so a non-primitive message is ignored.- Replacement construction throwing (
new LocalAggregateErrorunavailable) β falls back tonew 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
- Reach a host intrinsic prototype. Walk the prototype chain via
({}).__lookupGetter__('__proto__')composed withBuffer.apply, ending at hostObject.prototype(orArray.prototype,Function.prototype, etc.). Several earlier advisories (GHSA-grj5, GHSA-47x8, β¦) provided this walk; the same primitive lands here. - Write through the bridge. With the bridge proxy wrapping host
Object.prototypein hand, any of the following sandbox writes lands in the host realm:hostProto.x = vβsettrap βotherReflectSet(hostObjectPrototype, 'x', v).Object.defineProperty(hostProto, 'x', {value: v})βdefinePropertytrap βotherReflectDefineProperty(...).Reflect.set(hostProto, 'x', v)/Reflect.defineProperty(hostProto, ...)β same traps.delete hostProto.somePropβdeletePropertytrap βotherReflectDeleteProperty(...).Object.preventExtensions(hostProto)βpreventExtensionstrap β host prototype frozen forever.
- 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
// (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 toglobalsList/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
- Sandbox creates a null-proto carrier:
const o = {__proto__: null}. - Sandbox throws it:
throw o. Transformer-instrumented catch callse = handleException(e). handleExceptionwas passingethroughfrom(e). Withe's prototype being null,bridge.from()had no proto-mapping to consult and built a sandbox-side proxy treatingoas host-realm.- Sandbox writes a sandbox-side proxy of a host function onto the proxy:
e.f = Buffer.prototype.inspect. The bridgesettrap converts the sandbox value to host realm viaotherFromThis(value), yielding the raw hostinspect, then stores it on the underlying target β which is the sandbox objecto. - Sandbox reads via the original reference:
o.freturns the raw host function (no proxy in the way;oIS sandbox-realm, so plain property access bypasses the bridge entirely). o.f.constructoris hostFunctionβFunction('return process')()β hostprocessβ RCE.
The same bug exists on the globalPromise.prototype.then onFulfilled path: Promise.resolve({__proto__:null}).then(e => { e.f = HostFn; ... }).
Canonical Examples
// (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');
}
`);// 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 leakedWhy 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).ensureThisreturns 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 sameensureThisand the sub-error proto chain reaches a known host Error prototype mapping for genuinely-host sub-errors.globalPromise.prototype.thenonFulfilled wrap (line ~283): same change. The host-promise resolution path is unaffected because it goes throughnormalizeHostPromiseCallbacksinlib/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 usingfrom()(correct β values there ARE host-realm).bridge.setHostPromiseSanitizersinstall (line ~959): the rejection sanitizer is nowe => handleException(from(e))instead ofhandleException. The explicit outerfrom(e)preserves the GHSA-mpf8 invariant for genuinely-host null-proto rejection values (they reach sandbox callbacks bridge-wrapped, not raw); the innerhandleExceptionthen 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.jsand 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 useensureThis(sandbox-passthrough for unmapped values) instead offrom(always-wrap).{__proto__: null}followed bythroworPromise.resolve(...)in untrusted code review β the canonical attack carrier. Innocuous on its own, but combined with property assignment in catch /.thenit's a write-through probe.obj.constructor("return process")orobj.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()inhandleExceptionor sandbox-sidePromise.prototype.thenonFulfilled must re-prove the realm assumption holds for every callsite reachable on those paths.
Considered Attack Surfaces
localPromiseconstructor catch wrapper (line ~76):reject(handleException(e)). The executor runs in the sandbox;eis a sandbox-realm thrown value (host throws inside an executor that was passed through the bridge would already be wrapped at the bridge boundary).handleExceptionnow usesensureThisinternally, so this path is safe.- Sandbox-side
localPromise.prototype.thenonRejected wrap (line ~1170): also routes throughhandleException. Same reasoning β sandbox-realm rejection value,ensureThiscorrectly passes through. readonly()factoryfrom(mock)call (line ~1281):mockis 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 hostPromise.prototype.then/.catchinvocations: still usesfrom()directly (installed as the fulfillment sanitizer bybridge.setHostPromiseSanitizers(β¦, from)inlib/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:
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
- Resolve
getProto/setPrototo host'sObject.prototype.__proto__accessor viaBuffer.call.call({}.__lookup{Getter,Setter}__, Buffer, "__proto__"). - Trigger a host-realm rejection β
await WebAssembly.compileStreaming()throws a hostTypeError. - In
catch (e), the hostTypeErrorarrives wrapped (the first time the bridge has seen it). CallsetProto.call(getProto.call(e), null). The apply trap unwrapscontextto the raw hostTypeError.prototypeand forwards to the host setter, severing the hostTypeError.prototype.[[Prototype]]from hostError.prototype. - Trigger a second host-realm rejection. The fresh host
TypeErrorinstance 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, soensureThisfalls through and returns the raw hostTypeErrorunwrapped. - Sandbox reads
e.constructorβ raw hostTypeError(unwrapped, never crossed the bridge),.constructor.constructorβ hostFunction.new HostFunction("return process")()returns hostprocess. RCE.
Canonical Example
// (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/setPrototypeOftraps 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 asthis. 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
thisEnsureThisfall 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):
// 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.definePropertyIn the apply trap:
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 cachedangerousHostProtoMutatorsidentifies it regardless of how it's named in the sandbox. - Sandbox-applied host
Object.setPrototypeOf/Object.defineProperty/Object.definePropertiesβ reached viaObject(a bridge proxy of hostObject) or via a host-side method that returns these. - Sandbox-applied host
Reflect.setPrototypeOf/Reflect.definePropertyβ reached viaReflect(bridge proxy).Reflect.applyandReflect.constructare tracked as indirection primitives so a sandbox usingReflect.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/.bindindirection β the apply trap peels one layer to inspect the underlying function being applied.setProto.call(...)andsetProto.apply(...)are caught.Reflect.apply/Reflect.constructindirection β the apply trap peels these and inspectsargs[0]as the underlying function.
Considered Attack Surfaces
- Sandbox-realm
Object.setPrototypeOf/Reflect.setPrototypeOfon 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 legitimateinstanceofand 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 inthisFromOtherWithFactory/thisFromOtherForThrow/thisEnsureThisso the sandbox never holds a callable reference to a host prototype mutator regardless of how many indirection wrappers it stacks. Function.prototype.bindreturning 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 asobject. 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.
Related Categories
- Category 2: Prototype Chain Manipulation β sets up the attacker's goal of mutating a host prototype chain.
- Category 4: Error Object Exploitation β the canonical PoC uses host-realm
TypeErroras the carrier. - Category 7: Promise and Async Exploitation β
WebAssembly.compileStreaming()rejection is the host-error source. - Category 26: Sandbox-Realm Null-Proto via Bridge
from()β explains why we cannot indiscriminately wrap fall-through values inthisEnsureThis.
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
- Sandbox obtains a reference to any host-realm object the embedder exposed β either via
sandbox: { x }or any function/value reachable through the bridge. - 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)). - Sandbox writes a property:
child[key] = sandboxValue. V8's[[Set]]walkschildβproxy(hostObj)and invokes the trap withReceiver = child. - The pre-fix trap discarded
Receiverand ranotherReflectSet(hostObj, key, value). The property landed onhostObjon the host realm. - Host code subsequently reads
hostObj[key](orObject.getOwnPropertySymbols(hostObj), or dispatches through it via a well-known protocol such asutil.promisify). The attacker's value is read; if the host treats it as a callable, sandbox code runs in the host realm.
Canonical Example
// (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):
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 onchilddirectly and does not route through the trap, so it is not relevant; the first two are).Reflect.set(hostProxy, k, v, receiver)wherereceiver !== 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.createon 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 tohostProxy[k] = v. ExistingisProtectedHostObjectand__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 aTypeError, the sandbox sees aTypeError, the host object is untouched.- Adversary-controlled Proxy receiver with a custom
definePropertytrap β vm2 does not expose theProxyconstructor to the sandbox in plain VM mode (typeof Proxy === 'undefined'), so this composition is not reachable today. If a future change exposesProxy, the install-on-receiver branch must be re-audited. setPrototypeOf(child, hostProxy)afterchildalready has writes β the next write onchildwalks the new prototype, fires the trap withReceiver = child, and installs onchild. Host untouched.BaseHandler.definePropertyβ[[DefineOwnProperty]]carries noReceiverper ECMA-262 Β§9.5.6;Object.defineProperty(child, k, desc)wherechildinherits fromhostProxyinstalls onchilddirectly without invoking the proxy'sdefinePropertytrap. No analogous receiver bug exists. (Object.defineProperty(hostProxy, k, desc)does route to the trap, but that path already has theisProtectedHostObjectshort-circuit from GHSA-vwrp-x96c-mhwq.)BaseHandler.deletePropertyβ[[Delete]](Β§9.5.10) has noReceiver.delete child.kforchildinheriting from proxy is a no-op on the proxy.- Compound with Category 18: Array Species Self-Return β
Object.create(hostArray).constructor = fnnow installsconstructoron the sandbox child rather than (as before the existing array-constructor short-circuit) on the proxy target. The host array's rawconstructorslot remains untouched in either case;neutralizeArraySpeciesBatchcontinues 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:
// (advisory GHSA-cfcw-xp6x-25gj)
Buffer.call.call(Buffer.call, setProto, target, null);In the bridge apply trap this surfaces as:
object= hostFunction.prototype.call(outer.call)context= hostFunction.prototype.call(the receiverBuffer.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
- Obtain references to host
Object.prototype.__proto__accessor:setProto = Buffer.call.call(Buffer.call, {}.__lookupSetter__, Buffer, "__proto__"),getProtoanalogously. - Trigger a host-realm rejection (
await WebAssembly.compileStreaming()). - 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 onlyFunction.prototype.callas the underlying and allows the call through. - 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 hostTypeErrorhas not crossed yet; with its prototype chain severed, the proto-walk falls through andensureThisreturns the raw host value. e.constructor.constructorβ hostFunctionβnew HostFunction("return process")()β RCE.
Canonical Example
// (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/.getprojections, 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:
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__")assignsemptyFrozenObject(the host setter never crosses the bridge).- Any subsequent
setProto.call(...),setProto.apply(...),setProto.bind(...), orReflect.apply(setProto, ...)synchronously throwsTypeError: 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.callsubstitutes, 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/.getprojections. All funnel throughthisFromOtherWithFactory. - 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 nowemptyFrozenObject, not the host accessor. Detection: assignment value isObject.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.applytarget (e.g., a newFunction.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.setPrototypeOfon 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-realmObject.setPrototypeOf, which is a different function identity from the host's.Object.definePropertyon sandbox-realm targets β same reasoning. Sandbox-realmObject.definePropertycontinues to work normally.__defineGetter__/__defineSetter__on sandbox objects β theconnect()-registered sandbox surrogates are returned via themappingOtherToThiscache hit before the filter fires. Preserves the issue #176 regression behavior.
Related Categories
- Category 30: Host Prototype Mutation via Bridged Setter Primitives β direct ancestor. v6mx introduced the dangerous-mutator set and the depth-1 apply-trap peel; this category extends the same set's use to delivery refusal.
- Category 26: Sandbox-Realm Null-Proto via Bridge
from()Set-Trap Write-Through β explains why the cache-check must come first in each chokepoint (sandbox-realm surrogates are returned by the cache hit before the filter applies).
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:
// (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:
lib/bridge.jsthisEnsureThisβ the sole chokepoint that returns a host object raw on proto-walk fall-through (thisFromOtherForThrowandthisFromOtherWithFactoryproxy instead). During the walk, track whether the chain passes through the sandboxObject.prototype(thisObjectPrototype). Every genuine sandbox-realm value's chain terminates there; a severed host object walks tonullwithout ever seeing it. On fall-through, if!sawSandboxObjectProto, returnemptyFrozenObject.lib/setup-sandbox.jshandleExceptionβ the sole sanitizer the transformer routes every caught value through.isForeignSeveredHostValue(e)applies the same discriminator (chain reachesnullwithout hitting the sandboxObject.prototype, captured at module load and unforgeable from inside the sandbox); if true, replace the value with a benign sandboxError. PrimordialObject.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
- Extract the raw host getter:
const gP = Buffer.call.call(({}).__lookupGetter__, 67, '__proto__');. HostFunction.prototype.callunwraps theconnect()-mapped sandbox__lookupGetter__to its host counterpart, which (invoked host-side withthis = 67) walks hostNumber.prototype β Object.prototypeand returns the host__proto__getter. It crosses back and is wrapped as a callable bridge proxy. - Climb: starting from a host object reachable in NodeVM (
console._stdout), repeatedly callgP.call(cur). Each call returns the raw host[[Prototype]]β Socket β Duplex β Readable β Stream βEventEmitter.prototypeβ as a fresh, writable bridge proxy. - Write a sandbox function onto the reached host prototype:
EventEmitter.prototype.emit = fn.EE.prototypeis not protected, soBaseHandler.setforwards the write to the real host object. - Later, any host
process.emit(...)/console.log(...)invokes the sandboxemitwiththis === host objectβ arbitrary host code execution.
Canonical Example
// 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__').getcomposed withBuffer.call/Buffer.apply/Reflect.applyβ the raw-getter extraction primitive.- Repeated single-hop
getter.call(x)producing objects withownbuiltin-prototype methods (emit,pipe,write) β a raw-chain climb, distinct from the flattenedObject.getPrototypeOfwalk that terminates atObject.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/bindlaundering exactly as the peel was. Denying the reader at delivery is the complete, laundering-independent chokepoint. Object.getPrototypeOf/Reflect.getPrototypeOfas data-property readers: not currently extractable as raw host references via__lookupGetter__(they are data properties, not accessors), but added todangerousHostProtoReadersdefensively 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
- Obtain any host-realm object (via bridge-exposed built-in, error, or prototype traversal).
- Access
.constructorto get the object's constructor function. - Access
.constructoragain to getFunction(since every function's constructor isFunction). - Call
Function("return process")()to get the hostprocessobject. process.mainModule.require("child_process").execSync(...)for RCE.
Canonical Examples
// 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.constructoron any object is suspicious. Function("return process")()or equivalent string-to-code patterns.e.constructorinside acatchblock whereeis an error -- the error might carry host prototype references.global.constructor,this.constructor, or any object's.constructorfollowed 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
- Traverse
__proto__chains to reach host-realm prototypes. - Alternatively, pollute
Object.prototypewith getter/setter traps that intercept bridge-internal property access. - When the bridge copies property descriptors or checks types, the polluted prototype injects attacker code.
Canonical Examples
// 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.setPrototypeOforReflect.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
- Identify a V8 internal algorithm that reads a well-known symbol (e.g.,
Symbol.speciesfor promise/array construction,Symbol.hasInstanceforinstanceofchecks). - Override the symbol on a target object to redirect the algorithm's behavior.
- The redirected behavior causes V8 to pass unsanitized host values to attacker-controlled code.
Canonical Examples
// 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 TypeErrorWhy 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.speciesusage, especially assignment to.constructor[Symbol.species].Symbol.speciesas a getter -- TOCTOU attack returning different values on each access.Symbol.hasInstanceoverride via__defineGetter__orObject.defineProperty.Object.getPrototypeOf(Promise)-- accessingglobalPromiseto override itsSymbol.hasInstance.Symbol.for()-- can create cross-realm shared symbols.- Any Symbol used as
error.name-- triggersTypeErroron string conversion which may leak host errors. Symbol.iteratororSymbol.toPrimitiveoverrides -- 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
- Define a function in the sandbox that accesses
arguments.callee.callerorfn.caller. - Arrange for this function to be called by a bridge function (e.g., as a callback passed to a host API).
.callerreturns the bridge function, whose.constructoris the hostFunction.
Canonical Examples
// 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..callerproperty on any function..argumentsproperty on any function (the deprecated property, not the localargumentsobject).
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
- Use reflection APIs (
Object.getOwnPropertySymbols,Reflect.ownKeys,Object.getOwnPropertyDescriptors,Object.assign, object spread) to enumerate symbol-keyed properties on host objects. - Filter for the target symbol by
.description(e.g.,'nodejs.util.inspect.custom'). - Define the real symbol on a sandbox object.
- When Node.js internals encounter the object, they invoke the symbol-keyed method with host context.
Canonical Examples
/* 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]]onobj.Object.assign(target, ...sources)-- calls[[OwnPropertyKeys]]on each source.Object.defineProperties(obj, props)-- calls[[OwnPropertyKeys]]onprops.- 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}orconst {...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 hijackObject.defineProperties.WebAssembly.compileStreaming(obj)-- triggers internalutil.inspecton error.Object.defineProperty(hostStream, sym, {value:false})/hostStream[sym] = falsewithsym.descriptionin{nodejs.stream.disturbed, nodejs.stream.errored}-- shadowing a stream state accessor to flip host-sideisDisturbed/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
- Override a built-in constructor or method (
Array,Object.create,Reflect.construct). - Trigger a bridge operation that internally uses the overridden built-in (e.g., call
Buffer.from()). - The overridden built-in executes attacker code with access to bridge-internal references.
Canonical Examples
// 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.fromwith unusual arguments (Proxies, objects withvalueOf).- Override of
Object.create-- intercepting object creation. - Override of
Reflect.constructor otherReflectmethods. - Override of
Arrayconstructor orArray.prototypemethods. - Override of
Object.assign-- intercepting property copying. valueOf()ortoString()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
- Get
Object.getOwnPropertyDescriptor(Function.prototype, 'constructor')-- descriptor containsvalue: Function. - The descriptor is wrapped as a proxy, but the underlying host object contains the raw Function constructor.
- Pass the proxy to a host function like
Object.entries()-- it gets unwrapped viamappingThisToOther. - The host's
Object.entriessees unsanitized content, including the raw Function constructor. - Extract the Function constructor via chained
Object.entriescalls.
Canonical Examples
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
getOwnPropertyDescriptorscalls -- building deep nesting. Object.entries(descriptor)orObject.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
- Host array creation:
ho.entries({})creates a host array which gets proxied to the sandbox. - Species constructor setup: A sandbox function
x()is created that returns the same arrayr.x[Symbol.species] = xmakes V8's species resolution returnxitself. - Constructor injection: The attacker sets
r.constructor = x. Multiple methods:- Direct write:
r.constructor = x(blocked by proxysettrap storing locally) - Object.assign bypass:
ho.assign(r, {constructor: x})(sets directly on host array) - Non-configurable constructor:
Object.definePropertywithconfigurable: false(detected byReflect.deletePropertycheck) - Prototype-level:
Object.setPrototypeOf(blocked by own propertyconstructor=undefinedshadowing)
- Direct write:
- Species-driven map:
r.map(f)triggersArraySpeciesCreate. V8 readsr.constructor-> getsx, readsx[Symbol.species]-> getsx, callsnew x(length)-> returnsr.mapstores callback results directly intoron the host side. - Chained extraction: Multiple
cwucalls build up to extracting the hostFunctionconstructor -> RCE.
Canonical Examples
// 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:
- Proxy
gettrap β cachedArrayctor for host arrays: When sandbox code reads.constructoron a host-array-backed proxy, the trap returns a module-load-time-capturedthisArrayCtor = Arrayreference. This bypass of the normal property read neutralises any attacker-installedconstructor(directr.constructor = x,Object.defineProperty,Object.assign, prototype-chain injection viaObject.setPrototypeOf) and is immune to prototype pollution ofArray.prototype.constructor. Only defends sandbox-side reads; does not cover V8-internal reads issued from the host realm. - Apply/construct trap neutralize-and-restore: Before every
otherReflectApply(object, context, args)andotherReflectConstruct(object, args)β i.e. every sandboxβhost function invocation β the bridge walkscontextand each top-level argument. For every host array found (Array.isArrayis cross-realm safe), it installsconstructor = undefinedas a data own property (shadowing both own and inherited constructors; the ES2024 spec explicitly mapsconstructor === undefinedto%Array%in ArraySpeciesCreate). After the host call returns β in afinallyβ 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 = xwherexhasSymbol.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({})orObject.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
- Embedder exposes a plain sloppy host function
greeton the sandbox. - Sandbox calls it with no receiver:
const g = greet();. - Apply trap runs
otherReflectApply(greet, undefined, []); V8 bindsthis = host global. greetreturns/leaks the host global; without the guard the bridge wraps it in a proxy and returns it to the sandbox.g.process.getBuiltinModule('child_process').execSync('β¦')executes in the host.
Canonical Examples
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
thisto 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/.globaloff 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
- Sandbox calls
new Promise(executor). - Inside the executor, sandbox constructs an Error with a Symbol-named
.nameand accesses.stackβ V8's stack formatter throws a host TypeError synchronously. - The Promise constructor's spec-mandated executor try/catch catches the throw and sets the Promise to rejected with the raw host TypeError.
- No
.catch()is attached. - After microtask drain, host fires
unhandledRejectionwith the raw host TypeError. - Node 15+ default behaviour: terminate the host process.
Canonical Example
// (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:
- 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 throughhandleException(the existing SuppressedError/AggregateError-recursive sanitiser), thenrejected. A sandbox-side.catch()handler will see a sandbox-realm value rather than a raw host TypeError. - 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'sunhandledRejectionevent never fires. The tail uses the cached hostthen(captured before vm2'sthenoverride 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: falsecombined 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.
// 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 throughlocalPromise(becausePromise.rejectdelegates tonew this(...)) and gains the swallow tail. Covered.- Silent-failure trade-off: sandbox developers cannot use Node's host-side
unhandledRejectionlog 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:
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/.catchreaction (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
onRejectedreturnsundefined, 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
thenrequires 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:
constructtrap. A host constructor's body mayreturnan object, which overridesthis.BaseHandler.constructwrapped that value withthisFromOtherWithFactoryand never marked it. Reached throughnewand throughReflect.construct, and equally with a promise that rejects on a later tick.gettrap. 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
applyreturn.
// (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.
thisProxyOtherruns on the first crossing only; later deliveries hit themappingOtherToThiscache, 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.
isOtherPromiseis a brand check: it walks the other realm's prototype chain to the cached hostPromise.prototype(otherPromisePrototype, captured at bridge init next tootherPromiseThen). Nothing is invoked on the value, so a non-promise host object costs a short prototype walk instead of a thrownTypeError.Object.prototype.toStringis deliberately not used as the brand: it readsSymbol.toStringTag, which fires hostgettraps β a hostProxywhosegettrap mints a fresh rejected promise would be crashed by the brand check itself. The walk is capped at 100 links so an embedder-authoredProxychain 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 hostObject.prototypeis treated as a possible promise and handed tomarkHostPromiseHandled. The built-inPromise.prototype.thenbrand-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 owntheninvoked. 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.
applykeeps the GHSA-gjq8 call andconstructgains the matching one, both afterstripDangerousSymbolsFromHostResult. 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, andmarkHostPromiseHandled'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/.catchstill observes the sanitized rejection; the no-oponRejectedreturnsundefined, 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 returningfetch-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
- Attacker submits a small request that runs sandbox code containing
Buffer.alloc(LARGE_N)(or any of its variants above). - The sandbox-side
Buffer.allocis exposed by vm2 via the bridge; the call routes throughBaseHandler.applyto hostBuffer.alloc. - Host
Buffer.alloc(LARGE_N)runs synchronously in C++; V8's timeout cannot interrupt it. - RSS jumps by
LARGE_Nbytes; ifLARGE_Nexceeds the container's available memory, the process OOMs.
Canonical Example
// (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 viaReflect.apply. Registered withconnect()so the bridge surfaces this wrapper as the canonical sandboxBuffer.alloc.Buffer.allocUnsafe(size)/Buffer.allocUnsafeSlow(size)β same pattern, defense-in-depth (also covered transitively because they delegate to the now-cappedBuffer.alloc).- Deprecated
Buffer(N)/new Buffer(N)βBufferHandler.apply/constructtraps already special-case numeric first arg; the cap is added there too. Buffer.concat(list, totalLength)(added GHSA-gmc2-2x9w-cgh9) β capstotalLengthwhen supplied, or the summedlist[i].lengthwhen omitted, before delegating to host (which would otherwise callBuffer.allocUnsafe(totalLength)internally, bypassing the alloc wrapper).Buffer.from(value [, encoding | offset, length])(added GHSA-gmc2-2x9w-cgh9) β capsvalue.lengthfor object-typed inputs (closes the{length: N}array-like DoS path) but excludes TypedArray/DataView/Buffer copies (they have both.byteLengthand.buffer, and the source is already sandbox-allocated). Also caps the explicitlengththird argument used in the ArrayBuffer overload.Buffer.copyBytesFrom(view, offset, length)(added GHSA-gmc2-2x9w-cgh9, Node 22+) β capslengthwhen supplied, orview.byteLength - offsetwhen 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-controlledtotalLength(or sandbox-controllable sum oflist[i].length).Buffer.from({length: N})β array-like with fake numericlengthtriggers host'sfromArrayLike(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-controlledlengthor largeview.
Canonical Bypass Example (GHSA-gmc2-2x9w-cgh9)
// 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 everyArrayBuffer/SharedArrayBuffer/ TypedArray /WebAssembly.Memoryconstructor with the samebufferAllocLimitcap when a finite limit is configured.String.prototype.repeat(N): produces a sandbox-realm string of sizelen * Nbytes, 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 wasmmaximumdeclaration 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
- Operator configures
new VM({ bufferAllocLimit: 10 * 1024 * 1024 }). Buffer.alloc(20 MB)is correctly blocked.- Sandbox substitutes
new ArrayBuffer(1024*1024*1024)(ornew Uint8Array(...),new SharedArrayBuffer(...),new WebAssembly.Memory({initial: N})) β none routed throughcheckBufferAllocLimitβ host RSS jumps by the full size β OOM in memory-constrained environments.
Canonical Example
// (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 allocatedWhy 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 withvalueOf/Symbol.toPrimitive, or an array-like{length: N}is measured, not waved through. Resizable buffers are capped onmax(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. PinningmaxByteLengththis 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 ornew <TypedArray>({length: N})array-like amplifier.new WebAssembly.Memory({initial: N})andmemory.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:
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 themBuffer.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
- 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.
- Sandbox allocates any small buffer via a pooling factory (
Buffer.from([0])). - Sandbox reads
.bufferβ the full pool ArrayBuffer. - Sandbox builds a full-width view with the
Buffer.from(arrayBuffer, 0, byteLength)overload (which legitimately shares the passed ArrayBuffer). - Sandbox reads the view (disclosure) or writes it (corruption), reaching every byte of every buffer currently pooled.
Canonical Examples
// 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)returnsbufwhen it already owns an exact-size backing store, otherwise copies it into a standaloneLocalBuffer.alloc(n)(non-pooled, zero-filled, byteOffset 0) via the raw hostBuffer.prototype.copyprimitive.- Applied at every sandbox-facing pooling factory:
bufferFrom(the non-ArrayBuffer overloads),concat,copyBytesFrom, a newbufferOfwrapper, and the deprecatedBuffer(...)/new Buffer(...)call forms (BufferHandlernow routes its non-numeric path throughbufferFrom). - 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 capturedArrayBuffer.prototype/SharedArrayBuffer.prototypebyteLengthgetter, 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.bufferis 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
- 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.
- Sandbox obtains any host-allocated
Bufferβ a builtin's return value, a sandbox-injected buffer, a callback argument. - Sandbox reads the buffer's backing store β
.buffer, or its legacy twin.parent(DEP0004), both of which return the sameArrayBuffer. The bridgegettrap forwards the raw host accessor and wraps its result, which is the whole pool. - Sandbox builds a full-width view with
Buffer.from(ab, 0, ab.byteLength)β the sharing overload, which delegates to hostBuffer.fromand therefore views the raw pool. - Sandbox reads it (disclosure) or writes it (corruption), reaching every byte of every host buffer currently pooled.
Canonical Examples
/* 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%.prototypevsDataView.prototype) and the store (ArrayBuffer.prototypevsSharedArrayBuffer.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-correctslice.- Applied in
BaseHandler.getfor every object-valued read off a host view and, defensively, inBaseHandler.getOwnPropertyDescriptorfor a shadowing own data descriptor. The hot-path gate is the cached hostArrayBuffer.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
viewcarries theArrayBufferViewinternal slot but the bridge cannot read its extent β a realm exposing%TypedArray%.prototypewithout itsbuffer/byteOffset/byteLengthaccessors, a detached view on an engine whose getters throw, or aslicethat throws β anArrayBuffercoming off that view is refused withVMErrorrather 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.isViewis resolved from the OTHER realm's globalArrayBufferfirst, withArrayBuffer.prototype.constructoronly as a fallback: hanging it offconstructoralone 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,otherIsViewfalls back to a prototype-chain walk andotherBoundedViewStorerefuses 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/parentwith some otherArrayBuffer). There is no extent to bound it to and it is very likely another pooled store, so it is refused withVMErrorβ one identity compare on a path already taken. SharedArrayBuffer-backed views are bounded the same way:SharedArrayBuffer.prototype.sliceyields a fresh, smallerSharedArrayBuffer, so the wider shared store β and any host-realm view onto it β stops at the bridge.- The raw host
%TypedArray%.prototype.buffer,DataView.prototype.bufferandBuffer.prototype.parentgetters β and the matching offset getters%TypedArray%.prototype.byteOffset,DataView.prototype.byteOffsetandBuffer.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 theapplytrap. HostBufferis 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 withoutBuffer. - The triple stays consistent. Once a view's store is delivered bounded, that view's
byteOffsetβ and the legacy numericoffsetβ read as0from the samegettrap, because the store the sandbox holds now starts at the view's own first byte. Without this the standard Node re-view idiomBuffer.from(v.buffer, v.byteOffset, v.length)would throwRangeError: "offset" is outside of buffer bounds. bufferOwnsExactBackingStoreinlib/setup-sandbox.jsno longer tries to observe ownership β bounding makesbyteOffset === 0andbuffer.byteLength === lengthread identically for a pooled buffer and an owning one, and each.bufferread 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 whensize < (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. TheBuffer.from(arrayBuffer, byteOffset, length)sharing overload is preserved for a host-realmArrayBuffertoo β the sandbox-side internal-slot brand test cannot recognize a bridge proxy, sobufferFromadditionally 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/.parentis a bounded copy:hostBuf.buffer !== hostBuf.buffer(a fresh copy per read) and writes through it are not host-visible.byteOffsetand the legacy numericoffsetread as 0.- Sub-views the sandbox itself creates from a host-backed
Buffer(hostBuf.subarray(4, 8).buffer) lose.bufferaliasing 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/.parentread 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 sandboxtimeoutlike 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-realmArrayBufferbelowBuffer.poolSize / 2returns a copy rather than a live view, for the same reason. Larger sub-ranges stay live views: such stores are never pooled, sodepoolBufferleaves them alone. The full-range form β the documented re-view idiom β always shares.- The
Buffer.from(arrayBuffer, β¦)sharing overload discussed here is about host-realmArrayBuffers, which reach the sandbox as bridge proxies. A sandbox-realmArrayBufferpassed to a hostBufferAPI 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
ArrayBufferhanded over directly β a host object property whose value is a pooledArrayBuffer({ 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 ownbuffer/parentproperty 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
ArrayBufferfrom a different host realm (anothervmcontext, a worker), whose prototype is not the cachedArrayBuffer.prototype.otherBackingStoreInforeturns 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 !== 1inside a sandbox β pooling leak is open.hostBuffer.buffer.byteLength > hostBuffer.lengthfor any host-allocated buffer reachable from the sandbox (a builtin's return value, an injectedsandboxbuffer, a callback argument) β the hostβsandbox half is open.hostBuffer.parent.byteLength > hostBuffer.lengthβ the same half is open through the legacyparentaccessor. More generally: any own accessor on a host binary-data prototype that returns the backingArrayBuffermust be bounded or denied, not justbuffer. Enumerate them per engine β on Node 26 they are%TypedArray%.prototype.buffer,DataView.prototype.bufferandBuffer.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
ArrayBufferwithout passing throughotherBoundedViewStore. - Sandbox code that reaches for a raw host backing-store getter (
__lookupGetter__,getOwnPropertyDescriptoron a host prototype) rather than reading the property. - Any sandbox-facing
Buffer/typed-array factory whose result hasbyteOffset !== 0orbuffer.byteLength !== length. - Reading
.bufferon a pooled buffer and passing it to theBuffer.from(ab, off, len)overload. - New
Buffer.*factories in future Node versions must be checked for pool-backing, not just alloc-size (theBUFFER_STATIC_CLASSIFIEDfail-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
- Sandbox registers a cleanup callback against an object it creates, then drops the only strong reference:
registry.register(target, x); target = null;. run()returns almost instantly (registration is O(1)), well insidetimeoutβ vm2 believes execution completed safely.- At a later GC (forceable under memory pressure or
--expose-gc), V8 invokes the cleanup callback on its own native callback path β not a newrun()call, sodoWithTimeoutnever wraps it. - The callback busy-loops; the entire host event loop is frozen for its duration.
Canonical Example
// (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 wherecbperforms 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
WeakRefalone: cannot schedule execution βderef()is synchronous and returns within therun()timeout window. Removed only as the pair toFinalizationRegistry; keeping it would be safe.Promisecontinuations (Promise.resolve().then(busyLoop)): same after-run()class, but already closed byallowAsync: false, which the README pairs withtimeout.Promisecannot be removed (fundamental primitive).Atomics.wait(ta, i, v)on aSharedArrayBuffer: parks the thread synchronously insiderun(), so V8'sTerminateExecutiondoes interrupt it β verified it throwsScript execution timed outat the configured limit. Bounded; not in this class.- Buffer/TypedArray/
WebAssembly.Memoryallocation: synchronous native work, a different DoS class already capped bybufferAllocLimitβ see Category 23 and Category 36. - Objects returned from
run()with sandboxvalueOf/toString/Symbol.toPrimitive: run sandbox code when the host later touches them β already documented in the READMEtimeoutwarning ("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:
moduleexposesModule._load(name),Module._resolveFilename,Module._cache,createRequireβ all of which load any host builtin or external module ignoring vm2's allowlist.worker_threadsexposesnew Worker(src, {eval: true})β runs arbitrary JS in a fresh thread that has no vm2 sandbox at all.clusterexposescluster.fork()β spawns a host child process running attacker-controlled code.child_processexposesexecSync/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.vmexposesvm.runInThisContextβ evaluates code directly in the host realm, bypassing every bridge proxy.replexposesrepl.start({eval, input, output})β constructs an interactive evaluator attached to host streams.inspector(andinspector/promises, subpath family) exposes the inspector protocol β attaches a debugger to the host process and runsSession().post('Runtime.evaluate', { expression })against host JS.processexposesprocess.getBuiltinModule(name)(Node 22+) β reloads ANY core module regardless of the embedder's allow/deny list β plusprocess.binding(...),process.dlopen(...),process._linkedBinding(...), and the raw hostprocess.env. The sandbox globalprocessis a sanitized shim defined insetup-node-sandbox.js; the raw host module is never safe to expose.test(andtest/reporters, subpath family) exposestest.run({ files, execArgv })β the test runner's process-isolated mode spawns a SEPARATE host Node process and forwardsexecArgvto it verbatim, soexecArgv: ['--eval=<js>']executes attacker JavaScript in an unsandboxed host process. Same class ascluster.fork(). It is easy to misreadtestas inert tooling, but its documented API is a host-process launcher with caller-controlled command-line flags.
Attack Flow
- Allowlist includes a host-passthrough builtin (most commonly because the user wrote
builtin: ['*', '-child_process']and'*'expanded to include'module'). - Sandbox calls
require('module'). NodeVM's resolver finds'module'inBUILTIN_MODULES, callsaddDefaultBuiltinwhich loads it viavm.readonly(hostRequire('module')). TheReadOnlyHandlerproxy blocks mutation traps but notapply/getβ calling methods on the proxy still forwards them to the host realm. - Sandbox calls
Module._load('child_process'). The bridgeapplytrap forwards to hostModule._load, which loadschild_processnatively in the host with no vm2 check. child_process.execSync(...)β host RCE.
Canonical Examples
// (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');// (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');// (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');// (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');// (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');// (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):
DANGEROUS_BUILTINSSet 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_channelthroughdnswere added by Category 35 for the process-wide observability info-leak class (osanddnsvia GHSA-m5w8-4gq2-6f8x, which additionally close the host-process write APIsos.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).Family-prefix check via
isDangerousBuiltin(key)β any<family>/...whose family is in the denylist is also blocked (e.g.inspector/promises, futureinspector/foo, hypotheticalprocess/foo,module/foo). The check also strips the optionalnode:URL-style prefix sonode:processandnode:inspector/promisesare caught.Filter from
BUILTIN_MODULESβ closes the'*'wildcard expansion path.'*'will never auto-allow these names regardless of the user's exclusion list.Reject in
addDefaultBuiltinβ closes the explicit-allowlist path (builtin: ['module'],builtin: ['process'],builtin: ['inspector/promises']) and the lower-levelmakeBuiltins([...])API used by custom resolvers. TheSPECIAL_MODULESescape hatch is preserved: a future safe wrapper (e.g. amoduleshim that exposes onlybuiltinModulesmetadata) can be registered there if a real consumer needs it.Deny-token
node:normalization (GHSA-8686-vhfx-7r3j) β the'*'wildcard's negative-token check inmakeBuiltinsFromLegacyOptionsnow 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 thenode:-prefix stripping the resolver andisDangerousBuiltinalready 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.Deny-token family coverage (GHSA-6rh5-qq4q-97xh) β the same negative-token check now treats
<family>/<sub>as denied whenever-<family>is present, so-fsdeniesfs/promises,-pathdeniespath/posix/path/win32,-streamdeniesstream/promises/stream/web/stream/consumers. This is the user-deny-token mirror of the family-prefix matchingisDangerousBuiltinalready applies to the hardDANGEROUS_BUILTINSdenylist. Points 5 and 6 are composed in a single chokepoint,isBuiltinDenied(builtins, name), which normalizes thenode:prefix off both the module name and the token before matching and then applies the family split to the normalized name β so-node:fsdenies all four offs,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, sobuiltin: ['fs']behaves exactly as before.testfamily denied (GHSA-qhwx-74w5-xhxq) βtestjoinsDANGEROUS_BUILTINS, which is the whole fix: no new code path is needed. BecauseBUILTIN_MODULESis built by filteringnmod.builtinModulesthroughisDangerousBuiltin, 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 asbuiltin: ['cluster']already behaved. (testis denylisted because it is a host-process launcher with no legitimate in-sandbox use β the same rationale that later broughtchild_processonto the list; see point 8.)addDefaultBuiltinrefuses it a second time for the low-level registration path. Subpath coverage (test/reporters) falls out of the existing family-prefix match added forinspector/promises; no special case was required.isDangerousBuiltinadditionally strips repeatednode:prefixes, so the doubled spellingnode:node:testβ which the resolver normalizes down to a single prefix before lookup β cannot survive as an unnormalized denylist miss. Note this isisDangerousBuiltin(the hard, non-configurable denylist), which is a separate chokepoint fromisBuiltinDenied(points 5 and 6, user-supplied deny tokens); the two do not interact.child_processfamily denied (GHSA-pq68-rvw4-xp4r) βchild_processjoinsDANGEROUS_BUILTINS, closing the last host-process-spawning primitive reachable through the default loader. Before GHSA-pq68 it was reachable underbuiltin: ['*'], an explicitbuiltin: ['child_process'],['*', '-fs'], and thenode:spellings, and the read-only wrap forwardedexecSync/exec/spawn/execFile/forkstraight to the host β arbitrary host command execution from a sandbox with nofsorprocessof its own. That reach was a documented carve-out ("embedders may legitimately want it for trusted scripts"), but the rationale applies identically tocluster/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 fromBUILTIN_MODULES(so'*'never expands to it), refused inaddDefaultBuiltin(so an explicit request is rejected), and family/node:-normalized inisDangerousBuiltin(sonode:child_processand the doublednode:node:child_processare covered).Upgrade path for an embedder that genuinely needs child-process access for trusted scripts: re-expose it through the
mocksescape hatch, which is applied ahead of the dangerous check β either the real module (require: { mock: { child_process: require('child_process') } }, which forcesrequire('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 barebuiltin: ['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: [...]})assertsargs[0]->IsArray()in V8 C++. The array crosses the bridge as a Proxy, theIsArray()check fails, and the entire host process aborts. Reachable as ~150 bytes from sandbox underbuiltin: ['*']β not RCE, but a host-process-DoS primitive of the same severity class as Category 22.wasiexposes the WebAssembly System Interface preview1 syscall surface (filesystempreopens, host clock/random, network if preopened). The API is experimental and broad; even a misconfiguredpreopens: {}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 includemodule/worker_threads/cluster/child_process/vm/repl/inspector/trace_events/wasi/test; the shipped filter removes them. Note:'*'still allowsfs,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-realmeval.repl.start({eval, ...})β host-realm REPL evaluator.inspector.open()ornew (require('inspector/promises').Session)()β debugger attachment /Runtime.evaluatehost-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']β anode:-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 anyexecArgv/--eval/--require/--importstring 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-fsfor filesystem isolation on an unpatched version does not enforce it.
Considered Attack Surfaces
async_hooks,diagnostics_channel,perf_hooks,v8are 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_processis on the hard denylist (GHSA-pq68-rvw4-xp4r): a full-host-RCE primitive (execSync/spawn/forkrun arbitrary host commands) of the same "spawns a host process" class ascluster/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 throughrequire.mock(see Mitigation point 8) β the real module or a controlled facade β which makes the choice explicit in the embedder's own host code.fsis 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 useVMFileSystemor excludefsexplicitly. Since GHSA-6rh5-qq4q-97xh a-fstoken also coversfs/promises; on earlier versions it did not, andfs/promisesalone is a complete host filesystem read/write primitive.'*'is not sandbox-safe for untrusted code.dgram,net,http,https,dnsare network-IO builtins, allowed under'*'. Any of them give untrusted code outbound network access from the host. Embedders should explicitly exclude or allowlist.
Attack Category 24: NodeVM `require.root` Symlink Bypass (Path Check/Use TOCTOU)
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_modulesentry (canonical<root>/node_modules/ β /layout). - npm workspaces and
npm linkcreate equivalent symlinks. - With
context: 'host', the host loader runs the symlinked code with full host privileges β direct RCE.
Attack Flow
- 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. - Sandbox calls
require('./link.js')(orrequire('safe')for a directory-level symlink). - Resolver runs
path.resolve(...)producing a path that starts withrootPathand passesisPathAllowed. - Loader runs
hostRequire(filename)which follows the symlink to outside-root code. - In
context: 'host'the loaded module executes with host privileges β RCE.
Canonical Example
// (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.
DefaultFileSystem.realpath()andVMFileSystem.realpath()(inlib/filesystem.js) β new methods on the filesystem abstraction. The default delegates to hostfs.realpathSync;VMFileSystemdelegates to the user-suppliedfs.realpathSync.isPathAllowedrealpaths the candidate (inlib/resolver-compat.js) before the prefix-vs-rootPaths check. Ifrealpaththrows (file doesn't exist, broken link) the check denies by default.rootPathsare canonicalized at construction time so a symlinked root configuration (root: '/tmp/myroot'where/tmp/myrootis itself a symlink) compares the same canonical namespace as the candidate filenames.- Eager FileSystem-contract probe at NodeVM construction (in
lib/resolver-compat.js,makeResolverFromLegacyOptions). Ifrequire.rootis set, the resolver verifies that the FileSystem adapter implementsrealpath()and that calling it does not throw aTypeError(the signal thatVMFileSystem's underlyingfs.realpathSyncis missing). On contract violation it throwsVMErrorimmediately atnew NodeVM(...)time citing GHSA-cp6g-6699-wx9c, instead of silently denying every laterrequire(). Otherrealpatherrors at construction (ENOENT,EACCES) are tolerated β the root may legitimately not exist yet, and runtimeisPathAllowedwill 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.rootpointing outside (file-level or directory-level). fs.realpathSyncon the candidate βpath.resolveon the candidate β the smoking gun for this class.- pnpm / npm-workspaces /
npm linklayouts with NodeVMrequire.rootconfigured.
Considered Attack Surfaces
- Custom
fsadapters withoutrealpath/realpathSync: existingVMFileSystem({ fs: customFs })users whosecustomFslacksrealpathSync, and fully customFileSystemadapters that omitrealpath(), 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 laterrequire()into a single, clearly-labelledVMErroratnew 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 exposeopenat/O_NOFOLLOWto user code. Documented residual risk. mocks/overridesare 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 itsif (!options)branch. - Truthy non-object
requireβ{ nesting: true, require: true / 1 / 'yes' / Symbol() / function(){} }.makeResolverFromLegacyOptionsdestructures every primitive/function value to all-undefined, then callsmakeBuiltinsFromLegacyOptions(undefined, β¦, NESTING_OVERRIDE)β the same call shape theif (!options)branch produces. - Truthy non-
truenestingβ{ nesting: 1 / 'yes' / {} / [] / function(){}, require: false }. The override gate inside the constructor isnesting && NESTING_OVERRIDE, which fires for ANY truthy value. typeof-object non-configrequire(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 istypeof === '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 everytypeof-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
- Host configures
nesting: truewithout providing an explicitrequireconfig object β e.g.new NodeVM({ nesting: true }). The developer assumes the absence ofrequiremeans "no host modules" (matching the rest of the API's default-deny stance). - Sandbox code requires
vm2: succeeds becauseNESTING_OVERRIDEinjectedvm2into the builtin map even though the surroundingrequireconfig is empty/denied. - Sandbox constructs inner NodeVM with attacker-chosen
requireconfig:new NVM({ require: { builtin: ['fs'] } })(orexternal: truewith host context β any capability the outer config denied). - Inner sandbox loads host
fsand reads or writes any file the host process can reach. (Before GHSA-pq68-rvw4-xp4r the inner config could namechild_processdirectly 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
// 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
requireshape rejected at construction, and therequire: {}escape hatch still granting hostvm2only: see test/ghsa/GHSA-8hr7-r645-pc6w/adversarial.js.
Why It Works
The bug lives in lib/resolver-compat.js makeResolverFromLegacyOptions:
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):
// 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 shapeThe guard mirrors the actual reachability of the insecure resolver on two axes:
nestingchecked as truthy, matching thenesting && NESTING_OVERRIDEgate that decides whethervm2is exposed. Coversnesting: true / 1 / 'yes' / {} / [] / function(){}uniformly.requireOptsmust be aResolveror a plain config object β prototypeObject.prototypeornull, 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 notObject.prototype), all of which destructure to all-undefined. The GHSA-m4wxtypeof === 'object'test admitted the last two groups;isPlainConfigObjectcloses 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: , ... })withrequireset to anything other than a plain config object (orResolver) β flagged at construction withVMErrormentioning GHSA-m4wx-m65x-ghrr. Coversrequire: false / undefined / null / 0 / '' / true / 1 / 'yes' / Symbol() / function(){}(primitives/functions) andrequire: [] / 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), andnestingvaluestrue / 1 / 'yes' / {} / [] / function(){}.new NodeVM({ nesting: true })with norequirefield 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 whennestingis truthy and an explicitrequireconfig 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 thatnesting: trueis 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;makeResolverFromLegacyOptionsfalls into the "options-provided" branch and builds a resolver where the only builtin is stillvm2(via override), but the developer's intent is visible at the call site.- Sandbox-side
require('vm2')whennesting: falseβ already throwsEDENIEDbecause the override is not installed. Unaffected. mocks/overridesβ bypass the resolver entirely; unaffected by this fix and unaffected bynesting: true(mocks don't carry thevm2package).
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:
_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_passthroughThese 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, bypasseshttp/httpsblocking.require('_http_server').Server(handler).listen(0)β listening HTTP socket, bypassesnetblocking.require('_tls_wrap').TLSSocket/_tls_commonβ TLS primitives, bypasstlsblocking.
Attack Flow
- Embedder writes the documented "allow everything except network" pattern:javascript
new NodeVM({require: {builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2']}}) - The
'*'wildcard expands toBUILTIN_MODULES, which (pre-fix) includes every_http_*and_tls_*sibling because none of them matchinternal/orDANGEROUS_BUILTINS. - Sandbox code calls
require('_http_client'). The allowlist contains it,addDefaultBuiltinwraps the host module invm.readonly(), and the proxy is handed to the sandbox. - Sandbox calls
new (require('_http_client').ClientRequest)({host: '127.0.0.1', port: 80, ...})β outbound HTTP request from the host. Equivalent attack via_http_serveropens a listening socket.
Canonical Example
// (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:
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_clientregisters it withmakeBuiltins(['_http_client'])βaddDefaultBuiltindoes not consult thes.startsWith('_')filter. The legacyrequire.builtinoption is not such a route: both its array branch and its object-map branch iterateBUILTIN_MODULESand admit only names present there, sobuiltin: ['_http_client']andbuiltin: {_http_client: true}name a module that is not in the list and resolve toCannot find module '_http_client'. mock/overrideregistrations under underscored names continue to function β they bypassaddDefaultBuiltinentirely.
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').builtinModulespublished asModule.builtinModulesinside the sandbox (lib/setup-node-sandbox.js:140) β this is a static metadata list, not a loader. Sandbox code seeing_http_clientin the list does not gain the ability to load it; the resolver gates bythis.builtins.has(x).- Custom resolvers building their own builtins map via
makeBuiltinsFromLegacyOptionsβ same source list (BUILTIN_MODULES), same filter, same protection. hostRequireregistered bymock/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_barbuiltin 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 hostIncomingMessageobjects for every HTTP request the embedder serves, with fullAuthorization,Cookie,x-session-tokenheaders intact.async_hooksβasync_hooks.executionAsyncResource()returns the current hostAsyncResource. Embedders that useAsyncLocalStoragefor 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-sideperformance.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)invokessetpriority(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. Thedns/promisessubpath shares the surface and is covered by the same denial via the family-prefix matcher.
Canonical Example
// (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:
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 diskThe os / dns extension (GHSA-m5w8-4gq2-6f8x) β note the two host-process writes:
// (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):
- Filtered out of
BUILTIN_MODULESβ closes the'*'wildcard expansion path.builtin: ['*']andbuiltin: ['*', '-fs']no longer auto-allow these names. - Rejected in
addDefaultBuiltinviaisDangerousBuiltin(key)β closes the explicit-allowlist path (builtin: ['perf_hooks']), the object-map form (builtin: { v8: true }), and the lower-levelmakeBuiltins(['async_hooks'])API used by custom resolvers. - Family-prefix check β any
<family>/...whose family is in the denylist is also blocked (e.g. hypotheticalperf_hooks/foo). 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: ['*']orbuiltin: ['*', '-X']in NodeVM config β on an unpatched version this auto-allowsdiagnostics_channel,async_hooks,perf_hooks,v8; here they are filtered. Same caveat as Category 21:'*'still allowsfs,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
osexposes 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 withprocess.envexposure expectations. Embedders who consider hostname/userInfo()sensitive should excludeosexplicitly.dnscan resolve internal hostnames and exfiltrate via DNS lookup. Network-IO class, same ashttp/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 extendedDANGEROUS_BUILTINS). The sandbox globalprocessis a curated stub defined inlib/setup-node-sandbox.js.worker_threads.parentPortandworker_threads.workerDataβ would expose host worker IPC channel and initial data. Already denied by Category 21 (entireworker_threadsmodule 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) β handspathto 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 reportsERR_CRYPTO_ENGINE_UNKNOWN. Native RCE.node:sqliteDatabaseSync(':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 anynode:-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 callscrypto.getFips()observes the sandbox-chosen value (guestsetFips(1)β hostgetFips()returns1). 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 byurl'sURLSearchParams.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 releasedTLSSocketwhenever 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 β
setEnginereplaced with a stub that throws instead of forwarding to host OpenSSL, so no library is ever loaded;setFipsreplaced with a throwing stub so guest code cannot flip the host process's FIPS mode (GHSA-x3v6-43hc-82mc).setEngineandsetFipsare the onlyset*memberscryptoexposes, so the class is closed;getFips()(read-only) is untouched. The deprecatedcrypto.fipsaccessor (DEP0093, setter backed bysetFips) is non-enumerable, so theObject.assigncopy never carries it, and the read-only wrap refuses the write regardless. - node:sqlite β the
DatabaseSyncconstructor is wrapped soallowExtensionis forced off (for object- and function-typed options args β Node'sDatabaseSyncaccepts a function as options, and functions carry own properties); Node itself then throwsERR_INVALID_STATEfrom bothloadExtension()andenableLoadExtension(). The resolver is also hardened to collapse/reject repeatednode:prefixes. - tls β
setDefaultCACertificatesreplaced with a throwing stub (parallels the existingdnsdenial for process-wide network-state mutation). - http / https β
globalAgentreplaced with a fresh sandbox-dedicatedAgent, so the sandbox can never reach the host's shared singleton; the module's ownrequest()/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 futurecryptoset*member).node:sqliteDatabaseSync(..., {allowExtension: true})or.loadExtension(...)/.enableLoadExtension(...)from sandbox code; also anyrequire('node:node:...')double-prefix spelling.tls.setDefaultCACertificates(...)from sandbox code (watch forURLSearchParams.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.dlopenis already covered (the wholeprocessbuiltin is denied). Any future builtin that gains aloadExtension-style native loader must be added toBUILTIN_MEMBER_SANITIZERSorDANGEROUS_BUILTINS. http/httpsrequest 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-requestcaoptions β 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
- 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 "onlyleft-pad". - Sandbox requires a colliding or traversing specifier β
require('evil-left-pad')(substring collision) orrequire('left-pad/../evil-package')(traversal out of the package boundary). - The unanchored / tail-permissive pre-check passes, so
customResolvecalls the embedder's resolver with the attacker's specifier. - 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. customResolvepushes the resolved path ontothis.externalsand callsloadNodeModules, soisPathAllowedForModulenow approves it.- Host
require()executes the module's top-level code in host context β hostchild_process, hostfs, host env/secrets. Sandbox escape.
Canonical Example
// 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, sox@scope/pkgand@scope-evil/pkgare 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:
- Anchor the matcher (
LegacyResolverconstructor) βexternalCacheentries 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/pkgand@scope/pkg/subbut no longer matchesx@scope/pkgor@scope-evil/pkg.this.externals(the filename-side matcher, built bymakeExternalMatcher) is deliberately not changed β it matches resolved absolute paths, a different namespace. - 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,..fooas 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.resolvethat itself mapsleft-padto an arbitrary file is outside vm2's control by design; this fix constrains only which specifiers reach it. - Case-insensitive filesystems.
LEFT-PADdoes 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, soleft-pad\\..\\evilandleft-pad/..\\evilare rejected on POSIX too β verified empirically on darwin. On POSIX a backslash is a legal filename character andpath.resolvewould 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/evilandleft-pad/....//evilreach the resolver, butrequire()performs no URL-decoding andpath.resolvetreats....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:
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:
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
- Embedder allowlists one package, transitive loading off β
new NodeVM({require: {external: {modules: ['foo'], transitive: false}, root: appDir}}). Intended policy: "fooonly, andfoomay not pull in anything else". - 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. - An allowlisted package has a reachable relative-require path β
foo/index.jsdoesrequire('../foo2')on some code path the sandbox can trigger. - The prefix test approves the sibling β
path.startsWith(mod.path)is true, the remainder2/index.jshas nonode_modulessegment, soisPathAllowedForModulereturnstrue. - The un-allowlisted sibling loads, its top-level code runs, and its exports reach sandbox code β a package the embedder never authorized, with
transitive: falseexplicitly set.
The custom-resolver route (GHSA-5h3f-q97h-ccvc) needs neither a cooperating package nor transitive:
- 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, andcontext: 'host'(the default) means resolved modules run in the host realm. - 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, andcustomResolverecords that path as authorized. - Sandbox requires a prefix-sharing sibling by absolute path β
require('/app/node_modules/foo2/index.js'). The recorded prefix has no boundary, soisPathAllowedForModule'sthis.externalsfallback approves it. - The host
require()runs the sibling's top-level code, in the host realm, with the embedder's authority. Sandbox escape.
Canonical Examples
// 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/foo2The custom-resolver route, where the authorization record itself is widened:
// 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.jsxand/app/pkgs/foo/index.js.evil.jsthe same way. - A resolver returning the object shape (
{module: 'foo', path: '/app/node_modules'}) authorized/app/node_modulesitself, 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.
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/fooauthorizes itself and its descendants;/app/node_modules/foo2/index.js,/app/pkgs/foo/index.jsxand/app/pkgs/foo/index.js.evil.jsare denied. Bases are stored throughthis.fs.resolve, and every candidate reachesisPathAllowedalready normalized bytryFile/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.pathis anode_moduleslookup directory; onlypath/`` β the first specifier segment, or the first two for a scoped name β is recorded, so resolving@sc/pkgdoes not authorize@sc/pkg2. Amodulethat 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:customResolvereturnsundefinedand the standard loader reports module-not-found. - An answer the loader has to find by probing an extension still resolves, exactly.
LOAD_AS_FILEprobes<ext>for each configured extension, which a boundary-matched base does not cover, so those candidates are authorized individually inexternalExactand matched by full string equality βresolve()returning/app/pkgs/foo/indexstill loadsindex.js, and{module: 'bar', path: dir}still loads the filedir/bar.js. An exact entry authorizes no descendants and no siblings, soindex.jsx,index2.js,bar.jsxandbar2.jsstay denied. Extensions that are not their own basename are skipped, exactly astryWithExtensionskips them, so a separator inside a co