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.


Why sandboxing libraries are a high-value target

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.

How the vulnerability works

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:

  1. The setup. A developer creates the sandbox exactly as shown in vm2's README, under "Quick Examples": new NodeVM({ require: { external: true, root: './' } })..
  2. The untrusted code runs. vm.run(untrustedCode) starts executing whatever code the attacker controls.
  3. The code requires vm2 itself. It calls 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.
  4. The code builds a second, unrestricted sandbox. Using that real vm2 module, it creates a new NodeVM with its own settings: new real.NodeVM({ require: { builtin: ['child_process'] } }).. This second sandbox is entirely under the attacker's control.
  5. The code runs, unrestricted. inner.run("require('child_process').execSync(...)") executes, and the attacker has arbitrary command execution on the host.
The fix, and what it actually covers

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:

  • The CLI fix the maintainer shipped alongside this has its own separate advisory (GHSA-jxxv-8r27-vm4p).
  • Version 3.11.7 is a combined release that also fixes three other, unrelated bugs.

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.

Checking your exposure

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:

  • A default setting that looks safe but isn't. root: './' reads like a limit, but whether it actually limits anything depends entirely on what else is sitting in that folder.
  • A risky default with no warning next to it. 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.
  • A pattern that's easy to miss. An earlier bug reached this same "unrestricted nested VM" result through 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.
What this means for you

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.

Disclosure timelineDateEvent2026-07-23Report opened with the maintainer via GitHub private security advisory2026-08-18Report accepted by maintainer2026-08-18Fix committed to the maintainer's private fork2026-08-18We validated the fix against our PoC on the private fork2026-08-24Public 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.

How GitLab can help

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.

Looking ahead

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.