json1-11 Exercise 2: Prototype Pollution, eval, and Unsafe Loaders ==================================================================== Run with Node 24.18.0 (secure-json-parse 4.1.1), Python 3.14.0 (PyYAML 6.0.3), jq 1.8.2, curl 8.12.1 and headless Chrome 154 on Windows 11, installed in a scratch folder. Every payload only prints a message or sets a variable. --- pp.mjs (JSON.parse, a vulnerable merge, the fix, Map, null prototype, secure-json-parse, assign, spread, structuredClone) --- import sjp from "secure-json-parse"; const payload = '{"name":"x","__proto__":{"isAdmin":true}}'; const parsed = JSON.parse(payload); console.log("1. JSON.parse itself: own keys =", Object.keys(parsed), "| ({}).isAdmin =", ({}).isAdmin, "| parsed.isAdmin =", parsed.isAdmin); console.log(" __proto__ is an ordinary own property here:", Object.hasOwn(parsed, "__proto__")); // 2. the classic vulnerable recursive merge function mergeBad(target, src) { for (const k in src) { if (src[k] && typeof src[k] === "object") { if (!target[k]) target[k] = {}; mergeBad(target[k], src[k]); } else target[k] = src[k]; } return target; } const defaults = { theme: "light" }; mergeBad(defaults, JSON.parse(payload)); console.log("2. after mergeBad(defaults, parsed): ({}).isAdmin =", ({}).isAdmin, "| a brand-new object {} is now admin:", ({}).isAdmin === true); delete Object.prototype.isAdmin; console.log(" (cleaned up Object.prototype for the rest of this script:", ({}).isAdmin, ")"); // 3. fixes function mergeSafe(target, src) { for (const k of Object.keys(src)) { if (k === "__proto__" || k === "constructor" || k === "prototype") continue; if (src[k] && typeof src[k] === "object" && !Array.isArray(src[k])) { if (!Object.hasOwn(target, k) || typeof target[k] !== "object") target[k] = {}; mergeSafe(target[k], src[k]); } else target[k] = src[k]; } return target; } mergeSafe({ theme: "light" }, JSON.parse(payload)); console.log("3a. mergeSafe (skips the three names): ({}).isAdmin =", ({}).isAdmin); const viaCtor = '{"constructor":{"prototype":{"polluted":1}}}'; mergeBad({}, JSON.parse(viaCtor)); console.log("3b. the constructor.prototype route through mergeBad: ({}).polluted =", ({}).polluted); delete Object.prototype.polluted; mergeSafe({}, JSON.parse(viaCtor)); console.log(" same payload through mergeSafe: ({}).polluted =", ({}).polluted); const m = new Map(Object.entries(JSON.parse('{"a":1,"__proto__":{"x":1}}'))); console.log("3c. Map keys:", [...m.keys()], "| plain {} unaffected:", ({}).x); const dict = Object.assign(Object.create(null), JSON.parse(payload)); console.log("3d. Object.create(null) dictionary has no inherited properties: 'isAdmin' in dict =", "isAdmin" in dict, "| Object.getPrototypeOf(dict) =", Object.getPrototypeOf(dict)); try { sjp.parse(payload); } catch (e) { console.log("3e. secure-json-parse 4.1.1 default:", e.name + ": " + e.message); } console.log(" with protoAction 'remove':", JSON.stringify(sjp.parse(payload, { protoAction: "remove" }))); // 4. Object.assign / spread const t1 = Object.assign({}, parsed); console.log("4a. Object.assign({}, parsed): own keys", Object.keys(t1), "| Object.getPrototypeOf(t1) === Object.prototype:", Object.getPrototypeOf(t1) === Object.prototype, "| t1.isAdmin =", t1.isAdmin, "| ({}).isAdmin =", ({}).isAdmin); const t2 = { ...parsed }; console.log("4b. spread {...parsed}: own keys", Object.keys(t2), "| t2.isAdmin =", t2.isAdmin, "| prototype is Object.prototype:", Object.getPrototypeOf(t2) === Object.prototype); const t3 = structuredClone(parsed); console.log("4c. structuredClone(parsed): own keys", Object.keys(t3), "| t3.isAdmin =", t3.isAdmin); Output: 1. JSON.parse itself: own keys = [ 'name', '__proto__' ] | ({}).isAdmin = undefined | parsed.isAdmin = undefined __proto__ is an ordinary own property here: true 2. after mergeBad(defaults, parsed): ({}).isAdmin = true | a brand-new object {} is now admin: true (cleaned up Object.prototype for the rest of this script: undefined ) 3a. mergeSafe (skips the three names): ({}).isAdmin = undefined 3b. the constructor.prototype route through mergeBad: ({}).polluted = 1 same payload through mergeSafe: ({}).polluted = undefined 3c. Map keys: [ 'a', '__proto__' ] | plain {} unaffected: undefined 3d. Object.create(null) dictionary has no inherited properties: 'isAdmin' in dict = false | Object.getPrototypeOf(dict) = null 3e. secure-json-parse 4.1.1 default: SyntaxError: Object contains forbidden prototype property with protoAction 'remove': {"name":"x"} 4a. Object.assign({}, parsed): own keys [ 'name' ] | Object.getPrototypeOf(t1) === Object.prototype: false | t1.isAdmin = true | ({}).isAdmin = undefined 4b. spread {...parsed}: own keys [ 'name', '__proto__' ] | t2.isAdmin = undefined | prototype is Object.prototype: true 4c. structuredClone(parsed): own keys [ 'name', '__proto__' ] | t3.isAdmin = undefined --- ev.mjs (eval versus JSON.parse in Node) --- const evil = '{"a":1,"b":(globalThis.RAN = "code ran", 42)}'; try { JSON.parse(evil); } catch (e) { console.log("JSON.parse rejects it :", e.name); } console.log("RAN before eval :", globalThis.RAN); const v = eval("(" + evil + ")"); console.log("eval accepted it :", JSON.stringify(v), "| RAN after eval:", globalThis.RAN); const ok = '{"a":1}'; console.log("eval on honest JSON works too, which is why people did it:", JSON.stringify(eval("(" + ok + ")"))); Output: JSON.parse rejects it : SyntaxError RAN before eval : undefined eval accepted it : {"a":1,"b":42} | RAN after eval: code ran eval on honest JSON works too, which is why people did it: {"a":1} --- ev.py (eval, pickle and PyYAML UnsafeLoader versus safe_load) --- import json, pickle, yaml evil = '{"a": 1, "b": __import__("builtins").print("code ran inside eval") or 42}' try: json.loads(evil) except json.JSONDecodeError as e: print("json.loads rejects it:", type(e).__name__) print("eval accepted it :", eval(evil)) class Boom: def __reduce__(self): return (print, ("code ran during pickle.loads",)) blob = pickle.dumps(Boom()) print("pickle bytes made; loading them now:"); pickle.loads(blob) y = '!!python/object/apply:builtins.print ["code ran during yaml.load with UnsafeLoader"]' try: yaml.safe_load(y) except yaml.YAMLError as e: print("yaml.safe_load refuses :", type(e).__name__) print("UnsafeLoader:"); yaml.load(y, Loader=yaml.UnsafeLoader) print("json.loads of the same text as a JSON string is just data:", json.loads(json.dumps(y)) == y) Output: json.loads rejects it: JSONDecodeError code ran inside eval eval accepted it : {'a': 1, 'b': 42} pickle bytes made; loading them now: code ran during pickle.loads yaml.safe_load refuses : ConstructorError UnsafeLoader: code ran during yaml.load with UnsafeLoader json.loads of the same text as a JSON string is just data: True WHY THIS WORKS AS AN ANSWER --------------------------- Step 1 shows the parse alone is safe and step 2 shows the merge is what pollutes, both through __proto__ and through constructor.prototype; each pollution is deleted from Object.prototype straight afterwards so later steps are not contaminated, and the output checks that. mergeSafe leaves ({}) clean for both payloads. Object.assign did not pollute globally but changed the new object's prototype (t1.isAdmin is true). The eval and unsafe-loader payloads call print or set a variable and nothing else, and the same text is refused by JSON.parse, json.loads and yaml.safe_load. Not tested: lodash or other real merge libraries, other engines, the other unsafe-loader formats.