json1-11 Exercise 1: Big Numbers, Duplicate Keys and Injection ================================================================= 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. --- num.sh --- J=/c/lwdj/json/c4/bin/jq.exe D='{"id":9007199254740993,"price":0.1}' echo "document: $D" echo "node: $(node -e 'const o=JSON.parse(process.argv[1]);console.log(o.id, JSON.stringify(o))' "$D")" echo "python: $(./.venv/Scripts/python.exe -c 'import json,sys;o=json.loads(sys.argv[1]);print(o["id"], json.dumps(o))' "$D")" echo "jq unchanged: $(echo "$D" | $J -c .)" echo "jq .id+0: $(echo "$D" | $J -c '.id+0')" echo "jq .id|tostring: $(echo "$D" | $J -c '.id|tostring')" Output: document: {"id":9007199254740993,"price":0.1} node: 9007199254740992 {"id":9007199254740992,"price":0.1} python: 9007199254740993 {"id": 9007199254740993, "price": 0.1} jq unchanged: {"id":9007199254740993,"price":0.1} jq .id+0: 9007199254740992 jq .id|tostring: "9007199254740993" --- dup.sh (three tools, one document) --- J=/c/lwdj/json/c4/bin/jq.exe; D='{"role":"user","role":"admin","n":1,"n":2}' echo "doc: $D" echo "node : $(node -e 'console.log(JSON.stringify(JSON.parse(process.argv[1])))' "$D")" echo "python: $(./.venv/Scripts/python.exe -c 'import json,sys;print(json.dumps(json.loads(sys.argv[1])))' "$D")" echo "jq : $(echo "$D" | $J -c .) | .role -> $(echo "$D" | $J -c .role)" Output: doc: {"role":"user","role":"admin","n":1,"n":2} node : {"role":"admin","n":2} python: {"role": "admin", "n": 2} jq : {"role":"admin","n":2} | .role -> "admin" --- dup.py (strict hook, a SIMULATED first-wins reader, re-serialising) --- import json DOC = '{"role":"user","role":"admin","tags":[{"a":1,"a":2}]}' def strict(pairs): seen = set() for k, _ in pairs: if k in seen: raise ValueError("duplicate key: " + repr(k)) seen.add(k) return dict(pairs) def first_wins(pairs): # a SIMULATED first-wins parser: I did not find a real one among the tools I ran d = {} for k, v in pairs: d.setdefault(k, v) return d print("json.loads (default, last wins):", json.loads(DOC)) print("simulated first-wins parser :", json.loads(DOC, object_pairs_hook=first_wins)) try: json.loads(DOC, object_pairs_hook=strict) except ValueError as e: print("strict hook rejects :", e) try: json.loads('{"a":{"b":1,"b":2}}', object_pairs_hook=strict) except ValueError as e: print("strict hook, nested duplicate :", e) print("a gateway that checks 'first wins' sees role=%r; a backend that parses 'last wins' sees role=%r" % (json.loads(DOC, object_pairs_hook=first_wins)["role"], json.loads(DOC)["role"])) print("re-serialising after parsing removes the ambiguity:", json.dumps(json.loads(DOC))) Output: json.loads (default, last wins): {'role': 'admin', 'tags': [{'a': 2}]} simulated first-wins parser : {'role': 'user', 'tags': [{'a': 1}]} strict hook rejects : duplicate key: 'a' strict hook, nested duplicate : duplicate key: 'b' a gateway that checks 'first wins' sees role='user'; a backend that parses 'last wins' sees role='admin' re-serialising after parsing removes the ambiguity: {"role": "admin", "tags": [{"a": 2}]} --- inj.py (concatenation versus json.dumps; the attacker's field before and after the real one) --- import json def bad(name): return '{"user":"' + name + '","role":"user"}' # attacker field comes BEFORE the real role def bad2(name): return '{"role":"user","user":"' + name + '"}' # attacker field comes AFTER the real role def good(name): return json.dumps({"role": "user", "user": name}) attack = 'bob","role":"admin' print("bad (name first) normal name:", bad("bob"), "->", json.loads(bad("bob"))) print("bad (name first) attack :", bad(attack), "->", json.loads(bad(attack))) print("bad2 (name last) attack :", bad2(attack), "->", json.loads(bad2(attack))) print("good (json.dumps) attack :", good(attack), "->", json.loads(good(attack))) try: json.loads(bad('say "hi"')) except json.JSONDecodeError as e: print("bad with a plain quote : parse error:", e.msg) print("bad with a backslash :", json.loads(bad("C:" + chr(92) + "temp")), "(the characters after the backslash became a TAB)") Output: bad (name first) normal name: {"user":"bob","role":"user"} -> {'user': 'bob', 'role': 'user'} bad (name first) attack : {"user":"bob","role":"admin","role":"user"} -> {'user': 'bob', 'role': 'user'} bad2 (name last) attack : {"role":"user","user":"bob","role":"admin"} -> {'role': 'admin', 'user': 'bob'} good (json.dumps) attack : {"role": "user", "user": "bob\",\"role\":\"admin"} -> {'role': 'user', 'user': 'bob","role":"admin'} bad with a plain quote : parse error: Expecting ',' delimiter bad with a backslash : {'user': 'C:\temp', 'role': 'user'} (the characters after the backslash became a TAB) --- inj.mjs (the same in Node, then JSON inside a script block) --- const BS = String.fromCharCode(92); const bad = (name) => '{"user":"' + name + '","role":"user"}', good = (name) => JSON.stringify({ user: name, role: "user" }); const attack = 'bob","role":"admin'; console.log("bad with the attack:", JSON.stringify(JSON.parse(bad(attack)))); const bad2 = (name) => '{"role":"user","user":"' + name + '"}'; console.log("bad2 (name last) with the attack:", JSON.stringify(JSON.parse(bad2(attack)))); console.log("good with the attack:", JSON.stringify(JSON.parse(good(attack)))); // JSON inside an HTML " }; const page = (json) => ""; console.log("\nJSON.stringify leaves < alone:", page(JSON.stringify(data))); const safe = JSON.stringify(data).replace(//g, BS + "u003e").replace(/&/g, BS + "u0026"); console.log("escaping < > & as JSON escapes:", page(safe)); console.log("the escaped text still parses to the same data:", JSON.stringify(JSON.parse(safe)) === JSON.stringify(data)); console.log("ES2019: raw U+2028 inside a JSON string is legal JavaScript source now: ", (() => { try { return eval('({"a":"x' + String.fromCharCode(0x2028) + 'y"})').a.length === 3; } catch (e) { return e.name; } })()); Output: bad with the attack: {"user":"bob","role":"user"} bad2 (name last) with the attack: {"role":"admin","user":"bob"} good with the attack: {"user":"bob\",\"role\":\"admin","role":"user"} JSON.stringify leaves < alone: "}; escaping < > & as JSON escapes: the escaped text still parses to the same data: true ES2019: raw U+2028 inside a JSON string is legal JavaScript source now: true WHY THIS WORKS AS AN ANSWER --------------------------- Node turns 9007199254740993 into ...992 and writes the wrong value back; Python and a jq pass-through keep it; jq arithmetic loses the digit and tostring keeps it. All three tools keep the LAST duplicate, so the first-wins reader in dup.py is a simulation I wrote, not a real parser (I found none among the tools I ran). The injection output shows that the attack succeeded only when the injected key came after the real one, that a plain quote broke the parse, and that a backslash followed by t silently became a tab. json.dumps and JSON.stringify keep the odd name as data. In the script-block demo JSON.stringify leaves the closing tag intact and the u003c escape removes it while the data round-trips (checked by comparing parsed values; no browser was used in this part). Not tested: other languages' parsers, other Node or Python versions.