allowlisted: .../node_modules/foo/index.js
recorded as: ^\.\.\.\/node_modules\/foo <- no boundary!
guest asks: .../node_modules/foo2/index.js <- passes the prefix
loaded with: hostRequire() <- host authority
I was researching this week's sandbox-escape pile-up and found a CVE that is honestly more interesting than the usual vm2 escape. CVE-2026-100721 is not a parser bug or a VM breakout in the classic sense. It is an authorization bug: a path allowlist that treated a string prefix as permission. One allowlisted module ended up authorizing its neighbors. Here is what I found, and what I would check if you run vm2 anywhere.
NVD published the record on Sep 27 with VulnCheck as the CNA (NVD currently marks the record status "Deferred" while the MITRE record shows "PUBLISHED", a nuance worth knowing before you cite it). The advisory itself, GHSA-5h3f-q97h-ccvc, went up weeks earlier on Sep 8, published by vm2's own maintainer with a full proof-of-concept harness, and the fix shipped the same day in 3.12.2. The score is 9.5 under CVSS 4.0 and 9.0 under 3.1. SSVC rates exploitation as "poc" only: no reports of it being used in the wild, no KEV listing as far as I could verify.
What actually broke in vm2's external resolver?
vm2's NodeVM lets an embedder configure require.external with a custom resolver function. The guest asks for a module name like foo, the resolver decides where that comes from, and vm2 records the answer so later requires can be checked against the allowlist.
The recording was the bug. In LegacyResolver.customResolve in lib/resolver-compat.js, the resolved path was stored as:
this.externals = new RegExp('^' + escapeRegExp(resolvedPath));
No path separator at the end. No end-of-string anchor. Just "does the path start with the string I approved?" When a later require comes in, isPathAllowedForModule runs that regex against the requested path. Anything that starts with the approved prefix matches, whether or not it is the module the resolver actually resolved.
How does one allowlisted module authorize its sibling?
The maintainer's advisory walks through the exact shape. Set up a vm2 sandbox with a custom resolver for a module called foo, install a second package foo2 next to it, and run two requires from guest code:
// embedder config that triggers the bug
const vm = new NodeVM({
require: { external: true, resolve: (module) => `/tmp/vm2-prefix-case/node_modules/${module}` },
context: 'host', // NodeVM default
});
// guest code:
// require('foo') -> prefix recorded: ^/tmp/vm2-prefix-case/node_modules/foo
// require('/tmp/vm2-prefix-case/node_modules/foo2/index.js') -> passes the prefix
The first require is legitimate: the resolver answers with /tmp/vm2-prefix-case/node_modules/foo, and vm2 records that prefix. The second require points at foo2, a completely different, non-allowlisted package whose path happens to start with the same characters. The prefix regex says yes. Because the sandbox runs with the default context: 'host', the sibling loads through hostRequire, so foo2's top-level code executes in the host process with full Node.js authority before anything is wrapped with vm.readonly.
The proof-of-concept signal
The advisory's harness proves it with a signal: the candidate run prints PREFIX_PWN from a host-side child_process call, the control run (no prefix overlap) fails cleanly with ENOTFOUND. The fix in 3.12.2 changes the recording to a boundary-matched base path, so foo no longer authorizes foo2.
Is this a sandbox escape? Technically the guest never left the vm. It asked for a file and the authorization layer said yes. That is arguably worse, because no amount of VM hardening fixes a permission check that over-matches.
Why is the default configuration not the exposure?
This is the part coverage gets wrong, so let me be precise about the limits. The bug requires a non-default setup: require.external with a custom resolver function. Out of the box, vm2 with no custom resolver does not hit customResolve in this way. The CVSS 4.0 vector encodes that as AT:P (attack requires present conditions), and CISA's SSVC marks it "automatable: no".
So the honest framing is: if you embedded vm2 the way the API encourages you to for controlled external access, custom resolver plus allowlist, your allowlist was a prefix match. People write that config because it is the documented pattern for "guest may use these modules, nothing else." The feature exists precisely to be the security control, and the control over-matched.
I have not reproduced any of this myself; everything above traces to the NVD description, the maintainer's GHSA, and the 3.12.2 release notes, which agree with each other on the mechanism.
Can you detect this without upgrading?
If you can, upgrade to 3.12.2 or later; that is the whole fix and it carries no API changes per the release notes. If you cannot patch today, the exposure is auditable in minutes:
Check what you actually installed
# 1) what version is actually installed?
npm ls vm2
Audit the resolver config
You are looking for a NodeVM construction with require: { external: ... , resolve: <fn> }:
grep -rn --include="*.js" -E "external\s*:" .
grep -rn "new NodeVM" --include="*.js" -A4 .
No custom resolver, no CVE-2026-100721 exposure from this bug (two sibling CVEs published the same day cover other paths, CVE-2026-100722 and CVE-2026-100723, the latter a 7.5 zlib issue, so a clean here is not a clean everywhere).
One more honest caveat: an aggregator post claims the npm advisory database lagged this fix, with npm audit calling 3.12.1 clean for at least one of the records. I could not verify that directly, so treat it as a reason to check npm ls rather than trust npm audit alone.
Is vm2 still "dead", and does that matter?
Here is the part I got wrong going in. vm2 was famously discontinued in 2023 with the maintainer's own words about unfixable security issues, and that story is still the top search result. When I saw a fresh vm2 CVE this week I expected an abandoned codebase.
The repo tells a different story. There is a steady release ladder from v3.10.0 in late 2025 through v3.12.2 this September, dozens of advisory fixes along the way, a SECURITY.md, and CI for Bun as well as Node. The maintainer published this advisory himself, with a working PoC, on the same day the fix shipped.
That creates a real dilemma for anyone running it. The 2023 "never use vm2" advice is factually stale, but the 2026 record shows a steady drip of escapes in a library whose entire job is isolation. I genuinely do not know what the right recommendation is: "maintained" is not the same as "safe to trust with truly hostile code." I lean toward: fine for hardening untrusted-but-not-adversarial input, still not a boundary against a determined attacker, and the sibling CVEs published the same day support that caution.
What should you take away from this?
Three things I would actually do:
- Pin vm2 at 3.12.2 or later, and if you run a custom-resolver allowlist, read your logs for absolute-path requires after the first resolution.
- Treat any allowlist built from string prefixes as authorization code under test. The general lesson, string-prefix matching on paths, shows up in registry hostnames, CI path filters, and half the access-control code I have reviewed. Boundary-match or anchor it.
- Re-check the "dead project" folklore before it drives a decision. The list of things people told me was unfixable in 2023 that got fixed in 2026 keeps growing.
The debate I keep going back to: should vm2 refuse to even start a NodeVM whose custom resolver cannot promise boundary matching, instead of shipping a fix and hoping embedders upgrade? Fail-closed costs flexibility. Fail-open cost someone, eventually, host authority. Where would you draw that line?
Further reading from my own catalog: I traced two CVSS 9.8 agent-sandbox CVEs that landed the same day, built a security scanner harness that lied to itself, and followed an AI agent hijack that needed no CVE at all.
Sources: NVD record for CVE-2026-100721, GHSA-5h3f-q97h-ccvc, vm2 v3.12.2 release notes, VulnCheck advisory (Sep 26, 2026).
Top comments (0)