GHSA-cp6g-6699-wx9c: vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape
SummaryNodeVM'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 hostAffected 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 AccessDescriptionRoot Cause: Check/Use Path DiscrepancyThe `isPathAllowed` method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:The filename passed to this check is resolved via `DefaultFileSystem.resolve()`, which uses `path.resolve()`:`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:No Symlink Defenses ExistA 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:Full Execution ChainHost creates `NodeVM` with `require: { 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.js``tryFile()` calls `this.fs.resolve(x)` → `path.resolve()` → `/tmp/root/node_modules/safe/index.js` (symlink NOT followed)`isPathAllowed()` checks if path starts with `/tmp/root/` → **PASSES**`loadJS()` detects `context: 'host'`, calls `this.hostRequire(filename)`Node's `require()` follows the symlink, loads from `/outside/root/vm2/index.js`Module executes in host realm; exports proxied to sandboxSandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with `child_process`)Proof of ConceptImpact**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 RemediationOption 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:Also dereference root paths at construction time:**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`:**Tradeoff**: Fixes it at a higher layer but doesn't protect custom resolvers that bypass `makeExtensionHandler`.CreditThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).
CVSS v3.1: 8.5