ZeroPath at Black Hat USA 2026

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. Includes 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 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.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. 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 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 works as follows:

  1. Symbol acquisition: Sandbox code calls Symbol.for('nodejs.util.promisify.custom'), which passes through the incomplete override and returns the real cross-realm symbol.
  2. 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 set trap does not check whether the key is a dangerous symbol, so the write succeeds.
  3. Host-side hijack: When the host subsequently invokes util.promisify on the tampered function, the Node.js runtime reads the attacker-controlled nodejs.util.promisify.custom symbol 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.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)

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:

  1. Uses vm2 to execute untrusted JavaScript code, AND
  2. Has host-side code that reads symbol-keyed properties from objects accessible to the sandbox (such as calling util.promisify on 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:

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)

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

Detect & fix
what others miss

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