Introduction
A sandbox escape in the vm2 Node.js library lets untrusted code running inside the sandbox obtain real cross-realm symbols and write them onto host objects. With those symbols, the code can hijack host-side behavior such as util.promisify callbacks. vm2 has 1 million+ weekly npm downloads, so many applications rely on it to isolate untrusted JavaScript, and for each of them this vulnerability (CVSS 8.7) undermines the security boundary the sandbox exists to provide.
vm2 is an open-source sandbox library for Node.js that lets developers run untrusted JavaScript in an isolated context, exposing only built-in objects and Node's Buffer. It has 4.1k GitHub stars and 885 direct npm dependents, and it is widely used in code evaluation pipelines, including AI agent frameworks. Its maintenance is officially discontinued, yet it remains deeply embedded in the Node.js ecosystem.
Technical Information
Root Cause: A Dual Protection Gap
Security researcher @q1uf3ng discovered and reported CVE-2026-47135, which comes from two compounding gaps in vm2's sandbox isolation. The vulnerability is classified as CWE-693 (Protection Mechanism Failure), the category for a product that incorrectly uses a protection mechanism meant to provide sufficient defense against directed attacks.
Gap 1: Incomplete Symbol.for Interception in setup-sandbox.js
Node.js uses cross-realm symbols (prefixed with nodejs.*) as internal hooks that control host-side behavior. The vm2 sandbox overrides Symbol.for to intercept access to these dangerous symbols, but it blocks only 2 of the 9 known dangerous symbols: nodejs.util.inspect.custom and nodejs.rejection. The other 7 pass through the override unmodified and return real cross-realm symbol references. The vulnerable code path looked like this:
// BEFORE (vulnerable): if (keyStr === 'nodejs.util.inspect.custom') return blockedSymbolCustomInspect; if (keyStr === 'nodejs.rejection') return blockedSymbolRejection; return originalSymbolFor(keyStr); // everything else passes through!
When sandbox code calls Symbol.for('nodejs.util.promisify.custom'), it receives the actual host symbol rather than a sandbox-local substitute. The unblocked symbols were:
nodejs.util.promisify.customnodejs.stream.readablenodejs.stream.writablenodejs.stream.duplexnodejs.stream.transformnodejs.webstream.isClosedPromisenodejs.webstream.controllerErrorFunction
Gap 2: Missing Symbol Checks in Bridge Write Traps
The bridge object, defined in bridge.js, mediates all property access between the sandbox and host realms. Its get and ownKeys traps already run an isDangerousCrossRealmSymbol check and throw a VMError when they encounter a dangerous symbol. The write-access traps (set, defineProperty, and deleteProperty) have no such check at all. Sandbox code that has obtained a real cross-realm symbol through Gap 1 can therefore write it to host objects, define new properties with it, or delete existing symbol-keyed properties, and nothing intercepts the operation.
Attack Flow
Together, the two gaps form a complete attack chain. The advisory documents three verified exploitation scenarios:
| Attack Scenario | Symbol Used | Mechanism | Host Impact |
|---|---|---|---|
util.promisify hijack | nodejs.util.promisify.custom | Assign malicious callback to host function via symbol key | Host executes sandbox function instead of intended logic |
| Stream identity manipulation | nodejs.stream.writable | Write symbol to host ReadableStream | Alters duck-typing identity checks on host objects |
| General property tampering | Any unblocked nodejs.* symbol | Object.assign, Object.defineProperty, or delete on host objects with symbol keys | Controls host-side behavior dependent on symbol-keyed properties |
The primary exploitation path runs in three steps:
- Sandbox code calls
Symbol.for('nodejs.util.promisify.custom'), which passes through the incomplete override and returns the real cross-realm symbol. - Using the real symbol as a property key, the sandbox writes a malicious callback function onto a host function object. The bridge's
settrap does not check whether the key is a dangerous symbol, so the write succeeds. - When the host later calls
util.promisifyon the tampered function, the Node.js runtime reads the attacker-controllednodejs.util.promisify.customsymbol property and runs the sandbox's function instead of the intended promisification logic. In the verified demonstration, that function prints "HIJACKED by sandbox".
CVSS Vector Analysis
The CVSS vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N breaks down as follows:
- Attack Vector: Network and Privileges Required: None mean the flaw is exploitable remotely without authentication, provided the application executes untrusted JavaScript inside vm2.
- Attack Complexity: High means exploitation needs specific conditions: the application must run untrusted code in vm2, and the host must invoke a function that reads the tampered symbol property.
- Scope: Changed means the impact extends beyond the vulnerable component (the sandbox) to the host system.
- Confidentiality and Integrity: High with Availability: None means the attacker can achieve total information disclosure and data modification, but no direct denial of service.
The advisory states that "this is not a direct RCE," which separates CVE-2026-47135 from other vm2 CVEs such as CVE-2026-22709 (CVSS 9.8) and CVE-2026-43999 (CVSS 10.0) that achieve direct arbitrary code execution. The high confidentiality and integrity impacts still enable significant semantic confusion and data integrity violations on the host. Chained with other primitives from the broader 2026 CVE wave, the symbol escape could also serve as a stepping stone to full RCE.
AI Agent Context
Microsoft Security Research calls this pattern "Prompts Become Shells," describing how vm2 sandbox escapes in AI agent infrastructure convert prompt injection attacks into host-level infrastructure compromises. In AI agent frameworks where vm2 executes LLM-generated code, a common prompt injection might otherwise only produce misleading output. With a sandbox escape, the same injection becomes a host-level infrastructure compromise. Kodem Security predicts that at least one production AI agent compromise chaining a prompt injection to a JavaScript sandbox escape will be publicly disclosed by the end of Q4 2026.
Patch Information
The vulnerability was patched in vm2 version 3.11.4, released on May 18, 2026. Patrik Šimek wrote the fix in commit 928aef5 on May 17, 2026, and it is tracked under GitHub Security Advisory GHSA-m5q2-4fm3-vfqp. The commit modifies 5 files, with 471 additions and 25 deletions, and implements what the maintainer calls a "two-layer structural fix."
Layer 1: Sandbox-side Symbol.for Namespace Denial (lib/setup-sandbox.js)
The fix replaces the narrow two-key allowlist with a blanket prefix match, so any key starting with nodejs. is now denied at the source.
// AFTER (fixed): if (apply(localStringStartsWith, keyStr, ['nodejs.'])) { const cached = blockedNodejsSymbolCache[keyStr]; if (typeof cached === 'symbol') return cached; const fresh = Symbol(keyStr); localReflectDefineProperty(blockedNodejsSymbolCache, keyStr, { __proto__: null, value: fresh, writable: true, enumerable: false, configurable: false, }); return fresh; } return originalSymbolFor(keyStr);
The blockedNodejsSymbolCache object has a null prototype and keeps Symbol.for(k) === Symbol.for(k) true within the sandbox. The sandbox's own code keeps JavaScript's global Symbol registry semantics, and it never receives a real cross-realm symbol. The fix also captures localStringStartsWith from String.prototype.startsWith at module load time, before any sandbox code executes, so an attacker cannot monkey-patch it later.
isDangerousSymbol() used to check 2 symbols. It now iterates a centralized realDangerousSymbols array of all 9 known dangerous Node.js symbols:
nodejs.util.inspect.customnodejs.rejectionnodejs.util.promisify.customnodejs.stream.readablenodejs.stream.writablenodejs.stream.duplexnodejs.stream.transformnodejs.webstream.isClosedPromisenodejs.webstream.controllerErrorFunction
Layer 2: Bridge Write-Trap Guards (lib/bridge.js)
Independently of Layer 1, the maintainer added a defense-in-depth backstop on the bridge itself. The three write-direction traps (set, defineProperty, and deleteProperty) now include a guard:
// Added to set, defineProperty, and deleteProperty traps: if (!isHost && typeof key === 'symbol' && isDangerousCrossRealmSymbol(key)) { throw new VMError(OPNA); }
The !isHost condition limits the block to sandbox-originated writes, so host code can still manage its own symbol properties. The isDangerousCrossRealmSymbol function in bridge.js now covers all 9 symbols instead of 3, which brings it in sync with setup-sandbox.js.
The host-result scrub, the code that strips dangerous symbols from values returned by host functions, used to make two hard-coded otherReflectDeleteProperty calls. It now loops over the full dangerousSyms array, so any future addition to the dangerous-symbol list reaches the scrub logic automatically.
The commit also adds a 318-line test suite (test/ghsa/GHSA-m5q2-4fm3-vfqp/repro.js) that covers:
- the primary
util.promisifyhijack path - source-side
Symbol.fordenial for each known dangerous symbol, and for unknownnodejs.*keys to keep the fix forward-compatible - regression guards ensuring non-
nodejs.keys remain cross-realm - all three write-trap denials
To apply this fix, upgrade the vm2 npm package to version 3.11.4 or later. All versions 3.11.3 and below are affected.
Migration Recommendation
vm2 has a pattern of repeated sandbox escapes (8 security advisories in 2023, 13+ CVEs in 2026), and patching individual vulnerabilities is not enough. Endor Labs, Semgrep, and Kodem Security all recommend migrating to process-level or container-level isolation as the only sustainable path. The vm2 npm page itself recommends migrating to isolated-vm. For defense-in-depth, consider gVisor or Firecracker microVM isolation for sandboxed code execution environments.
Affected Systems and Versions
All versions of the vm2 npm package up to and including version 3.11.3 are affected. Version 3.11.4 and later contain the patch. The most recent release at the time of disclosure was version 3.11.5, published May 18, 2026.
The fork @usebruno/vm2 also carries the deprecation notice and should be considered potentially affected.
An application is vulnerable to this exploitation chain if both of these are true:
- It uses vm2 to execute untrusted JavaScript code.
- Its host-side code reads symbol-keyed properties from objects the sandbox can access, for example by calling
util.promisifyon functions the sandbox can modify.
Vendor Security History
vm2 has an extensive, well-documented history of sandbox escape vulnerabilities, and they point to fundamental weaknesses in its architecture rather than to isolated bugs:
| Year | Vulnerability Pattern | Notable CVEs |
|---|---|---|
| 2022 to 2023 | Proxy unwrap, prototype pollution, async sanitization | CVE-2023-37903 and others |
| 2023 | 8 advisories in one year (7 in a 4-month window) | Multiple GHSA entries |
| January 2026 | Promise callback sanitization bypass | CVE-2026-22709 (CVSS 9.8) |
| May 2026 | inspect() proxy unwrap | CVE-2026-24781 (CVSS 9.8) |
| May 2026 | SuppressedError exception abuse | CVE-2026-26332 |
| May 2026 | Allowlist bypass to child_process RCE | CVE-2026-43999 (CVSS 10.0) |
| June 2026 | Cross-realm Symbol escape | CVE-2026-47135 (CVSS 8.7) |
| June 2026 | CVE-2023-37903 patch bypass | CVE-2026-47137 (CVSS 10.0) |
Each patch addresses one specific escape primitive, while the underlying isolation model stays vulnerable to new bypass techniques. The 2026 wave of 13+ CVEs continues a multi-year pattern of structural weakness, and it is no one-time anomaly. The attack chain is the same across these CVEs and runs in four stages:
- Initial execution inside the sandbox.
- Boundary abuse through the specific primitive.
- Access to host capabilities.
- Final payload execution on the host.
References
- CVE-2026-47135 NVD Entry
- GHSA-m5q2-4fm3-vfqp: Sandbox escape via unblocked cross-realm Symbol.for keys
- Patch Commit 928aef5
- vm2 v3.11.4 Release
- GitHub Advisory Database: GHSA-m5q2-4fm3-vfqp
- Snyk Vulnerability: SNYK-JS-VM2-17111274
- OSV: GHSA-m5q2-4fm3-vfqp
- Miggo Security: CVE-2026-47135
- Kodem Security: vm2 Sandbox Escape, AI Agent RCE Risk and IOCs
- Endor Labs: Critical Sandbox Escape in vm2 Enables RCE
- Semgrep: New Sandbox Escape Affecting Popular Node.js Sandbox Library vm2
- vm2 GitHub Repository
- vm2 npm Package
- CWE-693: Protection Mechanism Failure
- CISA Known Exploited Vulnerabilities Catalog
- Greenbone May 2026 Threat Report



