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.