{"schema":"threatline.record/2","record":{"id":"CVE-2026-100722","aliases":["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(() => console.log('ALIVE'), 300);\n} else if (mode === 'control') {\n  vm.run('new HostReject().catch(function () {}); 1');\n  setTimeout(() => 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.","categories":["Libraries & packages"],"product":"vm2","vendor":"patriksimek","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-2v2p-6j97-cjg9","source_modified_at":"2026-10-05T23:19:56Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"range":"<= 3.12.1"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-100722","source_modified_at":"2026-09-28T13:51:49.241Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vm2","vendor":"patriksimek","versions":[{"version":"0","status":"affected","versionType":"semver","lessThan":"3.12.2"},{"version":"3.12.2","status":"unaffected","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-2v2p-6j97-cjg9","source_modified_at":"2026-10-05T23:30:04.641523138Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2","purl":"pkg:npm/vm2"},"ranges":[{"events":[{"introduced":"0"},{"fixed":"3.12.2"}],"type":"SEMVER"}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-2v2p-6j97-cjg9","source_modified_at":"2026-10-05T23:19:56Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"version":"3.12.2"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-100722","source_modified_at":"2026-09-28T13:51:49.241Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":null},"version":"3.12.2","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-2v2p-6j97-cjg9","source_modified_at":"2026-10-05T23:30:04.641523138Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2","purl":"pkg:npm/vm2"},"version":"3.12.2"}],"cvss":8.9,"severity":"High","kev":{"listed":false},"epss":{"probability":0.0026,"percentile":0.16776,"score_date":"2026-10-23"},"published_at":"2026-09-27T01:28:29.295Z","modified_at":"2026-10-05T23:30:04.641523138Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-2v2p-6j97-cjg9","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T23:19:56Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-100722","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-28T13:51:49.241Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-2v2p-6j97-cjg9","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T23:30:04.641523138Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-100722","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-2v2p-6j97-cjg9","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-100722","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/commit/6e487df9518e455e5e60faec3df3a0e2e27f9940","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/releases/tag/v3.12.2","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vm2-before-3.12.2-host-process-termination-via-construct-trap","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-2v2p-6j97-cjg9","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-09-28T13:51:25.270432Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-05T23:30:04.641523138Z","content_hash":"3ae44971ba60b2ad8086e88b07d8b01e8fdffca3a61edee1825afaf01d4a83ea"}}
