# Sandbox Escape Attack Patterns This document catalogs known attack patterns used to circumvent the vm2 sandbox. It is intended for AI agents and reviewers evaluating new code contributions. Each section describes a category of attack, provides canonical examples, explains why it is dangerous, and lists detection heuristics for spotting similar patterns in contributed code. ## How to Use This Document When reviewing a code contribution: 1. Check if any code (including test fixtures, examples, or "utility" helpers) matches the patterns in the family files. 2. Pay special attention to code that accesses `.constructor`, `__proto__`, `Symbol.species`, `Proxy`, `Reflect`, or overrides built-in prototype methods. 3. Any code that attempts to obtain a reference to host-realm `Function`, `process`, `require`, or `child_process` from within sandbox context is an escape attempt. 4. The ultimate goal of almost every escape is: get a reference to the host `Function` constructor, then call `Function("return process")()` to access Node.js `process` and from there `require("child_process").execSync(...)`. The catalog is split by mechanism family: this file is the index and the common material, and each family under `docs/attacks/` holds the numbered entries. Start from the [Category Index](#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](#category-entry-format) and verify the fix against the [Defense Invariants](#defense-invariants). --- ## Category Index ### Families | Family | Mechanism | Categories | Invariants | |---|---|---|---| | [Host Reference Primitives](attacks/host-reference-primitives.md) | 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](attacks/error-sanitization.md) | Exceptions and error containers as carriers of host references past `handleException` | 4, 16, 17, 38, 39, 48, 49 | 2, 3, 5 | | [Promise and Async](attacks/promise-async.md) | 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](attacks/host-prototype-mutation.md) | Writing into host intrinsic prototypes through bridge write traps, setter primitives, or `Receiver` confusion | 20, 26, 30, 32, 37, 50 | 6 | | [Bridge Internals](attacks/bridge-internals.md) | 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](attacks/transformer-and-modules.md) | Syntax the transformer cannot see, and dynamic code or module loading paths | 12, 13 | 9, 10 | | [NodeVM require and Allowlists](attacks/nodevm-require.md) | `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](attacks/host-resources.md) | Host memory, heap, process lifetime and the `timeout` guarantee: DoS and memory disclosure | 22, 23, 36, 41, 42 | none yet | ### Categories `Kind` is `primitive` (no prerequisites), `technique` (a delivery mechanism for primitives), or `compound` (a complete chain closed by an advisory fix). | # | Category | Family | Kind | Advisories | |---|---|---|---|---| | 1 | [Constructor Chain Traversal](attacks/host-reference-primitives.md#attack-category-1-constructor-chain-traversal) | [Host Reference Primitives](attacks/host-reference-primitives.md) | primitive | none | | 2 | [Prototype Chain Manipulation](attacks/host-reference-primitives.md#attack-category-2-prototype-chain-manipulation) | [Host Reference Primitives](attacks/host-reference-primitives.md) | primitive | none | | 3 | [Symbol-Based Attacks](attacks/host-reference-primitives.md#attack-category-3-symbol-based-attacks) | [Host Reference Primitives](attacks/host-reference-primitives.md) | primitive | none | | 4 | [Error Object Exploitation](attacks/error-sanitization.md#attack-category-4-error-object-exploitation) | [Error and Exception Sanitization](attacks/error-sanitization.md) | primitive | none | | 5 | [Function Caller/Callee Access](attacks/host-reference-primitives.md#attack-category-5-function-callercallee-access) | [Host Reference Primitives](attacks/host-reference-primitives.md) | primitive | none | | 6 | [Proxy Trap Exploitation](attacks/bridge-internals.md#attack-category-6-proxy-trap-exploitation) | [Bridge Internals](attacks/bridge-internals.md) | technique | none | | 7 | [Promise and Async Exploitation](attacks/promise-async.md#attack-category-7-promise-and-async-exploitation) | [Promise and Async](attacks/promise-async.md) | technique | GHSA-55hx-c926-fr95 | | 8 | [Cross-Realm Symbol Extraction from Host Objects](attacks/host-reference-primitives.md#attack-category-8-cross-realm-symbol-extraction-from-host-objects) | [Host Reference Primitives](attacks/host-reference-primitives.md) | technique | GHSA-m5q2-4fm3-vfqp, GHSA-47x8-96vw-5wg6, GHSA-jf8q-945g-9q4c | | 9 | [Proxy Handler Exposure via util.inspect](attacks/bridge-internals.md#attack-category-9-proxy-handler-exposure-via-utilinspect) | [Bridge Internals](attacks/bridge-internals.md) | technique | GHSA-v37h-5mfm-c47c, GHSA-qcp4-v2jj-fjx8 | | 10 | [Built-in Function Exploitation](attacks/host-reference-primitives.md#attack-category-10-built-in-function-exploitation) | [Host Reference Primitives](attacks/host-reference-primitives.md) | technique | none | | 11 | [Monkey-Patching Bridge Internals](attacks/bridge-internals.md#attack-category-11-monkey-patching-bridge-internals) | [Bridge Internals](attacks/bridge-internals.md) | technique | none | | 12 | [Code Transformation Bypass](attacks/transformer-and-modules.md#attack-category-12-code-transformation-bypass) | [Transformer and Module Loading](attacks/transformer-and-modules.md) | technique | GHSA-wp5r-2gw5-m7q7 | | 13 | [Dynamic Import and Module Loading](attacks/transformer-and-modules.md#attack-category-13-dynamic-import-and-module-loading) | [Transformer and Module Loading](attacks/transformer-and-modules.md) | technique | none | | 14 | [Object.prototype Trap Pollution via `in` Operator](attacks/bridge-internals.md#attack-category-14-objectprototype-trap-pollution-via-in-operator) | [Bridge Internals](attacks/bridge-internals.md) | technique | none | | 15 | [Property Descriptor Value Extraction](attacks/host-reference-primitives.md#attack-category-15-property-descriptor-value-extraction) | [Host Reference Primitives](attacks/host-reference-primitives.md) | technique | none | | 16 | [SuppressedError via Explicit Resource Management](attacks/error-sanitization.md#attack-category-16-suppressederror-via-explicit-resource-management) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | GHSA-55hx-c926-fr95, GHSA-35vh-489p-v7cx (dup of GHSA-55hx-c926-fr95) | | 17 | [WebAssembly JSTag Exception Catch](attacks/error-sanitization.md#attack-category-17-webassembly-jstag-exception-catch) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | none | | 18 | [Array Species Self-Return via Constructor Manipulation](attacks/host-reference-primitives.md#attack-category-18-array-species-self-return-via-constructor-manipulation) | [Host Reference Primitives](attacks/host-reference-primitives.md) | compound | GHSA-grj5-jjm8-h35p | | 19 | [Host prepareStackTrace Fallback via Array.fromAsync Promise Bypass](attacks/promise-async.md#attack-category-19-host-preparestacktrace-fallback-via-arrayfromasync-promise-bypass) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-v27g-jcqj-v8rw, GHSA-grj5-jjm8-h35p, GHSA-55hx-c926-fr95 | | 20 | [Host Intrinsic Prototype Pollution via Bridge Write Traps](attacks/host-prototype-mutation.md#attack-category-20-host-intrinsic-prototype-pollution-via-bridge-write-traps) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-vwrp-x96c-mhwq, GHSA-m5q2-4fm3-vfqp, GHSA-3vgf-8m4q-q4qr, GHSA-59g5-pmg6-5gr4 (dup of GHSA-3vgf-8m4q-q4qr) | | 21 | [NodeVM Builtin Allowlist Bypass via Host-Passthrough Builtins](attacks/nodevm-require.md#attack-category-21-nodevm-builtin-allowlist-bypass-via-host-passthrough-builtins) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-947f-4v7f-x2v8, GHSA-rp36-8xq3-r6c4, GHSA-8686-vhfx-7r3j, GHSA-6rh5-qq4q-97xh, GHSA-qhwx-74w5-xhxq, GHSA-m5w8-4gq2-6f8x, GHSA-pq68-rvw4-xp4r | | 22 | [Promise Executor Unhandled Rejection — Host Process DoS](attacks/host-resources.md#attack-category-22-promise-executor-unhandled-rejection--host-process-dos) (open residual) | [Host Resources](attacks/host-resources.md) | compound | GHSA-hw58-p9xv-2mjh, GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9 | | 23 | [Unbounded `Buffer.alloc(N)` — Host Heap DoS](attacks/host-resources.md#attack-category-23-unbounded-bufferallocn--host-heap-dos) | [Host Resources](attacks/host-resources.md) | compound | GHSA-6785-pvv7-mvg7, GHSA-gmc2-2x9w-cgh9, GHSA-v836-6xw4-9cx3 | | 24 | [NodeVM `require.root` Symlink Bypass (Path Check/Use TOCTOU)](attacks/nodevm-require.md#attack-category-24-nodevm-requireroot-symlink-bypass-path-checkuse-toctou) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-cp6g-6699-wx9c | | 25 | [NodeVM `nesting` Configuration Trap (NESTING_OVERRIDE-only resolver)](attacks/nodevm-require.md#attack-category-25-nodevm-nesting-configuration-trap-nesting_override-only-resolver) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-8hg8-63c5-gwmx, GHSA-m4wx-m65x-ghrr, GHSA-8hr7-r645-pc6w | | 26 | [Sandbox-Realm Null-Proto via Bridge `from()` — Set-Trap Write-Through](attacks/host-prototype-mutation.md#attack-category-26-sandbox-realm-null-proto-via-bridge-from--set-trap-write-through) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-mpf8-4hx2-7cjg, GHSA-9vg3-4rfj-wgcm | | 27 | [Internal State Probe via Computed Property Access on `globalThis`](attacks/bridge-internals.md#attack-category-27-internal-state-probe-via-computed-property-access-on-globalthis) | [Bridge Internals](attacks/bridge-internals.md) | compound | GHSA-wp5r-2gw5-m7q7, GHSA-2cm2-m3w5-gp2f | | 28 | [Bridge Internal-State Leak via Sandbox-Realm Array Setter](attacks/bridge-internals.md#attack-category-28-bridge-internal-state-leak-via-sandbox-realm-array-setter) | [Bridge Internals](attacks/bridge-internals.md) | compound | GHSA-9qj6-qjgg-37qq, GHSA-q3fm-4wcw-g57x, GHSA-grj5-jjm8-h35p | | 29 | [Async Generator yield*-Return Thenable Exception Capture](attacks/promise-async.md#attack-category-29-async-generator-yield-return-thenable-exception-capture) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-248r-7h7q-cr24 | | 30 | [Host Prototype Mutation via Bridged Setter Primitives](attacks/host-prototype-mutation.md#attack-category-30-host-prototype-mutation-via-bridged-setter-primitives) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-v6mx-mf47-r5wg, GHSA-9vg3-4rfj-wgcm | | 31 | [Promise Species Hijack in `localPromise` Swallow Tail](attacks/promise-async.md#attack-category-31-promise-species-hijack-in-localpromise-swallow-tail) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-hw58-p9xv-2mjh, GHSA-76w7-j9cq-rx2j | | 32 | [Bridge `set` Trap Ignores Spec `Receiver` — Inherited-Receiver Write-Through](attacks/host-prototype-mutation.md#attack-category-32-bridge-set-trap-ignores-spec-receiver--inherited-receiver-write-through) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-c4cf-2hgv-2qv6, GHSA-m5q2-4fm3-vfqp, GHSA-vwrp-x96c-mhwq | | 33 | [WebAssembly JSPI Cross-Realm Promise Prototype](attacks/promise-async.md#attack-category-33-webassembly-jspi-cross-realm-promise-prototype) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-6j2x-vhqr-qr7q, GHSA-55hx-c926-fr95, GHSA-wjwh-qqvp-g4p4, GHSA-m3pp-qgq7-gwm6 (dup of GHSA-wjwh-qqvp-g4p4) | | 34 | [NodeVM Wildcard Exposes Undocumented Underscored Builtins — Network Capability Bypass](attacks/nodevm-require.md#attack-category-34-nodevm-wildcard-exposes-undocumented-underscored-builtins--network-capability-bypass) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-r9pm-gxmw-wv6p | | 35 | [NodeVM Process-Wide Observability Builtins (Host-Data Info Leak)](attacks/nodevm-require.md#attack-category-35-nodevm-process-wide-observability-builtins-host-data-info-leak) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-m5w8-4gq2-6f8x, GHSA-9g8x-92q2-p28f, GHSA-rp36-8xq3-r6c4 | | 36 | [`bufferAllocLimit` Bypass via ArrayBuffer / TypedArray / WebAssembly.Memory](attacks/host-resources.md#attack-category-36-bufferalloclimit-bypass-via-arraybuffer--typedarray--webassemblymemory) | [Host Resources](attacks/host-resources.md) | compound | GHSA-6785-pvv7-mvg7, GHSA-v836-6xw4-9cx3 | | 37 | [Stacked Indirection Bypass of Host Prototype Mutator Peel](attacks/host-prototype-mutation.md#attack-category-37-stacked-indirection-bypass-of-host-prototype-mutator-peel) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-v6mx-mf47-r5wg, GHSA-cfcw-xp6x-25gj | | 38 | [`Error.cause` Host Reference Leak to Sandbox](attacks/error-sanitization.md#attack-category-38-errorcause-host-reference-leak-to-sandbox) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | GHSA-m283-3h24-438v | | 39 | [Host-Promise Rejection Sanitizer Bypass via `call`/`apply` Indirection](attacks/error-sanitization.md#attack-category-39-host-promise-rejection-sanitizer-bypass-via-callapply-indirection) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | GHSA-647f-g98j-qq25 | | 40 | [Host-Authority Builtin Members Survive the Read-Only Wrap](attacks/nodevm-require.md#attack-category-40-host-authority-builtin-members-survive-the-read-only-wrap) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-46pr-c5wc-xffx, GHSA-6w8r-xxw2-g3hx, GHSA-98xx-8mx4-x7cm, GHSA-h85j-hv3c-qfgq, GHSA-x3v6-43hc-82mc | | 41 | [Shared Buffer Pool Discloses / Corrupts Host Memory](attacks/host-resources.md#attack-category-41-shared-buffer-pool-discloses--corrupts-host-memory) | [Host Resources](attacks/host-resources.md) | compound | GHSA-fcqc-726x-5wfc, GHSA-489w-w794-jq94 | | 42 | [`FinalizationRegistry` Cleanup Callback — `timeout` Protection-Mechanism Failure](attacks/host-resources.md#attack-category-42-finalizationregistry-cleanup-callback--timeout-protection-mechanism-failure) | [Host Resources](attacks/host-resources.md) | compound | GHSA-r4fx-v8hh-22mv | | 43 | [Stale `PromiseThenLookupChain` Protector — Species Survives `finally`](attacks/promise-async.md#attack-category-43-stale-promisethenlookupchain-protector--species-survives-finally) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-27g9-p43v-cw3v | | 44 | [`vm.freeze()` Read-Only Bypass via Accessor Setter Leak](attacks/bridge-internals.md#attack-category-44-vmfreeze-read-only-bypass-via-accessor-setter-leak) | [Bridge Internals](attacks/bridge-internals.md) | compound | GHSA-633r-hq9m-c4ff | | 45 | [NodeVM External-Package Allowlist Bypass via Unanchored Matcher and `..` Traversal](attacks/nodevm-require.md#attack-category-45-nodevm-external-package-allowlist-bypass-via-unanchored-matcher-and--traversal) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-c48m-32m9-vx93 | | 46 | [NodeVM External-Package Allowlist Bypass via Unanchored Module-Path Prefix](attacks/nodevm-require.md#attack-category-46-nodevm-external-package-allowlist-bypass-via-unanchored-module-path-prefix) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-7q3f-wx44-378m, GHSA-5h3f-q97h-ccvc | | 47 | [Sandbox Rebuilt an Unrestricted NodeVM by Requiring vm2 From Disk; Shipped CLI Ran Untrusted Scripts With No Effective Sandbox Boundary](attacks/nodevm-require.md#attack-category-47-sandbox-rebuilt-an-unrestricted-nodevm-by-requiring-vm2-from-disk-shipped-cli-ran-untrusted-scripts-with-no-effective-sandbox-boundary) (open residual) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-jxxv-8r27-vm4p, GHSA-j3hm-6rg5-mchv, GHSA-cp6g-6699-wx9c | | 48 | [Host Filesystem Path Leak via Host-Realm Error Stack](attacks/error-sanitization.md#attack-category-48-host-filesystem-path-leak-via-host-realm-error-stack) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | GHSA-v27g-jcqj-v8rw, GHSA-x6m4-chr9-cg97 | | 49 | [Revisited Host Error Carrier Leaks a Live Proxy Through the Sanitizer Cycle Memo](attacks/error-sanitization.md#attack-category-49-revisited-host-error-carrier-leaks-a-live-proxy-through-the-sanitizer-cycle-memo) | [Error and Exception Sanitization](attacks/error-sanitization.md) | compound | GHSA-x965-fc75-jpqh | | 50 | [Host Prototype-Chain Climb via Raw `__proto__` Getter (Reader Side)](attacks/host-prototype-mutation.md#attack-category-50-host-prototype-chain-climb-via-raw-__proto__-getter-reader-side) | [Host Prototype Mutation](attacks/host-prototype-mutation.md) | compound | GHSA-88hf-g992-jg85 | | 51 | [`allowAsync: false` Bypass via Promise Thenable Assimilation](attacks/promise-async.md#attack-category-51-allowasync-false-bypass-via-promise-thenable-assimilation) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-f8gf-w286-fmq2 | | 52 | [Host `util` Members Auto-Forwarded to the Sandbox (`util.getCallSites` Host Call-Stack Leak)](attacks/nodevm-require.md#attack-category-52-host-util-members-auto-forwarded-to-the-sandbox-utilgetcallsites-host-call-stack-leak) | [NodeVM require and Allowlists](attacks/nodevm-require.md) | compound | GHSA-r273-hxvj-fxhp | | 53 | [Host-Promise `@@species` Hijack + Missing Handler Delivers the Raw Settlement to the Sandbox](attacks/promise-async.md#attack-category-53-host-promise-species-hijack--missing-handler-delivers-the-raw-settlement-to-the-sandbox) | [Promise and Async](attacks/promise-async.md) | compound | GHSA-6454-5x88-m6jw | | 54 | [Host Global Leak via a Sloppy Host Function's Nullish `this` (OrdinaryCallBindThis)](attacks/host-reference-primitives.md#attack-category-54-host-global-leak-via-a-sloppy-host-functions-nullish-this-ordinarycallbindthis) | [Host Reference Primitives](attacks/host-reference-primitives.md) | compound | GHSA-j89j-5m6r-cr2q | --- ## Category Entry Format Every category lives in exactly one family file under `docs/attacks/`. Category numbers are permanent identifiers: they are never reused, reassigned, or renumbered, because `CHANGELOG.md`, `test/ghsa/*/repro.js` and the skills refer to "Category N". Each entry uses the following structure: - **Heading**: `## Attack Category N: `. N is the next unused number across all families. - **`**Advisories**:`** — required. Every GHSA ID the entry covers. Mark duplicates as `GHSA-xxxx (dup of GHSA-yyyy)`. Write `none` for categories that predate the advisory process. - **`**Tests**:`** — required. Paths to the regression tests: `test/ghsa//` directories, and suite tests cited as `test/vm.js ("")`. - **`**Uses**:`** — present on most techniques and compounds (a linked list of the prerequisite categories the attack composes); the `Kind` column of the category index is the authority for whether an entry is a primitive, technique, or compound. - **`**Supersedes**:`** — optional. Link to an earlier category whose mitigation was specific rather than structural and is now subsumed by this fix. - **`### Description`** — What the attacker can do and the underlying mechanism. - **`### Attack Flow`** — Numbered step-by-step breakdown. - **`### Canonical Example(s)`** — Code blocks. Include all known variants when multiple bypass paths exist. - **`### Why It Works`** — Why the existing defenses didn't prevent this. Reference V8 internals where relevant. - **`### Mitigation`** — The structural fix. Cite the file and function. Reference the [Defense Invariant](#defense-invariants) the fix enforces. - **`### Fix shape`** — Optional. The structure of the fix stated separately from the code walk-through, where the Mitigation is long enough that the shape would otherwise be lost in it (Categories 39 and 48). - **`### Detection Rules`** — Bulleted heuristics for spotting similar patterns in code review. - **`### Considered Attack Surfaces`** — Optional. Adjacent surfaces analysed and ruled out, so future reviewers don't re-investigate. - **`### Known Residual`** — Optional. Must name the condition under which the residual becomes a bug, and once a later category closes it, a one-line pointer to that category replaces the section. Write in the present tense. An entry describes a closed hole and the structure that keeps it closed, except where a `Known Residual` section or an explicit status line says otherwise; as of this revision Categories 22 and 47 carry open residuals. The fix is the Mitigation section, so retrospective markers announcing that a hole is fixed, and phrasings such as "historically" or "was dangerous", do not appear. Where the pre-fix behaviour genuinely has to be stated, scope it explicitly — "Before GHSA-xxxx, the trap did Y. The trap now does Z." Links between categories always carry the file name (`family.md#anchor` from a family file, `attacks/family.md#anchor` from this index), and links to this document from a family file use `../ATTACKS.md#anchor`. `test/docs-catalog.js` fails the suite on a duplicate number, a gap in numbering, an unresolved link, or a missing metadata line. If a new vulnerability fits an existing category, add it as an additional canonical example, extend the Advisories and Tests lines, and update the Mitigation. Only create a new category for genuinely novel attack classes. After adding an entry: 1. Place it in the family file whose mechanism matches. If none fits, add a family file and a row to the family table below; that should be rare. 2. Add its row to the category index below. 3. Add a row to **Summary → How The Bridge Defends** and, for a compound, an entry to **Summary → Compound Attack Patterns**. 4. Run `npm test`; `test/docs-catalog.js` checks numbering, links, and metadata. 5. Add a one-line entry to `CHANGELOG.md` under the next release. --- ## Fundamentals Before diving into specific attack categories, it is essential to understand the architectural constraints that make sandbox escapes possible and the design choices that shape the defense surface. ### Realm Separation vm2 runs untrusted code inside a V8 context created by Node.js's `vm` module. Host and sandbox share the **same V8 isolate** -- they execute on the same thread, in the same heap. The sandbox gets its own set of global intrinsics (`Object`, `Function`, `Array`, `Error`, etc.), but these are all allocated from the same memory space as the host's intrinsics. There is no process boundary, no memory isolation, and no privilege separation at the OS level. If an attacker obtains a reference to any host-realm constructor, they can evaluate arbitrary code in the host context. A "host-realm object" is any object whose prototype chain leads to the host's intrinsics. A "sandbox-realm object" leads to the sandbox's intrinsics. The bridge's job is to ensure that sandbox code never sees a host-realm object directly -- only proxied wrappers that sanitize every property access. ### The Bridge Proxy Model `lib/bridge.js` is the core of vm2. It maintains two WeakMaps: - **`mappingThisToOther`**: maps host objects to their sandbox proxy wrappers (and vice versa, depending on which side loaded the bridge). - **`mappingOtherToThis`**: maps sandbox proxies back to the host objects they wrap. When a host object crosses into the sandbox, `thisFromOther(other)` looks it up in `mappingThisToOther`. If already wrapped, the existing proxy is returned (identity preservation). If new, a proxy is created whose traps sanitize every property access, method call, and prototype traversal. The **proxy invariant problem**: proxies preserve object identity (the same host object always maps to the same sandbox proxy), but every trap is an attack surface. Each trap must correctly handle attacker-controlled inputs, V8 internal algorithm invocations, and edge cases like non-configurable properties. The bridge is essentially a manually-written membrane, and any gap in the membrane is a potential escape. ### V8 Internal Algorithms vs JS-Level Code This is the **root cause** of most attacks in this document. V8 implements many specification algorithms in C++ (ArraySpeciesCreate, FormatStackTrace, PromiseResolveThenableJob, etc.). These C++ algorithms operate on raw object pointers, **bypassing proxy traps entirely** in many cases. When V8's C++ code reads `obj.constructor` for species resolution, it reads the actual property on the underlying object -- not the proxy's `get` trap return value. When V8's stack formatter calls `Error.toString()`, it runs in whatever realm created the error. This means: **any defense that relies solely on proxy traps is incomplete**. The bridge must also neutralize the raw objects themselves (e.g., setting `constructor = undefined` directly on host arrays) and control V8-level hooks (e.g., `Error.prepareStackTrace`). ### The Transformer's Role `lib/transformer.js` uses Acorn to parse sandbox code and instrument it: - **`catch` blocks**: Wrapped so that `handleException(e)` is called on every caught value. This sanitizes host-realm errors that V8 might throw (e.g., TypeError from type coercion failures). - **`with` statements**: Instrumented to prevent scope chain manipulation. `handleException` (defined in `lib/setup-sandbox.js`) calls `ensureThis` on the caught value, which walks the prototype chain and converts host objects to sandbox proxies. It also detects `SuppressedError` instances and recursively sanitizes their `.error` and `.suppressed` properties. **Critical limitation**: The transformer uses Acorn with `ecmaVersion: 2022`. Syntax introduced after ES2022 -- notably `using` declarations (ES2024) -- is invisible to the transformer when the keyword fast path skips the AST parse (a parsed source is rejected by acorn at `ecmaVersion: 2022`). Code using `using` inside `eval()` bypasses catch-block instrumentation entirely. ### The Error Generation Primitive A pattern that appears in nearly every compound attack: ```javascript const e = new Error(); e.name = Symbol(); // Symbol cannot be converted to string e.stack; // V8's C++ FormatStackTrace calls e.toString() -> TypeError ``` When V8 formats a stack trace, its C++ code calls `Error.prototype.toString()`, which concatenates `this.name + ": " + this.message`. If `name` is a `Symbol`, the string concatenation throws a `TypeError`. Depending on where this TypeError is caught, it may be a **host-realm** error (if the formatting happened in host-side code like `prepareStackTraceCallback`) or a sandbox-realm error (if the sandbox's `prepareStackTrace` handled it). This primitive generates host errors on demand and is referenced throughout this document. ### The `isHost` Asymmetry The bridge is loaded in both the host and the sandbox, but the security requirement is asymmetric: **only the sandbox side needs protection from leaking host objects**. The host side freely receives sandbox objects (they cannot harm the host). This means the bridge's proxy traps are primarily defensive on the sandbox-facing side: the `get` trap blocks dangerous constructors, the `ownKeys` trap filters dangerous symbols, the `apply` trap neutralizes species attacks, etc. ### The Escape Chain Almost every escape follows this progression: ``` Sandbox Object -> Host Constructor (Function) -> Code Evaluation ("return process") -> process.mainModule.require -> require("child_process") -> execSync("arbitrary command") ``` Block **any** step in this chain, and the escape fails. The bridge blocks step 1 (constructor access). The transformer blocks catch-clause access. Promise sanitization blocks async paths to step 2. Defense in depth means multiple layers must be bypassed for a successful escape. --- ## Defense Invariants These are the cross-cutting properties the sandbox must preserve. A fix that closes a specific PoC without restoring the relevant invariant is **specific** and will admit variants. A fix that restores the invariant at the right chokepoint is **structural**. Every Mitigation section should reference the invariant it enforces. 1. **No host-realm object reaches sandbox code unwrapped.** Every value crossing the boundary is a primitive, a sandbox-realm object, or a bridge proxy. `thisFromOther` / `ensureThis` is the single chokepoint; the WeakMap caches preserve identity. 2. **All caught exceptions are sanitized.** Every value entering a `catch` clause passes through `handleException`. Paths that bypass JS-level `catch` instrumentation (Wasm `try_table`, host-realm `Promise.then` rejection) are closed at the bridge. 3. **Cross-realm error containers are recursively sanitized.** `SuppressedError`, `AggregateError`, and `Error.cause` may carry host references in nested fields. `handleException` walks the structure with cycle detection. 4. **V8 internal algorithms cannot read attacker-controlled `constructor` on host objects.** ArraySpeciesCreate, PromiseResolveThenableJob, and similar C++ paths bypass proxy traps. The bridge neutralises raw `constructor` slots on host arrays before every host-side call (`neutralizeArraySpeciesBatch`, via `neutralizeArraySpeciesOn`) and pre-sets `Promise.constructor` as an own data property before `.then`/`.catch` (`resetPromiseSpecies`). 5. **`Error.prepareStackTrace` always resolves to a sandbox-realm safe default.** V8 must never fall back to the host's `prepareStackTraceCallback`. Setting `Error.prepareStackTrace = undefined` in the sandbox restores the safe default rather than removing it. 6. **Host-realm intrinsic prototypes are read-only from the sandbox.** `Object.prototype`, `Array.prototype`, `Function.prototype`, etc. cannot be polluted, deleted from, or frozen via bridge write traps. Mutability is preserved for non-intrinsic host objects (Buffer instances, embedder-exposed configs). 7. **Cross-realm well-known symbols are not extractable.** `Symbol.for('nodejs.util.inspect.custom')` and similar cross-realm symbols are filtered at the bridge so sandbox code cannot use them as a channel to register host-side callbacks. 8. **Reflect and dangerous-constructor identity is captured at init time.** The bridge caches `Reflect.*` references and built-in constructors before sandbox code runs. Sandbox-side monkey-patching of these cannot affect bridge internals. 9. **Post-ES2022 syntax is treated as a transformer blind spot.** `using`, `await using`, and any future syntax not understood by Acorn (`ecmaVersion: 2022`) bypasses catch instrumentation when the keyword fast path skips the AST parse (a parsed source is rejected by acorn at `ecmaVersion: 2022`). Defenses must hold even when no transformer instrumentation runs over the relevant scope. 10. **Dynamic code compilation paths cannot reach an unwrapped host realm.** `Function`, `eval` with host references, and dynamic `import()` are blocked or proxied. `import()` throws `VMError` unconditionally. 11. **Bridge-internal containers must not invoke sandbox code.** Lists, maps, and saved-state records allocated for the bridge's exclusive use are reached from sandbox-realm closures whose intrinsics (`Array.prototype`, `Object.prototype`, `Map.prototype`) are attacker-reachable. Reads and writes on those containers must use prototype-bypassing primitives — `Reflect.defineProperty`, `Reflect.apply` over cached `WeakMap.prototype.{get,set}`, etc. — never operators (`obj[i] =`, `map.set`, `for...in`) that fall through to the sandbox prototype chain. Otherwise an attacker-installed setter/getter on `Array.prototype[N]` or `Object.prototype.` can capture or mutate the bridge's raw saved state. 12. **No sandbox-visible object has a host-realm prototype chain without bridge interposition.** Every Promise (and, by extension, every spec-defined async dispatch target) reachable from sandbox code is either (a) sandbox-realm with `globalPromise.prototype` in its `[[Prototype]]` chain — so the sandbox-side `.then`/`.catch` overrides apply — or (b) a bridge proxy of a host-realm Promise — so the bridge `apply`-trap interception applies. A third shape (sandbox-realm allocation with a host-realm prototype, with no proxy in between) bypasses both layers: `p.then`/`.catch`/`.finally` lookup walks across realms to host native methods directly, `Object.defineProperty(p, 'constructor', ...)` writes onto the raw object, and V8's host-realm `SpeciesConstructor` dispatches the rejection through attacker-controlled species without ever invoking a sandbox-visible chokepoint. Any V8/Node primitive that produces such an object — WebAssembly JSPI is the first known one — must be neutralized at sandbox bootstrap. See [Category 33](attacks/promise-async.md#attack-category-33-webassembly-jspi-cross-realm-promise-prototype). 13. **The NodeVM builtin allowlist is a closed system.** No Node builtin whose own API can reload, evaluate, debug, spawn, or otherwise re-enter host code (`module`, `worker_threads`, `cluster`, `vm`, `repl`, `inspector`, `process`, `trace_events`, `wasi`) is reachable from the sandbox, regardless of how the embedder writes `builtin` — wildcard, explicit name, object syntax, low-level `makeBuiltins`. The check is family-prefix and `node:`-normalised, so subpath builtins (`inspector/promises`) and URL-style spellings (`node:process`) share fate with their canonical name. The only way to re-expose any of these names is to register a sandbox-safe wrapper through `SPECIAL_MODULES`, `mocks`, or `overrides` — i.e. the embedder must consciously opt into a stub that is not the raw host module. 14. **`allowAsync: false` is a closed synchronous boundary.** When async is disabled, no sandbox-reachable operation may schedule code into a later microtask — there is nothing to run after `run()` returns. Blocking `Promise.prototype.then` is not sufficient: thenable assimilation (`PromiseResolveThenableJob`) reaches sandbox code through every native resolve capability without consulting `.then`. Every entry point that can resolve a native promise with a thenable — the static methods (`resolve`/`all`/`race`/`any`/`allSettled`/`try`), the constructor's resolve argument, `withResolvers`, `Array.fromAsync`, and the realm-intrinsic base reachable via `Object.getPrototypeOf(Promise)` — must refuse object/function resolutions (TOCTOU-safe, without reading `.then`) or throw. See [Category 51](attacks/promise-async.md#attack-category-51-allowasync-false-bypass-via-promise-thenable-assimilation). The [Security Checklist for Bridge Changes](#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). 1. **Prototype Pollution + Proxy Trap** [Categories 2, 6]: Pollute `Object.prototype` to inject trap handlers, then trigger the trap via bridge operations. 2. **Symbol.species + Async Error** [Categories 3, 7]: Set `Symbol.species` to custom class, trigger host error in async path, receive unsanitized error in custom class constructor. 3. **Built-in Override + Type Coercion** [Categories 10, 11]: Override `Array` or `Object.create`, then pass object with `valueOf()` to `Buffer.from()` to trigger the override. 4. **Monkey-patch + Promise** [Categories 7, 11]: Override `Function.prototype.call`, then trigger `Promise.then()` to intercept internal callback dispatch. 5. **Object.defineProperty disable + Species Attack** [Categories 3, 11]: Override `Object.defineProperty` to no-op, preventing species reset, then exploit unprotected species. 6. **Symbol Extraction + Array Monkey-patch** [Categories 8, 11]: Override `Array.prototype.splice`/`push` to no-op, then call `Object.getOwnPropertySymbols(hostObj)` hoping the filter uses array methods. 7. **Internal [[OwnPropertyKeys]] + Proxy Trap** [Categories 6, 8]: Call `Object.assign(proxyTarget, hostObj)` where `proxyTarget` is a Proxy with a `set` trap to leak real symbols. 8. **Constructor Accessor TOCTOU + Species Attack** [Categories 3, 7]: Define a getter on `p.constructor` that returns `Promise` on first read (passes check) but returns malicious `Symbol.species` on subsequent reads. 9. **Prototype Mutation + Species TOCTOU** [Categories 2, 7]: Access `globalPromise.prototype` and replace `constructor` data property with accessor. Own-property checks miss inherited accessors. 10. **Symbol.hasInstance + Species Attack** [Categories 3, 7]: Override `Symbol.hasInstance` on `globalPromise` so `instanceof` fails, causing `resetPromiseSpecies` to skip. 11. **Promise Static Method Stealing + Error Trigger** [Categories 4, 7]: Copy Promise static methods to FakePromise, trigger host error during iteration/callback. Unsanitized error goes to FakePromise's reject handler. 12. **Reflect.construct instanceof bypass + Species** [Categories 3, 7]: Use `Reflect.construct(Promise, [...], FakePromise)` to bypass `instanceof` guard, combined with `FakePromise[Symbol.species] = FakePromise`. 13. **Duck-typing + showProxy + Handler Exposure** [Categories 8, 9]: Create Buffer duck-type, invoke inspect with `showProxy: true`, probe exposed handler. 14. **Duck-typing + showProxy + fromOtherWithContext** [Categories 8, 9]: Same as 13 but specifically exploits `fromOtherWithContext` method. Variant: `doPreventExtensions`. 15. **Host prepareStackTrace + Array.fromAsync + SuppressedError** [Categories 4, 7, 16, 19]: Walk prototype chain to get host Array, call `Array.fromAsync` for host promise, use `using` + Error Generation Primitive to create SuppressedError with host TypeError, catch via host promise's unsanitized `.catch()`. 16. **Duck-typing + showProxy + handler.get() with forged target** [Categories 1, 9]: Obtain handler via showProxy, use host-side `Array.reduce` to call `handler.get(rawHostFunction, 'constructor')`, leaking raw host `Function` via prototype chain fallback. 17. **Descriptor Extraction + Object.entries + Array Unwrap** [Categories 1, 15]: Get property descriptor for `Function.prototype.constructor`, build nesting via chained `getOwnPropertyDescriptors`, extract with `Object.entries()`. Defense: recursive `containsDangerousConstructor` with cycle detection. 18. **SuppressedError + Symbol Name Trick** [Categories 4, 16]: `DisposableStack.defer()` or `using` with dispose method triggering Error Generation Primitive. `SuppressedError` wraps unsanitized host TypeError. Also works with `AsyncDisposableStack`. 19. **WebAssembly JSTag Exception Catch** [Categories 4, 12, 17]: Wasm module imports JSTag and trigger function. Trigger causes host TypeError via Error Generation Primitive. Wasm `try_table/catch` catches and returns unsanitized externref. 20. **Array Species Self-Return + Object.assign** [Categories 3, 10, 18]: Create host array, set up self-referential species constructor, inject via `Object.assign` (bypasses proxy `set` trap), call `r.map(f)` for raw host values. Chain `cwu` calls to extract host `Function`. 21. **Host Built-in Identity Leak via Proto Walk** [Categories 1, 2, 8]: Walk the prototype chain via `({}).__lookupGetter__('__proto__')` composed with `Buffer.apply` (or any host-bound `__proto__` getter) to terminate at host `Object.prototype`, then read `.constructor` to obtain a *reference* to host `Object` whose identity is disjoint from sandbox `Object`. The original symbol-filter patch (commit `67bc511`) closed the demonstrated RCE payload but left this primitive intact — any future bypass that turns "I have a host built-in handle" into "I can read a host symbol or call a host method that bypasses bridge sanitisation" would re-enable the same escape class. Closed structurally by `thisAddIdentityMapping` in `lib/bridge.js` (see Category 8 mitigation). 22. **Async Generator yield*-Return Thenable + Stack-Overflow Realm Skew** [Categories 4, 7, 29]: Use `yield*` to a no-`return` inner async iterator, then `.return(thenable)` where the thenable's `.then` synchronously throws via deep recursion. V8's `PromiseResolveThenableJob` captures the throw and the yield* continuation surfaces it as `{ value, done: false }` — bypassing both the transformer's user-`catch` instrumentation and the `globalPromise.prototype.then` rejection sanitiser. Binary-search the recursion depth where the overflow originates inside V8's host C++ code so the `RangeError` is host-realm, then `e.constructor.constructor("return process")()`. Closed by wrapping `%AsyncGeneratorPrototype%.next/.return/.throw` to route iterator-result `.value` and rejections through `handleException`, plus replacing every thenable arg with a sandbox-realm wrapper whose `.then` is a fixed `safeThen` and always-shadowing the non-function branch so V8's re-read of `.then` cannot observe attacker-controlled values. 23. **Host Prototype Mutation via Apply-Trap Indirection + WebAssembly Rejection** [Categories 2, 4, 7, 30]: Resolve host `Object.prototype.__proto__` setter via `Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__")` (the `connect()`-aliased sandbox `__lookupSetter__` walks back to host). Trigger a host-realm `TypeError` (e.g., `await WebAssembly.compileStreaming()`). Inside `catch(e)`, call `setProto.call(getProto.call(e), null)` — the apply trap unwraps `context` and forwards to the host setter, severing host `TypeError.prototype.[[Prototype]]` without any write trap firing. The next host `TypeError` from `await WebAssembly.compileStreaming()` walks back into sandbox code through V8 async internals; the bridge's proto-walk no longer finds the registered mapping at the right level and the value falls through unwrapped. `e.constructor.constructor` is then host `Function`. Closed structurally by (A) caching host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `Object.prototype.__define{Getter,Setter}__`) and refusing them in the apply trap with one layer of indirection peel for `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}`; (B) cache-check on `mappingOtherToThis` before the proto-walk in `thisEnsureThis` so any previously-bridged host value returns the existing proxy even with a tampered proto chain. 24. **Promise Species Hijack + Stack-Overflow Realm Skew** [Categories 4, 7, 18, 29, 31]: `class FakePromise extends Promise { static get [Symbol.species]() { return ct } }` reroutes the swallow-tail child constructor inside `localPromise` to a sandbox-controlled `ct`. `ct` rebinds V8's internal `(resolve, reject)` capability to a sandbox collector; trigger a host-realm `RangeError` via `e.stack` after deep recursion (binary-searched depth) inside the downstream chain; V8's `PromiseResolveThenableJob` delivers the raw host Error to the collector — `ex.constructor.constructor("return process")()` then yields RCE. Closed by adding `resetPromiseSpecies(this)` immediately before the swallow-tail `apply(globalPromisePrototypeThen, this, ...)` call so the species protocol always resolves to `localPromise` regardless of the user's subclass `Symbol.species` override. 25. **WebAssembly JSPI Cross-Realm Promise + Species Hijack** [Categories 3, 7, 33]: JSPI returns a sandbox-realm Promise with host-realm `Promise.prototype` in its `[[Prototype]]` chain — bypassing both the sandbox-side `.then`/`.catch` overrides and the bridge `apply`-trap callback wrapping. Install `Object.defineProperty(p, 'constructor', {get(){return F}})` directly on the raw object; `p.finally(()=>{})` calls host `Promise.prototype.finally`, whose internal SpeciesConstructor reads F and dispatches the eventual host-realm rejection (host `TypeError` from `WebAssembly.compileStreaming(Promise.resolve(0))`) through F's reject closure with **no bridge wrapping**. `e.constructor.constructor("return process")()` evaluates in host realm because `Function.[[Realm]]` is host → RCE. Closed by deleting `WebAssembly.promising` and `WebAssembly.Suspending` at sandbox bootstrap, mirroring the `WebAssembly.JSTag` removal. 26. **Stale Engine Protector + Species Hijack + Stack-Overflow Realm Skew** [Categories 3, 4, 7, 43]: On Node 26 / V8 14.6, vm2's `Promise.prototype.then`/`catch` overrides were installed by plain assignment, which left the `PromiseThenLookupChain` protector valid; `p.finally()` on an ordinary `(async () => 1)()` Promise took the `InvokeThen` fast path to the native `then` and never entered vm2's wrapper, so `resetPromiseSpecies` never ran. An own `constructor` with `Symbol.species` pointing at a sandbox class then received the native reaction's resolve/reject; driving that reaction into a calibrated stack overflow delivered a raw host-realm `RangeError` to the attacker's reject closure → `e.constructor.constructor` → host `Function` → host `process`, with `eval: false` and `wasm: false`. Closed by installing the wrappers via `Reflect.defineProperty` (which invalidates the protector) and by wrapping `Promise.prototype.finally` to run `resetPromiseSpecies(this)` before the cached native call. 27. **Read-Only View Setter Leak via Descriptor Extraction** [Categories 6, 15, 44]: `vm.freeze(cfg, 'cfg')` exposes a host object with an accessor property. The direct write traps (`set` / `defineProperty` / `deleteProperty`) are inert, but `Object.getOwnPropertyDescriptor(cfg, 'level').set` (or `__lookupSetter__`, `Reflect.getOwnPropertyDescriptor`, `Object.getOwnPropertyDescriptors`) returns a live bridge-wrapped host setter. `desc.set.call(cfg, value)` routes through `BaseHandler.apply` onto the unwrapped host object, mutating host state through a read-only view. Closed by overriding `ReadOnlyHandler.getOwnPropertyDescriptorDesc` to strip the `set` accessor before it is wrapped, leaving the getter operative — a single hook that closes all four descriptor-read channels. 28. **External Allowlist Substring Collision + Subpath Traversal** [Categories 21, 24, 45]: With `require: {external: ['left-pad'], resolve, context: 'host'}`, the `externalCache` pre-check in `LegacyResolver.customResolve` used unanchored regexes, so `require('evil-left-pad')` passed by substring containment; the embedder's custom resolver then located the colliding package in the application's dependency directory, `customResolve` appended the resolved path to `this.externals`, and host `require()` ran its top-level code in host context (host `child_process` from a sandbox configured with `builtin: []`). Anchoring the matcher to `^(?:)(?:[\\/].*)?$` closed the collision but not the second stage: the permitted subpath tail accepts `..` segments, so `left-pad/../evil-package` and `left-pad/sub/../../evil-package` walked out of the package boundary to the same effect, at a depth no regex lookahead can reach. Closed by both layers together — anchored matcher plus a segment-split rejection of any `..` in the bare specifier, applied before the resolver is consulted and before any canonicalization (`realpath` would erase the `..` evidence). 29. **Thenable Assimilation Past `allowAsync: false`** [Categories 7, 51]: Not a containment breach but a policy/timeout bypass. Hand a thenable to any native resolve capability — `Promise.resolve/all/race/any/allSettled/try`, `new Promise(r=>r(thenable))`, `withResolvers().resolve`, `Array.fromAsync`, or the realm-intrinsic base reached via `Object.getPrototypeOf(Promise)` — so V8's `PromiseResolveThenableJob` runs the attacker `.then` in a microtask after `run()` returns, outside `timeout`. Closed by guarding every resolve capability (TOCTOU-safe, refuses object/function without reading `.then`), throwing the static methods, neutralizing `Array.fromAsync`, and interposing a construct-guard Proxy on `localPromise`'s prototype so the native base is un-constructable from the sandbox — all gated to `allowAsync: false`. 30. **Host-Promise Species Hijack + Missing Handler** [Categories 7, 43, 53]: Write `constructor = {[Symbol.species]: Evil}` onto a raw host promise returned by an embedder API, then call `.then()` / `.catch()` / `.finally()` with the settlement-direction handler omitted. `SpeciesConstructor` reads the species off the raw host object (no trap), and V8's internal Thrower/Identity reaction delivers the raw host settlement to the sandbox-captured capability with no callback slot to sanitize. Defense: `neutralizeHostPromiseSpeciesOn` shadows the host promise's `constructor` across the call. 31. **Sloppy Host Function + Nullish Receiver** [Categories 10, 54]: Call any embedder-exposed non-strict host function with no receiver (`greet()`, `.call(null)`, `Reflect.apply(fn, undefined, [])`, `bind(null)()`). V8's OrdinaryCallBindThis substitutes the host realm's global for `this`, and whatever the function returns or stashes from `this` is the host global — `process.getBuiltinModule('child_process')` follows. Defense: the host global is refused at every host→sandbox coercion chokepoint, delivered as `undefined`. ### How The Bridge Defends | Attack | Defense | |--------|---------| | Constructor chain | Returns `{}` for Function constructor access; `isThisDangerousFunctionConstructor` blocks all variants | | __proto__ access | Intercepts and returns sandbox-side prototype | | Proxy traps | Wraps Proxy constructor, sanitizes handler objects, null-prototype handlers | | Symbol.species (Promise) | Unconditionally sets `p.constructor = localPromise` as own data property before every `.then()`/`.catch()` **and before the internal swallow-tail call in `localPromise`'s constructor** (GHSA-76w7-j9cq-rx2j); eliminates TOCTOU and species hijack via subclass `[Symbol.species]` | | Symbol.species (host Promise via then/catch/finally) (Category 53: GHSA-6454-5x88-m6jw) | `neutralizeHostPromiseSpeciesOn` in `lib/bridge.js` shadows the raw host promise's own `constructor` with an inert `undefined` data property across a sandbox→host `then`/`catch`/`finally` call, so `SpeciesConstructor` resolves to the host `%Promise%` and the missing-handler result capability cannot be hijacked; restored in `finally`, fails closed with `VMError` on a non-configurable `constructor` or non-extensible promise. `peelEffectivePromiseCall` unwinds `Function.prototype.call`/`.apply` and host `Reflect.apply` indirection and recognizes `.finally`, so every invoker of a host promise method with a sandbox-chosen receiver reaches the same gate. | | Symbol.species (Array) | Three-layer defense: set/defineProperty traps + neutralizeArraySpeciesBatch in apply trap | | Reflect.construct instanceof bypass | `resetPromiseSpecies` sets constructor on any object, not just `instanceof globalPromise` | | Species TOCTOU via accessor | Own data property set by `Reflect.defineProperty`; no getter invoked | | Species TOCTOU via prototype | `globalPromise.prototype` is frozen | | Symbol.hasInstance bypass | `globalPromise` is frozen | | Non-extensible promise | `Reflect.defineProperty` fails -> throws `LocalError` | | Error exploitation | Safe `defaultSandboxPrepareStackTrace`; V8 never falls back to host formatter | | Promise callbacks | All callbacks wrapped with `ensureThis()` sanitization | | Promise static methods | All wrapped to use `localPromise` as constructor, ignoring `this` | | Built-in override | Caches references at init time, uses `Reflect.apply` | | caller/callee | Throws immediately on access | | Monkey-patching | Uses cached `Reflect.*` methods, not prototype methods | | Transformer bypass | Validates against internal variable name patterns | | Dynamic import | Throws `VMError` unconditionally | | `vm.freeze` accessor setter leak | `ReadOnlyHandler.getOwnPropertyDescriptorDesc` strips `set` from every descriptor before wrapping (GHSA-633r-hq9m-c4ff); `getOwnPropertyDescriptor` / `__lookupSetter__` / `Reflect.getOwnPropertyDescriptor` / `Object.getOwnPropertyDescriptors` yield getter-only descriptors, getter preserved; `doPreventExtensions` routes copied descriptors through the same hook so the proxy target stays consistent with the trap | | Prototype trap pollution | Handlers use null-prototype objects | | Cross-realm symbols | Bridge proxy traps filter dangerous symbols; sandbox overrides reflection APIs. `isDangerousCrossRealmSymbol` (bridge.js) / `isDangerousSymbol` (setup-sandbox.js) flag any REGISTERED symbol whose `Symbol.keyFor` is in the reserved `nodejs.` namespace — a namespace catch-all (not a fixed list) so extraction and write-traps block current AND future `nodejs.*` internals (e.g. stream brand/state `nodejs.stream.{readable,…,disturbed,errored}`) without going stale; well-known symbols and benign registered symbols still cross (GHSA-m5q2-4fm3-vfqp, GHSA-jf8q-945g-9q4c) | | Host built-in identity leak | `thisAddIdentityMapping` pre-caches every well-known prototype + constructor in `mappingOtherToThis`/`mappingThisToOther`; cache check in `thisFromOtherWithFactory` short-circuits before wrapping. Function-family prototypes intentionally NOT cached so the dangerous-constructor sentinel still fires. | | Proxy handler exposure | Closure-scoped WeakMap and conversion methods; `isThisDangerousFunctionConstructor` on `get` trap returns | | Property descriptor extraction | `containsDangerousConstructor` + `preventUnwrap` blocks unwrapping | | SuppressedError | `handleException` detects and recursively sanitizes `.error`/`.suppressed` | | WebAssembly JSTag | `WebAssembly.JSTag` deleted from sandbox | | `node:test` host RCE via `run({execArgv})` (GHSA-qhwx-74w5-xhxq) | On Node 18+ `builtinModules` lists `node:test` with the `node:` prefix and `test` was not in `DANGEROUS_BUILTINS`, so `builtin: ['node:test']` admitted the real host module; `test.run({files, execArgv:['--eval=']})` 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 `` 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 `/` as denied when `-` 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 || byteLength !== store.byteLength`), the sandbox receives a host-side `ArrayBuffer.prototype.slice(byteOffset, byteOffset + byteLength)` copy of exactly the view's bytes, never Node's shared 64 KiB pool — so `.buffer`, the legacy `.parent` (DEP0004) and any future alias are covered at once. Applied in the `get` trap (gated by the cached host `ArrayBuffer.isView`) and defensively in `getOwnPropertyDescriptor`; a view whose extent cannot be read fails closed with `VMError`; `SharedArrayBuffer`-backed views are bounded via `SharedArrayBuffer.prototype.slice`; a bounded view reports `byteOffset` / legacy `offset` as 0 so the re-view idiom stays consistent; and the raw host backing-store getters (`%TypedArray%.prototype.buffer`, `DataView.prototype.buffer`, `Buffer.prototype.parent`) plus their offset counterparts are denied delivery so getter extraction cannot sidestep the trap | | `timeout` bypass via `FinalizationRegistry` cleanup callback (Category 42: GHSA-r4fx-v8hh-22mv) | `timeout` is implemented with V8's `TerminateExecution` and bounds only the synchronous body of `run()`; a `FinalizationRegistry` cleanup callback is fired by the GC *after* `run()` returns, so sandbox code inside it ran with no timeout accounting and could block the host event loop indefinitely — and `allowAsync: false`, which closes the equivalent `Promise`-continuation path, does not close this one. `lib/setup-sandbox.js` deletes `FinalizationRegistry` and `WeakRef` from the sandbox global (guarded by `typeof` so pre-Node-14 is unaffected), the same withholding used for timers/`queueMicrotask`; neither constructor has literal syntax, so the binding cannot be reconstructed from inside. `NodeVM` inherits it. Scope: this restores the documented `timeout` control in the default configuration — it is not a general DoS guarantee, and an embedder re-exposing either global via the `sandbox` option re-opens the vector by choice. | | `allowAsync: false` thenable assimilation (GHSA-f8gf-w286-fmq2) | Resolve capability refuses object/function values (TOCTOU-safe, no `.then` read); static methods throw; `Array.fromAsync` stubbed; construct-guard Proxy on `localPromise`'s prototype makes the native base un-constructable from the sandbox — all gated to `allowAsync: false` | | Host filesystem path leak via host-realm error stack (Category 48: GHSA-x6m4-chr9-cg97) | GHSA-v27g's `defaultSandboxPrepareStackTrace` / CallSite redaction only covers stacks formatted **in the sandbox realm**; a host-realm Error arrives with `.stack` already formatted by V8 host-side (absolute paths, `node:` / `internal/` frames, vm2's own `lib/*.js`, the embedding application's source) and crossed verbatim. Redacted at three chokepoints, each preserving the message header and clean sandbox frames: `BaseHandler.get` / `getOwnPropertyDescriptor` in `lib/bridge.js` (`redactHostStack`, gated by `isOtherErrorObject` — the `[[ErrorData]]` brand OR'd with a proto-walk to host `Error.prototype`, so neither GHSA-cfcw prototype severance nor a `Symbol.toStringTag` override smuggles a stack past it; the descriptor trap collapses the Node 22+ own-**accessor** shape of `Error#stack` into a redacted data descriptor so `desc.get.call(hostErr)` / `__lookupGetter__` cannot pull the raw string through the apply trap); `sanitizeHostOwnProps` in `lib/setup-sandbox.js` (`x6m4RedactHostFramesFromStack`), which covers the GHSA-m283 rebuild path where `.stack` is copied as a primitive via `v = e[k]` and never crosses a bridge trap; and `transformAndCheck` in `lib/vm.js`, which truncates the whole frame section of sandbox-destined compile errors (the config-free `eval("@@@ catch")` path) host-side, pre-bridge. The frame classifier extends GHSA-v27g's `isHostFrameFileName` with `file://` / `wasm://` schemes and `..`-traversal paths. Non-Error host objects the embedder deliberately exposes (including a plain object with a `stack`-named string) are untouched. | | Revisited host error carrier leaks a live proxy through the sanitizer cycle memo (Category 49: GHSA-x965-fc75-jpqh) | `handleException`'s cycle memo stored `visited.set(e, true)` and returned the raw carrier `e` on revisit — safe for seal-in-place carriers, but the `AggregateError`/`SuppressedError` handlers *rebuild* rather than seal, so a carrier revisited within one traversal (self-cycle `agg.errors=[agg]`, duplicate `[shared,shared]`, mutual `a↔b`) had its live host proxy re-embedded into the rebuilt `errors[]` → host RCE. The memo now maps each carrier to *exactly what a revisit must return*: itself when sealed in place, or its sandbox-realm replacement when rebuilt. Host-wrapped `AggregateError`/`SuppressedError` use a **two-phase build** — construct the empty replacement, register it in `visited` before recursing (so every cycle terminates on the replacement), then install the sanitized children via `localReflectDefineProperty`; attacker own-props are dropped by construction. The `sanitizeHostOwnProps` rebuild is memoized too (closes the duplicated-plain-error-with-prototype-leak residual), and a `_blockHostWrapped` backstop replaces any element still `_isHostWrapped` after recursion with a neutral sandbox `Error`. Extends the Category 38 (GHSA-m283-3h24-438v) fix. | | Ignored host-promise rejection aborts host process (GHSA-gjq8-xm47-88rc, GHSA-2v2p-6j97-cjg9) | `markHostPromiseHandled` attaches a benign `.then(noop, noop)` to the underlying host promise via the cached host `Promise.prototype.then`, so an unhandled host rejection cannot trip Node's `unhandledRejection` abort. The mark is applied at the host→sandbox DELIVERY chokepoint: `thisProxyOther`'s `!isHost` block, the single place a host object is given its sandbox proxy, gated on the `isOtherPromise` brand check (a depth-capped prototype walk to the cached host `Promise.prototype`, plus a chain-terminus test that catches promises from a second host realm — no `.then` invocation on non-promises, and no `Symbol.toStringTag` read that would fire host `get` traps). That covers `construct` returns (`new HostCtor()` / `Reflect.construct`), accessor values read through the `get` trap, and rejected promises passed as arguments to sandbox callbacks — every route the original per-trap fix left open. The `apply` and `construct` traps additionally keep an unconditional `markHostPromiseHandled(ret)`, which catches a host promise whose prototype chain the host detached. Multicast keeps the sandbox's own GHSA-55hx-sanitized `.then`/`.catch` observing the rejection; the no-op `onRejected` returns `undefined` so no new unhandled rejection is created; non-promise / fulfilled returns are inert (try/catch + `[[PromiseState]]` slot check). Sibling of GHSA-hw58 (Category 22), covering the host→sandbox direction. | | Host `util` members auto-forwarded to the sandbox (Category 52: GHSA-r273-hxvj-fxhp) | `defaultBuiltinLoaderUtil` in `lib/builtin.js` exposed `util` as `Object.assign({}, util)` — a wholesale copy that admits every future host `util` member unreviewed. On Node >= 22.9 that leaked `util.getCallSites()` (host call stack: absolute paths incl. vm2 `lib/`, embedder entrypoint, `node:internal` frames — bypassing GHSA-v27g, which only redacts sandbox-realm Error stacks), plus `getCallSite` / `setTraceSigInt` / private internals; `sys` (a util alias) leaked the same via the generic loader. The copy is now built from a vetted, forward-safe allowlist (`SAFE_UTIL_MEMBERS` via `sanitizeUtilModule`, presence-gated for Node 8→26), registered in `BUILTIN_MEMBER_SANITIZERS` for both `util` and `sys` so no unreviewed host member auto-enters. Restores Defense Invariant #5 for the programmatic stack-introspection channel. | | Host global leak via a sloppy host function's nullish `this` (Category 54: GHSA-j89j-5m6r-cr2q) | A sloppy-mode host function called from the sandbox with a nullish receiver gets V8's OrdinaryCallBindThis substitution of the **host global** for its `this`, and an unguarded bridge wrapped and delivered that global. The host realm's global is cached at bridge init (`thisRealmGlobal = global`, read by the other side as `otherGlobal`); `thisFromOtherWithFactory` / `thisEnsureThis` / `thisFromOtherForThrow` return `undefined` for `other === otherGlobal` (guarded by `!isHost`) **before** the `mappingOtherToThis` cache lookup, so no delivery path (apply-trap return, set-into-sandbox-object, callback argument, explicit `return globalThis`, throw) can surface it. `undefined` rather than `emptyFrozenObject` preserves strict-mode `this === undefined`; delivery-block is chosen over receiver-substitution because strict-vs-sloppy is not synchronously detectable. `global` (Node 8+), not `globalThis` (Node 12+). | --- ## Security Checklist for Bridge Changes When modifying `bridge.js`, `setup-sandbox.js`, or `transformer.js`, answer these questions: 1. **Does this change expose any new return path for host objects?** Every return value from proxy traps and bridge functions must be sanitized. 2. **Can sandbox code call this method directly (not through a proxy)?** Methods accessible on handler objects or prototypes can be called with attacker-controlled arguments. 3. **Does this method accept parameters that could be attacker-controlled?** Parameters like `target`, `receiver`, or callback arguments may be forged. 4. **Are all Reflect.* calls using cached references?** Sandbox-side `Reflect` overrides must not affect bridge internals. 5. **Could this path be triggered by V8 internal algorithms (bypassing proxy traps)?** V8 C++ code like ArraySpeciesCreate, FormatStackTrace, and PromiseResolveThenableJob operate on raw objects. 6. **Does this handle all error types that could be thrown (including host-realm errors)?** Any try/catch in bridge code might catch host errors that need sanitization. 7. **Are there any new well-known symbols that need filtering?** New symbols could provide cross-realm communication channels. --- ## Runtime-Dependent Attack Surface: Sandbox `Proxy` Availability Everything else in this document describes V8 as shipped by a current Node.js. This section records where the sandbox's own shape differs by runtime, because a reader reasoning about the proxy-handler surface needs to know the answer is not the same everywhere. ### What differs `lib/setup-sandbox.js` installs `Proxy` as `undefined` in the `Object.defineProperties(global, ...)` bootstrap block, then attempts to install a wrapped `proxiedProxy` over it. Whether that second write lands depends on how the engine treats a sealed slot on a `vm` context's global proxy: | Runtime | Sandbox `typeof Proxy` | Effect | |---|---|---| | Node >= 10 | `undefined` | Slot genuinely sealed, so the install is a silent no-op. Sandbox code cannot construct proxies at all. | | Node 8 | `function` | Slot stays writable, so the install lands and the sandbox gets `proxiedProxy`, whose handler arguments are sanitised by `wrapProxyHandler` / `makeSafeArgs`. | | Bun (JSC) | `undefined` | Slot sealed and the write *throws*; it is wrapped in `try`/`catch` so setup survives. Same end state as modern Node. | ### Why it matters for the threat model The proxy-handler attack classes in this document -- notably [Category 9](attacks/bridge-internals.md#attack-category-9-proxy-handler-exposure-via-utilinspect) and the trap-reentrancy patterns -- assume sandbox code can construct a `Proxy` and supply a hostile handler. On Node >= 10 and on Bun that primitive is absent from the sandbox: there is no `Proxy` to construct. On Node 8 it exists and is defended by wrapping rather than by removal. The modern configuration is therefore the *more* restrictive one. A defence described elsewhere as "the handler arguments are sanitised" is, on Node >= 10, backed by the stronger fact that the constructor is unreachable. Nothing here weakens a defence on any runtime; it records that one runtime relies on the wrapper while the others do not need it. ### Maintenance note Do not "clean up" either half of this mechanism without testing the full supported Node range. Both halves have engine-dependent behaviour a current Node cannot reveal: - Spelling out `writable: false, configurable: false` on the sealed slots is a no-op on modern V8 and a behaviour change on Node 8, where only the explicit form actually seals the slot. Doing so removed `Proxy` from Node 8 sandboxes once already. - `Reflect.defineProperty` cannot substitute for the plain assignment: on Node 8 it returns `true` and stores nothing. `test/vm.js` carries an AST-based guard that fails if any write to the sealed `Error` / `Promise` / `Proxy` slots is left outside a `try` block, since an unguarded write aborts sandbox setup on JavaScriptCore. --- ## Considered Attack Surfaces These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them. - **WeakRef / FinalizationRegistry**: For the *object-leak* surface these are safe — held values are specified at registration time, and `thisFromOther` always re-wraps values crossing the boundary, so a weak reference cannot leak a raw host object. However, `FinalizationRegistry` opened a separate *timeout-bypass / DoS* surface (its cleanup callback runs after `run()` returns, outside the timeout) — see [Category 42](attacks/host-resources.md#attack-category-42-finalizationregistry-cleanup-callback--timeout-protection-mechanism-failure). Both are now **removed from the default sandbox globals** (GHSA-r4fx-v8hh-22mv). - **structuredClone**: Not available in default `vm` context globals. Even if available, `structuredClone` strips prototype chains and creates plain objects, which cannot carry host constructors. - **SharedArrayBuffer / Atomics**: Likely unavailable in default VM contexts due to COOP/COEP requirements. Even if available, SharedArrayBuffer only shares raw bytes -- no object references can cross through it. Its *allocation-size* surface is a separate matter and is capped alongside `ArrayBuffer`, `TypedArray` and `WebAssembly.Memory` by [Category 36](attacks/host-resources.md#attack-category-36-bufferalloclimit-bypass-via-arraybuffer--typedarray--webassemblymemory). - **Error.cause on sandbox-created errors**: Set by sandbox code on sandbox-realm errors, so `ensureThis` handles it through normal property access on proxied errors. `Error.cause` on a *host* error carrying a live host reference is a different surface and is closed by [Category 38](attacks/error-sanitization.md#attack-category-38-errorcause-host-reference-leak-to-sandbox) and [Category 39](attacks/error-sanitization.md#attack-category-39-host-promise-rejection-sanitizer-bypass-via-callapply-indirection). - **Private fields (#field)**: Use the `[[PrivateName]]` internal slot, which is not accessible through Proxies. Cannot be used to leak host references across the bridge. - **Iterator helpers** (`.map`, `.filter`, `.take`, etc. on iterators): Operate on sandbox iterators and do not use `ArraySpeciesCreate`. Results are plain iterator objects without species resolution. - **TypedArray species**: TypedArray values are coerced to numbers during storage. Functions become `NaN`. Species self-return on TypedArrays cannot store object references — the Array form of the same primitive is [Category 18](attacks/host-reference-primitives.md#attack-category-18-array-species-self-return-via-constructor-manipulation). - **Symbol.isConcatSpreadable**: Species is handled by `neutralizeArraySpeciesBatch` (which sets `constructor = undefined` on every host array crossing a call, see [Category 18](attacks/host-reference-primitives.md#attack-category-18-array-species-self-return-via-constructor-manipulation)), and spreading through the bridge is safe because proxy traps sanitize element access. - **Proxy.revocable**: Revocation creates errors in the realm where the proxy was created. Since sandbox-created proxies create sandbox-realm errors, this does not introduce cross-realm error leakage. - **`nodejs.util.inspect.custom` installed on host-side proxy targets** (issue #566, 3.11.5; the handler-exposure surface `util.inspect` opened before is [Category 9](attacks/bridge-internals.md#attack-category-9-proxy-handler-exposure-via-utilinspect)): To restore correct `util.inspect` output on Node 26+ (which reads the symbol directly off the raw `[[ProxyTarget]]` slot, bypassing the `get` trap), `lib/bridge.js` installs a host-realm function under `Symbol.for('nodejs.util.inspect.custom')` on every host-side proxy target. The function walks `this` via cached `Reflect.{ownKeys,getOwnPropertyDescriptor}`, so every read flows through bridge traps. Reasoned safe because: (1) the property is reachable from sandbox code only via the proxy, and the existing `isDangerousCrossRealmSymbol` filter in `get` / `ownKeys` / `getOwnPropertyDescriptor` traps returns `undefined` for the host's marker symbol — confirmed across 10 extraction vectors including `getOwnPropertyDescriptors`, `__lookupGetter__`, `Object.assign`, spread, prototype-chain walks; (2) sandbox `Symbol.for('nodejs.util.inspect.custom')` has a distinct identity from the host's, so sandbox cannot forge a key that the bridge would treat as the marker (existing GHSA-47x8-96vw-5wg6 defense); (3) the property is installed as `configurable: true` so the trap returning `undefined` does not violate proxy invariants; (4) the install uses module-cached `thisReflectDefineProperty`, immune to runtime poisoning of `Reflect.defineProperty`; (5) the function's `catch` fallback returns a static literal — no host reference is reachable; (6) a `WeakMap` (`thisInspectInFlight`) bounds recursion on self-referential graphs and clears via `finally`, so independent inspect calls cannot leak `[Circular]` state across each other. - **Embedded bootstrap sources and the `/vm2/lib/` virtual filename** (3.12.x): `bridge.js`, `setup-sandbox.js`, `setup-node-sandbox.js` and `events.js` reach the sandbox realm as string literals from the generated `lib/sources.js` (built by `scripts/build-sources.js`) instead of `fs.readFileSync(\`${__dirname}/...\`)`, so bundlers can ship vm2. Two consequences were checked. (1) The embedded text is byte-identical to the file on disk — `test/sources.js` asserts this and CI fails on a stale commit — so no transpiler or minifier ever rewrites the boundary code (the `Function.prototype.toString` alternative was rejected for exactly that reason). (2) The scripts now compile under `/vm2/lib/` rather than the host install path. The GHSA-v27g `isHostFrameFileName` and GHSA-x6m4 `x6m4IsHostFrameFile` classifiers key on a leading `/`, so bootstrap frames remain **host** frames and stay redacted from sandbox-visible stacks (`test/sources.js` proves a slash-less name would expose `bridge.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](attacks/host-reference-primitives.md#attack-category-8-cross-realm-symbol-extraction-from-host-objects) mechanism, so `isDangerousCrossRealmSymbol` must learn each one. - **Error.isError()** (TC39 Stage 3): Type discrimination that could bypass proxy-based error wrapping. If `Error.isError()` operates on internal slots rather than prototype checks, it could distinguish host errors from sandbox errors. - **Temporal API**: Introduces new built-in objects with deep prototype chains. Any new global constructor is a potential source of host-realm references. - **Transformer ecmaVersion upgrades**: Any new JavaScript syntax with implicit catch semantics (like `using` in ES2024) must be evaluated for transformer coverage. The transformer's `ecmaVersion: 2022` limitation means all post-2022 syntax with error-handling behavior is a blind spot — see [Category 12](attacks/transformer-and-modules.md#attack-category-12-code-transformation-bypass) for the blind spot itself and [Category 22](attacks/host-resources.md#attack-category-22-promise-executor-unhandled-rejection--host-process-dos) for the `await using` form the pin currently holds shut. - **Any primitive that returns a sandbox-realm object with a host-realm prototype**: WebAssembly JSPI and the streaming compile APIs were the first (see [Category 33](attacks/promise-async.md#attack-category-33-webassembly-jspi-cross-realm-promise-prototype)). Every new Node API that hands the sandbox a Promise or iterator must be checked for this shape at bootstrap.