CVE-2026-100722: vm2: Host Promise rejection from an exposed constructor can terminate the vm2 host process
## Summary An 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. ## Technical Details The 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. `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. The 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. This 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. ## PoV Save the following as `construct-promise-poc.js`: ```js 'use strict'; const { VM } = require(process.cwd() + '/lib/main.js'); function HostReject() { return Promise.reject(new Error('constructed-host-boom')); } const mode = process.argv[2]; const vm = new VM({ sandbox: { HostReject } }); if (mode === 'vulnerable') { console.log('VULNERABLE_STARTED'); vm.run('new HostReject(); 1'); setTimeout(() => console.log('ALIVE'), 300); } else if (mode === 'control') { vm.run('new HostReject().catch(function () {}); 1'); setTimeout(() => console.log('CONTROL_ALIVE'), 300); } else { throw new Error('usage: node construct-promise-poc.js vulnerable|control'); } ``` ## PoC Check 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. Tested 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: ```text NODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js vulnerable ``` Abridged vulnerable output: ```text VULNERABLE_STARTED Error: constructed-host-boom at VM2 Wrapper.construct (.../lib/bridge.js:2340:11) at VM.run (.../lib/vm.js:613:16) ``` The 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. The otherwise identical control is: ```text NODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js control ``` Control output: ```text CONTROL_ALIVE ``` The control exits successfully. The crash is a process-level availability failure, not a guest exception caught and returned by `VM.run`. ## Impact An 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. The 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. ## Suggested Fix Restore 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: ```js stripDangerousSymbolsFromHostResult(ret); markHostPromiseHandled(ret); return thisFromOtherWithFactory(getHandlerFactory(this), ret, thisFromOther(object)); ``` The 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. ## Affected Package/Versions - Package: `vm2` (npm). - Confirmed vulnerable: `3.11.8`, including revision `91034466bfb7f56b95fd48083ec6ca36d058f164`. - The exact tested affected range is `3.11.8`; no broader version range is inferred from this test. ## Advisory History The 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. The 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`. The 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. The 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.
Recommended action
Recommended action
Upgrade affected packages to a patched version: vm2 3.12.2.
Technical details
- Vendor
- Not specified
- Product
- vm2
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source