Описание
vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape
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:
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 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:
Full Execution Chain
- Host creates
NodeVMwithrequire: { 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.jstryFile()callsthis.fs.resolve(x)→path.resolve()→/tmp/root/node_modules/safe/index.js(symlink NOT followed)isPathAllowed()checks if path starts with/tmp/root/→ PASSESloadJS()detectscontext: 'host', callsthis.hostRequire(filename)- Node's
require()follows the symlink, loads from/outside/root/vm2/index.js - Module executes in host realm; exports proxied to sandbox
- Sandbox uses loaded module to escalate (e.g., creates a new privileged NodeVM with
child_process)
Proof of Concept
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.rootentirely: The root restriction — the primary defense against module loading attacks — provides no protection when symlinks are present. - Common in production: pnpm (where ALL
node_modulesare symlinks), npm workspaces, andnpm linkall 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:
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.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Пакеты
vm2
= 3.10.5
3.11.0
Связанные уязвимости
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.
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.
Уязвимость функции path.resolve() библиотеки vm2 пакетного менеджера NPM, позволяющая нарушителю обойти защитный механизм песочницы и выполнить произвольный код