{"schema":"threatline.record/2","record":{"id":"CVE-2026-92942","aliases":["GHSA-r4fx-v8hh-22mv"],"summary":"vm2: timeout Option Bypass via FinalizationRegistry Cleanup Callback (Unbounded Host Event-Loop Block)","details":"### Vulnerability Summary\n \nvm2's `VM({ timeout })` option is documented and relied upon as the mechanism that bounds how long sandboxed code may execute. In the current implementation, the timeout only wraps the single synchronous call to `VM#run()` (via `doWithTimeout` → `this._runScript(script)` in `lib/vm.js`). It does not, and structurally cannot, bound code that the V8 engine itself schedules to run *after* that call has already returned.\n \n`FinalizationRegistry` and `WeakRef` are exposed to sandboxed code completely unmodified — they are not present anywhere in `lib/setup-sandbox.js`'s list of specially-wrapped/hardened globals (only `WeakMap`, `Promise`, `Proxy`, `Reflect`, etc. receive hardening there). Sandboxed code can register a `FinalizationRegistry` callback against an object it creates and immediately drops. `VM#run()` returns normally, well within the configured timeout, because registration is instant. At some later point — determined entirely by the V8 garbage collector, and forceable on demand by the host process (e.g. under memory pressure, or via `--expose-gc`) — the engine invokes the sandboxed cleanup callback directly. This invocation is **not** mediated by `doWithTimeout`, `Script.runInContext({timeout})`, or any other vm2 accounting mechanism, because it isn't a new call to `VM#run()` at all — it's the GC's own native callback-invocation path.\n\n## Affected Code & Version\n \n- **Repository:** `patriksimek/vm2`\n- **Version tested:** `3.11.6` (commit `a5b31cd9c01b37139aa9c71df1c691a6d1b440f9`, 2026-08-14) — the current `main` branch, i.e. this reproduces on the latest release, after all 2026 CVE-wave fixes (CVE-2026-22709, CVE-2026-26956, and the May 2026 13-advisory batch).\n- **`lib/vm.js`, `run()` (~line 501) and `doWithTimeout()` (~line 105):** the `timeout` option only wraps the single call to `this._runScript(script)`. No mechanism exists to bound execution triggered by engine-internal callbacks scheduled outside that call.\n- **`lib/setup-sandbox.js`, lines 1–14:** the module's captured/hardened globals list (`LocalWeakMap`, `LocalProxy`, `LocalError`, etc.) does not include `FinalizationRegistry` or `WeakRef`. Repo-wide `grep` for `FinalizationRegistry|WeakRef` returns zero matches in `lib/`, confirming neither receives any wrapping, restriction, or special handling — they are exposed to sandboxed code as bare, fully-functional constructors.\n## Steps to Reproduce\n \nEnvironment: Node.js v22.22.2, vm2 checked out at the commit above, dependencies installed with `npm install`.\n \n1. Clone and install:\n```\n   git clone https://github.com/patriksimek/vm2.git\n   cd vm2 && npm install\n```\n2. Save as `poc_timeout_bypass.js` in the repo root:\n```js\n   const { VM } = require('./lib/main.js');\n \n   const CONFIGURED_TIMEOUT_MS = 200;\n   const vm = new VM({ timeout: CONFIGURED_TIMEOUT_MS });\n \n   const t0 = Date.now();\n   let runError = null;\n   try {\n     vm.run(`\n       let target = {};\n       const registry = new FinalizationRegistry(() => {\n         const busyStart = Date.now();\n         while (Date.now() - busyStart < 3000) { /* burn CPU, block event loop */ }\n       });\n       registry.register(target, 'held-value');\n       target = null; // drop only strong reference -> GC-eligible\n     `);\n   } catch (e) { runError = e.message; }\n   const t1 = Date.now();\n   console.log('vm.run() returned after', t1 - t0, 'ms. Threw:', runError);\n \n   if (!global.gc) { console.log('Re-run with --expose-gc'); process.exit(1); }\n   global.gc(); global.gc();\n \n   const timerScheduledAt = Date.now();\n   setTimeout(() => {\n     const delay = Date.now() - timerScheduledAt;\n     console.log('Host setTimeout(10ms) actually fired after', delay, 'ms');\n     console.log('Total wall time since vm.run() returned:', Date.now() - t1, 'ms');\n   }, 10);\n```\n3. Run: `node --expose-gc poc_timeout_bypass.js`\n4. **Observed output:**\n```\n   vm.run() returned after 2 ms. Threw: null\n   Host setTimeout(10ms) actually fired after 3000 ms\n   Total wall time since vm.run() returned: 3029 ms\n```\n5. **Interpretation:** `run()` returned in 2ms, well inside the configured 200ms timeout — vm2 believes execution completed safely. A host-side timer scheduled for 10ms did not fire until ~3000ms later, proving the entire Node.js event loop — not just the sandbox — was blocked by sandboxed code running 15x longer than the configured timeout, entirely after `run()` had returned and outside any timeout enforcement.\n6. `typeof FinalizationRegistry` and `typeof WeakRef` inside a fresh `VM()` both evaluate to `\"function\"` with no wrapping, confirming the surface is reachable by design, not by an incidental leak.\n## Fix Recommendation\n \nPick one or combine:\n \n1. **Remove `FinalizationRegistry` and `WeakRef` from the sandbox global scope by default.** These are rarely needed by untrusted scripts and their GC-driven, engine-scheduled invocation model is fundamentally incompatible with a wall-clock `timeout` promise. Delete them from the context in `lib/setup-sandbox.js` alongside the other hardening done there, mirroring how other dangerous globals are handled.\n2. **If they must remain available**, wrap `FinalizationRegistry`'s constructor so the callback the sandbox supplies is itself invoked through a host-side dispatcher that re-applies a fresh per-invocation timeout (e.g. via `Script.runInContext` with `timeout` again, or by tracking cumulative CPU time via `process.hrtime`/`Isolate::TerminateExecution` if this is ever ported to a true isolate model). A callback that exceeds its budget should be forcibly terminated the same way an over-budget `run()` call is today.\n3. **Document the gap explicitly** if neither is implemented immediately: the current README/docs describe `timeout` as bounding sandboxed execution without qualification. At minimum, callers should be warned that GC-triggered callbacks (`FinalizationRegistry`) are exempt.\n\n\nany application that runs untrusted code through vm2 with a `timeout` expecting it to bound total CPU time can be denied service indefinitely. A single-line sandboxed script (`registry.register(target, x)` then drop the reference) can queue an unbounded busy-loop that fires at an unpredictable future moment and freezes the entire host Node.js process (single-threaded event loop) for as long as the attacker's loop runs — with no relationship at all to the configured `timeout` value. Because the freeze happens asynchronously and disconnected from the triggering `run()` call, it also undermines incident response: by the time the hang is observed, the `run()` call that caused it may be long gone from logs/traces.\n \nThis is a **timeout-enforcement bypass / uncontrolled resource consumption** issue, not (as currently demonstrated) a proxy/realm escape to host object access — sandboxed code stays within its own realm. See \"What this report does not claim\" below.","categories":["Libraries & packages"],"product":"vm2","vendor":"patriksimek","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-r4fx-v8hh-22mv","source_modified_at":"2026-10-05T22:35:04Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"range":"<= 3.11.6"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92942","source_modified_at":"2026-09-18T19:34:23.775Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vm2","vendor":"patriksimek","versions":[{"version":"0","status":"affected","versionType":"semver","lessThan":"3.11.7"},{"version":"3.11.7","status":"unaffected","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-r4fx-v8hh-22mv","source_modified_at":"2026-10-05T22:45:04.555144461Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.11.7"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-r4fx-v8hh-22mv","source_modified_at":"2026-10-05T22:35:04Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"version":"3.11.7"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92942","source_modified_at":"2026-09-18T19:34:23.775Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":null},"version":"3.11.7","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-r4fx-v8hh-22mv","source_modified_at":"2026-10-05T22:45:04.555144461Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"version":"3.11.7"}],"cvss":7.5,"severity":"High","kev":{"listed":false},"epss":{"probability":0.00488,"percentile":0.41094,"score_date":"2026-10-23"},"published_at":"2026-09-17T13:46:05.537Z","modified_at":"2026-10-05T22:45:04.555144461Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-r4fx-v8hh-22mv","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T22:35:04Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-92942","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-18T19:34:23.775Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-r4fx-v8hh-22mv","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-05T22:45:04.555144461Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-92942","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-r4fx-v8hh-22mv","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-92942","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/commit/fe2fcca2a1e693548993eccc624489dc4cb586b4","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.7","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vm2-before-3.11.7-timeout-bypass-via-finalizationregistry","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-r4fx-v8hh-22mv","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"yes","technical_impact":"partial","assessed_at":"2026-09-18T19:34:17.365666Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-05T22:45:04.555144461Z","content_hash":"f4748be75acfb78477f468fd4a0dc9875c73333e7396d29ebeef9b9c9e6add48"}}
