CVE-2026-100722: vm2 before 3.12.2 Host Process Termination via Construct Trap

Published Sep 27, 2026
·
Updated

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('VULNERABLESTARTED'); vm.run('new HostReject(); 1'); setTimeout(() => console.log('ALIVE'), 300); } else if (mode === 'control') { vm.run('new HostReject().catch(function () {}); 1'); setTimeout(() => console.log('CONTROLALIVE'), 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 NODEOPTIONS=--unhandled-rejections=strict. The abort flag makes the process crash signal explicit:

text NODEOPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js vulnerable

Abridged vulnerable output:

text VULNERABLESTARTED 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 NODEOPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js control

Control output:

text CONTROLALIVE

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 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, 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”. 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.

Other sources

vm2 before 3.12.2 does not apply host-side Promise rejection handling in the sandbox-to-host construct trap. In BaseHandler, the apply trap calls markHostPromiseHandled() on the returned value, but the adjacent construct path returns the result of Reflect.construct without the same sanitization. If an embedder exposes a constructable host function whose constructor returns a native rejected Promise, an untrusted script executed via VM.run can invoke it with new and ignore the result; the rejected host Promise crosses the bridge unhandled and, under Node's strict unhandled-rejection policy, is promoted to an uncaught exception that terminates the host process.

— NVD

Affected Software

2 affected componentsFixes available
npm/vm2<3.12.2
npm/vm2<=3.12.1
3.12.2

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/vm2 to a version that resolves this vulnerability.

    Fixed in 3.12.2
  2. Upgrade

    Upgrade vm2 to a version that resolves this vulnerability.

    Fixed in 3.12.2

Event History

Sep 27, 2026
CVE Published
via MITRE·01:28 AM
Data Sourced
via MITRE·01:28 AM
DescriptionSeverityWeakness
Data Sourced
via NVD·02:17 AM
DescriptionSeverityWeakness
Oct 5, 2026
Advisory Published
via GitHub·11:19 PM
Data Sourced
via GitHub·11:19 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are actually exposed to host-process termination?

Exposure requires an application using vm2 before 3.12.2 that executes untrusted code with VM.run and exposes a constructable host function to that code. The exposed constructor must be capable of returning a native rejected Promise.

2

What must an attacker do to trigger the issue?

An attacker must be able to run an untrusted script in the vm2 sandbox and invoke the exposed host function with new. The script can ignore the constructor result, leaving the rejected host Promise unhandled across the sandbox-to-host bridge.

3

Does exploitation depend on Node.js runtime policy?

Yes. The described host-process termination occurs when Node uses a strict unhandled-rejection policy, under which the unhandled rejected Promise is promoted to an uncaught exception.

4

How can I assess whether my application is affected?

Check whether your application uses a vm2 version before 3.12.2, calls VM.run with untrusted input, and exposes constructable host functions into the sandbox. Review whether any such constructor can return a native rejected Promise and whether the Node runtime uses strict unhandled-rejection handling.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203