{"id":"GHSA-2v2p-6j97-cjg9","summary":"vm2: Host Promise rejection from an exposed constructor can terminate the vm2 host process","details":"## Summary\n\nAn untrusted script run by `VM.run` can construct an embedder-exposed host function that returns a rejected native Promise and ignore the result. The sandbox-to-host `construct` trap forwards that Promise without applying the host-side rejection handling already used by the neighboring `apply` trap, so Node's strict unhandled-rejection policy terminates the host process.\n\n## Technical Details\n\nThe precondition is an application-supplied constructable host function in the VM sandbox whose constructor body returns a native rejected Promise. In JavaScript, an object explicitly returned by a constructor replaces the newly allocated instance, so `new HostReject()` produces that Promise.\n\n`VM.run` executes the attacker-controlled source. For an ordinary host-function call, `BaseHandler.apply` invokes the host function, calls `markHostPromiseHandled(ret)`, and then wraps the result. The adjacent `BaseHandler.construct` path instead calls `Reflect.construct` and returns `thisFromOtherWithFactory(...)` without calling the same sanitizer. The rejected host Promise therefore crosses the bridge still unhandled. With Node's strict unhandled-rejection behavior, the rejection is promoted to an uncaught exception and kills the host process.\n\nThe shortest path is `VM.run` → the sandbox bridge → `BaseHandler.construct`. The control changes only the guest expression: it attaches `.catch(function () {})` to the constructed Promise before ignoring it. The host function, VM configuration, rejection, and Node policy remain the same, and the control process survives.\n\nThis is unintended because the repository's GHSA-gjq8 hardening explicitly marks host Promises handled at the apply boundary, while no equivalent handling exists in the adjacent construct return path. The construct path is a distinct boundary and fix surface, not a second invocation of the already-fixed apply path.\n\n## PoV\n\nSave the following as `construct-promise-poc.js`:\n\n```js\n'use strict';\n\nconst { VM } = require(process.cwd() + '/lib/main.js');\n\nfunction HostReject() {\n  return Promise.reject(new Error('constructed-host-boom'));\n}\n\nconst mode = process.argv[2];\nconst vm = new VM({ sandbox: { HostReject } });\n\nif (mode === 'vulnerable') {\n  console.log('VULNERABLE_STARTED');\n  vm.run('new HostReject(); 1');\n  setTimeout(() =\u003e console.log('ALIVE'), 300);\n} else if (mode === 'control') {\n  vm.run('new HostReject().catch(function () {}); 1');\n  setTimeout(() =\u003e console.log('CONTROL_ALIVE'), 300);\n} else {\n  throw new Error('usage: node construct-promise-poc.js vulnerable|control');\n}\n```\n\n## PoC\n\nCheck out vm2 revision `91034466bfb7f56b95fd48083ec6ca36d058f164`, install its declared dependencies with `npm ci --ignore-scripts`, and save `construct-promise-poc.js` in that checkout's module root (the directory containing `package.json` and `lib/`). Run the script and both commands below from that same module-root directory. The script resolves `lib/main.js` from the current directory, so the test uses the checked-out vm2 source.\n\nTested with vm2 `3.11.8` at that revision on Node.js `v26.8.1`, with `NODE_OPTIONS=--unhandled-rejections=strict`. The abort flag makes the process crash signal explicit:\n\n```text\nNODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js vulnerable\n```\n\nAbridged vulnerable output:\n\n```text\nVULNERABLE_STARTED\nError: constructed-host-boom\n    at VM2 Wrapper.construct (.../lib/bridge.js:2340:11)\n    at VM.run (.../lib/vm.js:613:16)\n```\n\nThe process exits before printing `ALIVE` (exit status 139 in the tested execution). The stack paths are environment-dependent; the `construct` and `VM.run` frames identify the relevant target functions.\n\nThe otherwise identical control is:\n\n```text\nNODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js control\n```\n\nControl output:\n\n```text\nCONTROL_ALIVE\n```\n\nThe control exits successfully. The crash is a process-level availability failure, not a guest exception caught and returned by `VM.run`.\n\n## Impact\n\nAn attacker who can submit JavaScript to a VM and who receives a constructable Promise-returning host API can terminate the Node.js process hosting the sandbox with one expression. This can take down a plugin worker, notebook kernel, queue consumer, or multi-tenant execution worker serving other users. The exploit does not require filesystem access, a Node builtin, nested VMs, or host compromise. It requires the embedder to expose the host constructor and the host to use strict unhandled-rejection handling.\n\nThe impact is host availability only; this report does not claim confidentiality or integrity impact. The typed classification is CWE-248 (Uncaught Exception) and CWE-703 (Improper Check or Handling of Exceptional Conditions), with CVSS 3.1 `AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H` (8.6, High) for a deployment that accepts untrusted code over a network boundary.\n\n## Suggested Fix\n\nRestore the bridge invariant that every host-native Promise crossing into the sandbox is marked handled before it can be ignored. In `BaseHandler.construct`, add the same `markHostPromiseHandled(ret)` call used by `BaseHandler.apply`, after the host result is produced and before it is converted and returned:\n\n```js\nstripDangerousSymbolsFromHostResult(ret);\nmarkHostPromiseHandled(ret);\nreturn thisFromOtherWithFactory(getHandlerFactory(this), ret, thisFromOther(object));\n```\n\nThe call should attach the benign host-side rejection reaction without changing the Promise value or preventing sandbox code that later attaches `.catch()` or `.then(..., onRejected)` from observing the rejection. Regression tests should cover an ignored rejected Promise returned through `new`, the caught control, ordinary non-Promise constructor returns, and the existing apply-path behavior.\n\n## Affected Package/Versions\n\n- Package: `vm2` (npm).\n- Confirmed vulnerable: `3.11.8`, including revision `91034466bfb7f56b95fd48083ec6ca36d058f164`.\n- The exact tested affected range is `3.11.8`; no broader version range is inferred from this test.\n\n## Advisory History\n\nThe public [GHSA-gjq8-xm47-88rc advisory](https://github.com/patriksimek/vm2/security/advisories/GHSA-gjq8-xm47-88rc) describes host-returned Promise rejection termination and records `3.11.8` as its patched version. Its fix and reproduction cover a host function invoked through the bridge `apply` route. This report uses the distinct `construct` route reached by `new`, where the tested `3.11.8` source still omits `markHostPromiseHandled(ret)`. A fix for `apply` does not automatically fix this adjacent return path.\n\nThe checked related public history includes [GHSA-hw58-p9xv-2mjh](https://github.com/patriksimek/vm2/security/advisories/GHSA-hw58-p9xv-2mjh), the earlier sandbox-Promise constructor issue. GHSA-hw58 concerns an error raised by a Promise executor for a Promise created inside the sandbox; it is the sandbox-native `localPromise`/executor path, not GHSA-gjq8's host-returned Promise through `BaseHandler.apply` and not this host-constructor result through `BaseHandler.construct`.\n\nThe checked search also found [PR #421, “Handle errors thrown in async functions”](https://github.com/patriksimek/vm2/pull/421). That is a separate async-error history item concerning errors from async functions and timer callbacks, with general unhandled-rejection handling, rather than a host Promise returned through the GHSA-gjq8 `apply` boundary or a Promise returned by an exposed constructor through this `construct` trap.\n\nThe bound prior local report, titled “NodeVM crypto sanitizer exposes process-wide crypto.setFips,” covers a different root cause: an allowlisted `crypto` builtin exposes `setFips`, allowing guest code to mutate host-wide cryptographic state. Its boundary and fix surface are builtin sanitization and process-state mutation, not host-Promise rejection handling; it therefore differs from both the GHSA-gjq8 `apply` route and this `BaseHandler.construct` route. No prior report covering this construct-trap root cause was found.","aliases":["CVE-2026-100722"],"modified":"2026-10-05T23:30:04.985027283Z","published":"2026-10-05T23:19:55Z","database_specific":{"github_reviewed_at":"2026-10-05T23:19:55Z","nvd_published_at":null,"cwe_ids":["CWE-248","CWE-703"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-2v2p-6j97-cjg9"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-100722"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/commit/6e487df9518e455e5e60faec3df3a0e2e27f9940"},{"type":"PACKAGE","url":"https://github.com/patriksimek/vm2"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/releases/tag/v3.12.2"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/vm2-before-3.12.2-host-process-termination-via-construct-trap"}],"affected":[{"package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.12.2"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.12.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2v2p-6j97-cjg9/GHSA-2v2p-6j97-cjg9.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H"},{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:H"}]}