# piscina: Prototype-pollution gadget in ThreadPool.options allows RCE via execArgv / loadBalancer / env

Piscina's ThreadPool.options inherits from Object.prototype, so polluted execArgv/loadBalancer/env keys reach worker constructors, yielding RCE in workers and main thread.

- Payout: Research
- Severity: critical
- Program: Piscina
- Found by: Fcmam5
- Disclosed: 2026-10-01
- CVE: CVE-2026-102992
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-67c8-pqhq-4rmx
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-67c8-pqhq-4rmx
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:prototype-pollution vuln:rce

**Target:** Node.js worker thread pool library (npm piscina)
**Root cause:** ThreadPool.options is built as a plain object (`{ ...kDefaultOptions, ...options, filename, maxQueue: 0 }`) inheriting Object.prototype, so options lacking explicit defaults in kDefaultOptions are read from the prototype chain.
**Steps:**
1. Pollute the prototype: `Object.prototype.execArgv = ['--require', '/tmp/attacker.js']`
2. Instantiate a Piscina pool with a valid worker filename and minThreads/maxThreads 1
3. Call `await pool.run(1)` to spawn a worker
4. Worker is created with inherited execArgv, preloading /tmp/attacker.js on startup
**How found:** Noticed ThreadPool.options is a plain object inheriting Object.prototype and that kDefaultOptions lacks defaults for many options, so prototype-chain reads supply them; also observed the prior GHSA-x9g3-xrwr-cwfg fix hardened filename reads but not the options object itself.
**Exploitation:** Attacker first needs a prototype-pollution primitive elsewhere in the app (vulnerable merge/JSON.parse merge). Setting Object.prototype.execArgv to ['--require', '/tmp/attacker.js'] causes every spawned worker to preload the attacker module; loadBalancer can inject a function invoked during scheduling in the main thread; env injects environment variables into workers.
**Escalation:** Chains a separate prototype-pollution bug into RCE via execArgv, or main-thread code execution via loadBalancer.
**Insight:** After a prototype-pollution fix, audit every object spread/merge that becomes a config object: any key without an explicit default in the defaults map is still reachable via Object.prototype, and options forwarded to child_process/Worker (execArgv, env, argv) are direct RCE gadgets.

## Proof of concept (copied exactly from the original report)

**PoC polluting execArgv to preload attacker module**

```
import { resolve } from 'node:path'
import Piscina from 'piscina'

Object.prototype.execArgv = ['--require', '/tmp/attacker.js']

const pool = new Piscina({
  filename: resolve(import.meta.dirname, 'worker.js'),
  minThreads: 1,
  maxThreads: 1,
})

await pool.run(1)
```

