Automated Threat Modeling

vm2 CVE-2026-47135: Brief Summary of a Cross-Realm Symbol Sandbox Escape in Node.js

A brief summary of CVE-2026-47135, a high-severity sandbox escape in the vm2 Node.js library caused by incomplete cross-realm symbol blocking and missing bridge trap checks, with patch analysis and mitigation guidance.

CVE Analysis

12 min read

ZeroPath CVE Analysis
ZeroPath CVE Analysis

2026-06-12

vm2 CVE-2026-47135: Brief Summary of a Cross-Realm Symbol Sandbox Escape in Node.js
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

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.custom
  • nodejs.stream.readable
  • nodejs.stream.writable
  • nodejs.stream.duplex
  • nodejs.stream.transform
  • nodejs.webstream.isClosedPromise
  • nodejs.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 ScenarioSymbol UsedMechanismHost Impact
util.promisify hijacknodejs.util.promisify.customAssign malicious callback to host function via symbol keyHost executes sandbox function instead of intended logic
Stream identity manipulationnodejs.stream.writableWrite symbol to host ReadableStreamAlters duck-typing identity checks on host objects
General property tamperingAny unblocked nodejs.* symbolObject.assign, Object.defineProperty, or delete on host objects with symbol keysControls host-side behavior dependent on symbol-keyed properties

The primary exploitation path runs in three steps:

  1. Sandbox code calls Symbol.for('nodejs.util.promisify.custom'), which passes through the incomplete override and returns the real cross-realm symbol.
  2. Using the real symbol as a property key, the sandbox writes a malicious callback function onto a host function object. The bridge's set trap does not check whether the key is a dangerous symbol, so the write succeeds.
  3. When the host later calls util.promisify on the tampered function, the Node.js runtime reads the attacker-controlled nodejs.util.promisify.custom symbol 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.custom
  • nodejs.rejection
  • nodejs.util.promisify.custom
  • nodejs.stream.readable
  • nodejs.stream.writable
  • nodejs.stream.duplex
  • nodejs.stream.transform
  • nodejs.webstream.isClosedPromise
  • nodejs.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.promisify hijack path
  • source-side Symbol.for denial for each known dangerous symbol, and for unknown nodejs.* 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:

  1. It uses vm2 to execute untrusted JavaScript code.
  2. Its host-side code reads symbol-keyed properties from objects the sandbox can access, for example by calling util.promisify on 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:

YearVulnerability PatternNotable CVEs
2022 to 2023Proxy unwrap, prototype pollution, async sanitizationCVE-2023-37903 and others
20238 advisories in one year (7 in a 4-month window)Multiple GHSA entries
January 2026Promise callback sanitization bypassCVE-2026-22709 (CVSS 9.8)
May 2026inspect() proxy unwrapCVE-2026-24781 (CVSS 9.8)
May 2026SuppressedError exception abuseCVE-2026-26332
May 2026Allowlist bypass to child_process RCECVE-2026-43999 (CVSS 10.0)
June 2026Cross-realm Symbol escapeCVE-2026-47135 (CVSS 8.7)
June 2026CVE-2023-37903 patch bypassCVE-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:

  1. Initial execution inside the sandbox.
  2. Boundary abuse through the specific primitive.
  3. Access to host capabilities.
  4. Final payload execution on the host.

References

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization