CVE-2026-104849: Tinypool: Prototype Pollution Gadget to RCE in run() options
Tinypool versions before 2.1.2 let an attacker who can pollute `Object.prototype.filename` redirect `pool.run(task, options)` to an attacker-chosen worker module and run code in the pool. It only applies when the app passes its own options object to `run()`. Upgrade to tinypool 2.1.2.
Published Oct 5, 2026 · Analysis Oct 25, 2026
What an attacker can do
pool.run(task, options) reads filename from the options object the caller passes. If that object has no own filename, the lookup falls through to Object.prototype. An attacker who pollutes Object.prototype.filename, for example through a vulnerable lodash.merge or qs.parse elsewhere in the app, makes tinypool load and run a worker module of their choice.
Two conditions must hold: a prototype pollution primitive exists in the process, and the app calls run() with a second argument. A plain pool.run(task) is not affected, because the default filename is null and no caller-controlled object is involved.
The result is arbitrary JavaScript in the worker pool. In the advisory's proof of concept, an Express handler runs pool.run(req.body, { signal }). The malicious worker receives the request body and returns it, so it can read or change the data the app meant to process. That also gives code execution on the server. This is the tinypool counterpart of the piscina issue GHSA-x9g3-xrwr-cwfg, and it is separate from the execArgv and env issue in the entry for CVE-2026-104848.
Who is affected
Tinypool earlier than 2.1.2. Only code that calls pool.run(task, options) with an options object is exposed, for example to pass an AbortSignal.
npm ls tinypoolSearch your code for .run( calls on pools that pass a second argument.
tinypool
- Ecosystem
- npm
- Affected
- < 2.1.2
- Fixed in
- 2.1.2
- Check
npm ls tinypool
How to fix it
Upgrade to tinypool 2.1.2.
npm install [email protected]The release note reads "guard worker filename from proto pollution" (pull request 135). Versions 2.1.1 and earlier still have this flaw, even though 2.1.1 fixed a different tinypool prototype pollution issue. The reporter suggests reading options from own properties only and using a null-prototype object internally. The advisory lists no other workaround. Patching the upstream prototype pollution bug removes the entry point.
How to tell if you were hit
Look for worker modules loading from a path your application never configured. Check pool worker stack traces or logs for unexpected .mjs or .js filenames. Search the file system for worker files you did not deploy. Review request logs for JSON bodies with __proto__ or constructor.prototype keys aimed at endpoints that merge or parse input, since those are the likely pollution route.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
tinypool is a fork of piscina and inherited the same prototype-pollution surface. When pool.run(task, options) is called, the filename option is read from the provided options object. If that object does not have an own filename property, the lookup falls through to Object.prototype.
An attacker who can pollute Object.prototype.filename (for example, via a vulnerable lodash.merge, qs.parse, or similar elsewhere in the application) can make tinypool load and execute an attacker-controlled worker module.
This is the tinypool counterpart to the piscina root discovery GHSA-x9g3-xrwr-cwfg.
pool.run(task) with no second argument is not affected, because kDefaultOptions.filename is null and the options object is not user-controlled. The exploit only triggers when the caller passes their own options object to pool.run().
Impact
Arbitrary JavaScript execution in the worker pool. If the application passes attacker-controlled data as the run() task and also supplies a run() options object, the attacker can redirect execution to a malicious worker that exfiltrates or modifies that data, achieving remote code execution and/or data exfiltration.
Proof of Concept
// legitimate-worker.mjs
export default async (task) => ({ by: 'legitimate-worker', processed: task })
// malicious-worker.mjs
export default async (task) => ({ by: 'attacker', stolenRequestBody: task })
// main.js
import express from "express";
import Tinypool from "tinypool";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Simulate upstream prototype pollution (lodash merge, qs parse, etc.)
Object.prototype.filename = path.join(__dirname, "malicious-worker.mjs");
const pool = new Tinypool({
filename: path.join(__dirname, "legitimate-worker.mjs"),
});
express()
.use(express.json())
.post("/", async (req, res) => {
const ac = new AbortController();
const result = await pool.run(req.body, { signal: ac.signal });
res.json(result);
})
.listen(31337);Suggested fix
Read all user-supplied options from own properties only (Object.hasOwn or Object.prototype.hasOwnProperty.call) and build the internal ThreadPool.options object with a null prototype.
Credited to Fcmam5.
Questions
- Is `pool.run(task)` without options affected?
No. The advisory says a call with no second argument is safe, because the default
filenameisnulland no caller-controlled options object exists. Only calls that pass their own options object trigger the flaw.- Does tinypool 2.1.1 fix this?
No. The patched version for this advisory is 2.1.2. Anything below 2.1.2 is listed as vulnerable.
- Can this be exploited on its own?
No. The attacker first needs a prototype pollution bug elsewhere in the same process, such as in a merge or query-string parser. Tinypool then turns it into code execution.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
