Introduction
A sandbox escape in the popular vm2 Node.js library allows untrusted code running inside the sandbox to obtain real cross-realm symbols and write them onto host objects, hijacking host-side behavior such as util.promisify callbacks. For any application relying on vm2 to isolate untrusted JavaScript, and there are many given the library's 1 million+ weekly npm downloads, this vulnerability (CVSS 8.7) undermines the fundamental security boundary the sandbox is supposed 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. With 4.1k GitHub stars, 885 direct npm dependents, and widespread use in code evaluation pipelines (including AI agent frameworks), vm2 occupies a critical position in the Node.js ecosystem despite its officially discontinued maintenance status.
Technical Information
Root Cause: A Dual Protection Gap
CVE-2026-47135 stems from two compounding deficiencies in vm2's sandbox isolation, discovered and reported by security researcher @q1uf3ng. The vulnerability is classified under CWE-693 (Protection Mechanism Failure), indicating the product incorrectly uses a protection mechanism that should 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 the implementation only blocks 2 of 9 known dangerous symbols: nodejs.util.inspect.custom and nodejs.rejection. The remaining 7 symbols 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 full list of unblocked symbols included:
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. The get and ownKeys traps already include an isDangerousCrossRealmSymbol check that throws a VMError when dangerous symbols are encountered. However, the corresponding write-access traps (set, defineProperty, and deleteProperty) entirely lack this check. This means sandbox code that has obtained a real cross-realm symbol via Gap 1 can write it to host objects, define new properties with it, or delete existing symbol-keyed properties without any interception.
Attack Flow
The combination of these two gaps creates 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 works as follows:
- Symbol acquisition: Sandbox code calls
Symbol.for('nodejs.util.promisify.custom'), which passes through the incomplete override and returns the real cross-realm symbol. - Host object mutation: 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. - Host-side hijack: When the host subsequently invokes
util.promisifyon the tampered function, the Node.js runtime reads the attacker-controllednodejs.util.promisify.customsymbol property. Instead of the intended promisification logic, the host executes the sandbox's function, printing "HIJACKED by sandbox" in the verified demonstration.
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 reflects several important characteristics:
- Attack Vector: Network and Privileges Required: None indicate this is exploitable remotely without authentication, provided the application executes untrusted JavaScript inside vm2.
- Attack Complexity: High reflects that exploitation requires 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 is critical here, as 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 explicitly states "this is not a direct RCE," distinguishing CVE-2026-47135 from other vm2 CVEs like CVE-2026-22709 (CVSS 9.8) and CVE-2026-43999 (CVSS 10.0) that achieve direct arbitrary code execution. However, the high confidentiality and integrity impacts enable significant semantic confusion and data integrity violations on the host, and when chained with other primitives from the broader 2026 CVE wave, the symbol escape could serve as a stepping stone to full RCE.
AI Agent Context
Microsoft Security Research has named the paradigm "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 sandbox escape transforms a common prompt injection (which might otherwise only produce misleading output) into 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. The fix was authored by Patrik Šimek in commit 928aef5 (May 17, 2026) and is tracked under GitHub Security Advisory GHSA-m5q2-4fm3-vfqp. The commit modifies 5 files with 471 additions and 25 deletions, implementing what the maintainer describes as 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: 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 (a null-prototype object) ensures that Symbol.for(k) === Symbol.for(k) identity holds within the sandbox, preserving JavaScript's global Symbol registry semantics for the sandbox's own code while never returning a real cross-realm symbol. An important defensive detail: localStringStartsWith is captured from String.prototype.startsWith at module load time before any sandbox code executes, preventing an attacker from monkey-patching it later.
The isDangerousSymbol() function was expanded from checking 2 symbols to iterating 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)
Even if Layer 1 holds, 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 ensures this only blocks sandbox-originated writes (host code can still manage its own symbol properties). The isDangerousCrossRealmSymbol function in bridge.js was correspondingly expanded from 3 symbols to all 9, bringing it in sync with setup-sandbox.js.
Additionally, the host-result scrub (the code that strips dangerous symbols from values returned by host functions) was refactored from two hard-coded otherReflectDeleteProperty calls into a loop over the full dangerousSyms array, ensuring any future additions to the dangerous-symbol list automatically propagate to the scrub logic.
The commit also includes a comprehensive test suite (test/ghsa/GHSA-m5q2-4fm3-vfqp/repro.js, 318 lines) covering the primary util.promisify hijack path, Symbol.for source-side denial for each known dangerous symbol and forward-compatible unknown nodejs.* keys, regression guards ensuring non-nodejs. keys remain cross-realm, and validation of 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
Given the pattern of repeated sandbox escapes (8 security advisories in 2023, 13+ CVEs in 2026), patching individual vulnerabilities is insufficient. The consistent recommendation across Endor Labs, Semgrep, and Kodem Security is that migration to process-level or container-level isolation is the only sustainable path. The vm2 npm page itself recommends migrating to isolated-vm, and for defense-in-depth, gVisor or Firecracker microVM isolation should be considered 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. The vulnerability is patched in version 3.11.4 and later (the most recent release as of disclosure is version 3.11.5, published May 18, 2026).
The fork @usebruno/vm2 also carries the deprecation notice and should be considered potentially affected.
Any application that:
- Uses vm2 to execute untrusted JavaScript code, AND
- Has host-side code that reads symbol-keyed properties from objects accessible to the sandbox (such as calling
util.promisifyon functions the sandbox can modify)
is vulnerable to this exploitation chain.
Vendor Security History
vm2 has a well-documented and extensive history of sandbox escape vulnerabilities that reveal fundamental architectural weaknesses rather than 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) |
The pattern is clear: each patch addresses a specific escape primitive while the underlying isolation model remains vulnerable to new bypass techniques. The 2026 wave of 13+ CVEs is a continuation of a multi-year pattern of structural weakness, not a one-time anomaly. The universal attack chain across these CVEs operates in four stages: initial execution inside the sandbox, boundary abuse via the specific primitive, host capability access, and 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



