{"repro_id":"REPRO-2026-00098","version":5,"title":"SandboxJS: Host Prototype Pollution via Array Intermediary (Sandbox Escape)","repro_type":"security","status":"published","severity":"critical","description":"A sandbox escape vulnerability allows sandboxed code to mutate host built-in prototypes by laundering the `isGlobal` protection flag through array literal intermediaries. When a global prototype reference (e.g., `Map.prototype`, `Set.prototype`) is placed into an array and retrieved, the `isGlobal` taint is stripped, permitting direct prototype mutation from within the sandbox. This results in persistent host-side prototype pollution and may enable RCE in applications that use polluted properties in sensitive sinks (example gadget: `execSync(obj.cmd)`).","root_cause":"# Root Cause Analysis: GHSA-ww7g-4gwx-m7wj\n\n## Summary\n\nA sandbox escape vulnerability in `@nyariv/sandboxjs` (versions <= 0.8.30) allows sandboxed untrusted code to bypass the `isGlobal` protection mechanism and pollute host built-in prototypes (e.g., `Map.prototype`, `Set.prototype`). The vulnerability occurs when global prototype references are placed into array literals - the `isGlobal` taint flag is stripped during array creation via `valueOrProp()`, allowing the retrieved prototype to be modified directly from within the sandbox.\n\n## Impact\n\n- **Package:** `@nyariv/sandboxjs` (npm)\n- **Affected Versions:** `<= 0.8.30`\n- **Fixed In:** `0.8.31`\n- **CVSS Score:** 9.1 (Critical)\n- **CWE:** CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes)\n\n**Risk:** This is a sandbox escape vulnerability that breaks isolation between untrusted sandboxed code and the host environment. Attackers can:\n1. Persistently pollute host prototypes (e.g., `Map.prototype`, `Set.prototype`)\n2. Overwrite built-in methods (e.g., `Set.prototype.has`)\n3. Inject properties that can be used in RCE gadgets if host code uses polluted properties in sensitive sinks like `child_process.execSync()`\n\n**Consequences:** Any application using this package to execute untrusted JavaScript is vulnerable to complete host compromise.\n\n## Root Cause\n\n### Technical Explanation\n\nThe sandbox uses a `Prop` class with an `isGlobal` flag to track whether a value is a global/prototype that should be protected from modification. The `set()` method in `src/utils.ts` checks this flag:\n\n```typescript\nset(key: string, val: unknown) {\n  // ...\n  if (prop.isGlobal) {\n    throw new SandboxError(`Cannot override global variable '${key}'`);\n  }\n  (prop.context as any)[prop.prop] = val;\n  return prop;\n}\n```\n\nHowever, when values pass through array literal creation in `src/executor.ts` (lines 559-571), the `valueOrProp()` function unwraps `Prop` objects into raw values, stripping the `isGlobal` taint:\n\n```typescript\naddOps(LispType.CreateArray, (exec, done, ticks, a, b: Lisp[], obj, context, scope) => {\n  const items = (b as LispItem[])\n    .map((item) => {\n      if (item instanceof SpreadArray) {\n        return [...item.item];\n      } else {\n        return item;\n      }\n    })\n    .flat()\n    .map((item) => valueOrProp(item, context));  // <- isGlobal flag lost here\n  done(undefined, items);\n});\n```\n\nWhen `Map.prototype` is accessed via `[Map.prototype][0]`, the retrieved value no longer has the `isGlobal` flag, bypassing the protection.\n\n### Fix Commit\n\n- https://github.com/nyariv/SandboxJS/commit/f369f8db26649f212a6a9a2e7a1624cb2f705b53\n\n## Reproduction Steps\n\nRun the reproduction script:\n```bash\n./repro/reproduction_steps.sh\n```\n\nThe script performs three tests:\n\n1. **Prototype Pollution Test:** Places `Map.prototype` into an array, retrieves it, and sets `polluted='pwned'`. Verifies that `'polluted' in Map.prototype` returns `true` and `Map.prototype.polluted === 'pwned'`.\n\n2. **Method Overwrite Test:** Retrieves `Set.prototype` via array and overwrites `Set.prototype.has` with `isFinite`. Verifies the overwrite succeeds.\n\n3. **RCE Gadget Test:** Pollutes `Map.prototype` with `cmd='id'`, demonstrating that `new Map().cmd` returns `'id'`, which could be passed to `execSync()` by vulnerable host code.\n\n### Expected Evidence\n\nThe script outputs `[PASS]` for all three tests and confirms:\n- `\"polluted\" in Map.prototype = true`\n- `Map.prototype.polluted = pwned`\n- `Set.prototype.has === isFinite: true`\n- `new Map().cmd = id`\n\n## Evidence\n\n**Log Files:**\n- `logs/reproduction.log` - Complete reproduction output\n\n**Key Excerpts from Successful Run:**\n```\n[TEST 1] Prototype pollution via array intermediary\nBefore: \"polluted\" in Map.prototype = false\nAfter: \"polluted\" in Map.prototype = true\nMap.prototype.polluted = pwned\n[PASS] Prototype pollution confirmed!\n\n[TEST 2] Overwrite Set.prototype.has\nSet.prototype.has === isFinite: true\n[PASS] Set.prototype.has was successfully overwritten!\n\n[TEST 3] RCE gadget via prototype pollution\nnew Map().cmd = id\n[PASS] RCE gadget works - injected command in prototype!\n```\n\n**Environment:**\n- Node.js (tested with npm-installed package)\n- `@nyariv/sandboxjs` version 0.8.30 (vulnerable)\n\n## Recommendations / Next Steps\n\n### Immediate Actions\n\n1. **Upgrade to version 0.8.31 or later** - The fix commit addresses the taint stripping issue\n2. **Audit existing deployments** - Check if any applications using this package process untrusted user input\n\n### Fix Approach (for maintainers)\n\n1. **Preserve `isGlobal` through array/object literals:** Modify the array/object creation code to preserve the `Prop` wrapper instead of unwrapping via `valueOrProp()`\n2. **Hard block built-in prototype writes:** Add explicit checks for built-in prototype objects (`Map.prototype`, `Set.prototype`, etc.) regardless of how they're obtained\n3. **Defense in depth:** Consider freezing built-in prototypes before running untrusted code\n\n### Testing Recommendations\n\nAdd regression tests that:\n- Attempt prototype pollution via `[Map.prototype][0]`, `[Set.prototype][0]`, etc.\n- Verify the `isGlobal` protection is maintained through array/object literals\n- Test method overwriting attempts on built-in prototypes\n\n## Additional Notes\n\n### Idempotency Confirmation\n\nThe reproduction script has been verified to pass two consecutive runs:\n- Run 1: All tests passed\n- Run 2: All tests passed\n\nThe script includes cleanup to remove prototype pollution after testing (`delete Map.prototype.polluted`, etc.), ensuring idempotent behavior.\n\n### Edge Cases\n\n- Other built-in prototypes (`Array.prototype`, `Object.prototype`, `Promise.prototype`, etc.) may also be vulnerable via the same vector\n- Object literals (not just arrays) may have similar taint-stripping issues\n- The vulnerability requires the sandbox to have access to global prototypes, which is the default configuration\n\n### Related Identifiers\n\n- **CVE:** CVE-2026-25881\n- **GHSA:** GHSA-ww7g-4gwx-m7wj\n- **NVD:** https://nvd.nist.gov/vuln/detail/CVE-2026-25881\n","ghsa_id":"GHSA-ww7g-4gwx-m7wj","cve_id":"CVE-2026-25881","package":{"name":"@nyariv/sandboxjs","ecosystem":"npm","affected_versions":"<= 0.8.30","fixed_version":"0.8.31"},"reproduced_at":"2026-02-19T19:47:55.006840+00:00","duration_secs":961.11830163002,"tool_calls":116,"turns":93,"handoffs":3,"total_cost_usd":0.4201503999999999,"agent_costs":{"repro":0.2386735,"support":0.0151242,"vuln_variant":0.16635270000000002},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p5":0.2386735},"support":{"accounts/fireworks/models/kimi-k2p5":0.0151242},"vuln_variant":{"accounts/fireworks/models/kimi-k2p5":0.16635270000000002}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-02-19T19:47:56.727690+00:00","retracted":false,"artifacts":[{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3979,"category":"reproduction_script"},{"path":"repro/rca_report.md","filename":"rca_report.md","size":6070,"category":"analysis"},{"path":"bundle/ticket.json","filename":"ticket.json","size":13725,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":5407,"category":"ticket"},{"path":"bundle/source.json","filename":"source.json","size":7683,"category":"other"},{"path":"repro/test_repro.js","filename":"test_repro.js","size":2842,"category":"other"},{"path":"repro/package.json","filename":"package.json","size":279,"category":"other"},{"path":"logs/variant_run.log","filename":"variant_run.log","size":10966,"category":"log"},{"path":"logs/vuln_test.log","filename":"vuln_test.log","size":4582,"category":"log"},{"path":"logs/fixed_test.log","filename":"fixed_test.log","size":4913,"category":"log"},{"path":"logs/reproduction.log","filename":"reproduction.log","size":959,"category":"log"}]}