Repository navigation
JSON.parse returns unexpected keys after decoding specified JSON key #63785
Description
Version
24.16.0
Platform
Microsoft Windows NT 10.0.20348.0 x64
Linux [REDACTED] 5.14.0-611.54.3.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Thu May 7 16:31:24 EDT 2026 x86_64 x86_64 x86_64 GNU/Linux
Subsystem
No response
What steps will reproduce the bug?
Background
I was fuzzing a custom JSON parser by generating random stuff, serializing them with JSON.stringify, parsing them with my parser, and cross-checking the result against native JSON.parse.
The fuzz test reported divergences somehow, and during the troubleshoot I found this very weird bug. I'm able to reproduce it on two machines (Windows / Linux, Node 24.16.0 x64), but unable to reproduce it on my own machine with Node 20 / 16, nor can I reproduce it in ChatGPT's sandbox (Node v22.16.0 and Linux I guess).
Trigger sequence definition
For a given sequence pair:
const CANARY = '{"\\r":"value are not important","\\n":"just keys"}', CHECK = '\n'.charCodeAt(0);
const TRIGGER = '{\"\\r\":[],\"\\\\\":0}';The first key of two sequences should be the same (\r vs \r, or \f vs \f, etc.). And the second of canary should be another escaped control string (We use \n in this case.
These are all valid trigger sequences:
const CANARY = '{"\\r":"value are not important","\\f":"just keys"}', CHECK = '\f'.charCodeAt(0);
const TRIGGER = '{"\\r":[],"\\\\":0}';
const CANARY = '{"\\f":"value are not important","\\n":"just keys"}', CHECK = '\n'.charCodeAt(0);
const TRIGGER = '{"\\f":[],"\\\\":0}';
const CANARY = '{"\\n":"value are not important","\\f":"just keys"}', CHECK = '\f'.charCodeAt(0);
const TRIGGER = '{"\\n":[],"\\\\":0}';
const CANARY = '{"\\f":"value are not important","\\r":"just keys"}', CHECK = '\r'.charCodeAt(0);
const TRIGGER = '{"\\f":[],"\\\\":0}';Minimal reproduce snippet
This is a hand-written version without AI slop.
console.info('Node version: ' + process.versions.node);
const CANARY = '{"\\r":"value are not important","\\f":"just keys"}', CHECK = '\f'.charCodeAt(0);
const TRIGGER = '{"\\r":[],"\\\\":0}';
const isStateBroken = (pass) => {
const keys = Object.keys(JSON.parse(CANARY));
console.info(`[${pass.padEnd(10)}] Test keys: ${keys.map(k => Buffer.from(k).toString('hex')).join(', ')}`);
return keys[1].charCodeAt(0) !== CHECK;
};
// Do a check first
if (isStateBroken('Before')) {
console.log('State already broken even before trigger sequence. Cannot reproduce!');
process.exit(2);
}
// Parse some good jsons
JSON.parse('{"good": "json"}');
JSON.parse('{"yay": 114514}');
// Still good
if (isStateBroken('Good Parse')) {
console.log('State already broken even before trigger sequence. Cannot reproduce!');
process.exit(2);
}
// WTF
try {
JSON.parse(TRIGGER);
} catch {
console.log('Trigger sequence parse failed!');
process.exit(2);
}
// Is it broken now?
const bad = isStateBroken('Triggered');
console.log(bad ? 'BROKEN' : 'Nope');
process.exit(bad ? 0 : 1);Output
[root@localhost bin]# ./node wtf.mjs
Node version: 24.16.0
[Before ] Test keys: 0d, 0c
[Good Parse] Test keys: 0d, 0c
[Triggered ] Test keys: 0d, 5c
BROKEN
[root@localhost bin]# node-20 wtf.mjs
Node version: 20.14.0
[Before ] Test keys: 0d, 0c
[Good Parse] Test keys: 0d, 0c
[Triggered ] Test keys: 0d, 5c
Nope
Here's a GPT-5.5 generated version, that uses my original fuzzer
It basically does the same thing with the minimal version, just using my original fuzzer.
const POOL = ['a', 'b', ' ', '"', '\\', '\n', '\t', '\r', '\b', '\f', String.fromCharCode(0), String.fromCharCode(0x1f), '/', '中', 'é', '\u{1f600}'].join('');
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randNumber(rng) {
const k = Math.floor(rng() * 8);
if (k === 0) return 0;
if (k === 1) return Math.floor((rng() - 0.5) * 2e6);
if (k === 2) return (rng() - 0.5) * 2e6;
if (k === 3) return rng() * 1e-7;
if (k === 4) return rng() * 1e21;
if (k === 5) return Number.MAX_SAFE_INTEGER;
if (k === 6) return -0;
return rng() < 0.5 ? 0.1 + 0.2 : Math.PI;
}
function randString(rng) {
const len = Math.floor(rng() * 8);
let s = '';
for (let i = 0; i < len; i++) {
s += rng() < 0.6
? [...POOL][Math.floor(rng() * [...POOL].length)]
: String.fromCharCode(Math.floor(rng() * 0x90));
}
return s;
}
function randValue(rng, depth) {
const r = rng();
if (depth <= 0 || r < 0.45) {
const k = Math.floor(rng() * 5);
if (k === 0) return null;
if (k === 1) return rng() < 0.5;
if (k === 2) return randNumber(rng);
return randString(rng);
}
const len = Math.floor(rng() * 5);
if (r < 0.72) return Array.from({ length: len }, () => randValue(rng, depth - 1));
const o = {};
for (let i = 0; i < len; i++) {
o[randString(rng)] = randValue(rng, depth - 1);
}
return o;
}
function reserialize(value, rng) {
const styles = [undefined, 1, 2, 4, '\t', ' \t'];
const indent = styles[Math.floor(rng() * styles.length)];
const body = JSON.stringify(value, null, indent);
const pad = () => ' \t\r\n'.repeat(Math.floor(rng() * 2));
return pad() + body + pad();
}
// Canary: a two-byte string value.
// The second key is the JSON escape "\\r", which should decode to CR / U+000D.
const CANARY = '{"\\f":"中","\\r":"x"}';
const canaryBad = () => {
const keys = Object.keys(JSON.parse(CANARY));
return keys[1].charCodeAt(0) !== 0x0d;
};
const ITERS = Number(process.argv[2]) || 2000;
console.log('node:', process.version);
console.log('v8:', process.versions.v8);
console.log('pool len:', [...POOL].length, '| cold canary OK:', !canaryBad());
const rng = mulberry32(305419896);
let firstBadAt = -1;
for (let n = 0; n < ITERS; n++) {
JSON.parse(reserialize(randValue(rng, 1 + Math.floor(rng() * 5)), rng));
if (firstBadAt < 0 && canaryBad()) {
firstBadAt = n;
}
}
console.log(`after ${ITERS} JSON.parse-only iters: canary bad = ${canaryBad()}, first bad at iter = ${firstBadAt}`);
const keys = Object.keys(JSON.parse(CANARY));
console.log('final canary keys:');
for (const key of keys) {
console.log(JSON.stringify(key), [...key].map(ch => ch.charCodeAt(0).toString(16).padStart(2, '0')));
}Output
>node wtf-gpt.mjs
node: v24.16.0
v8: 13.6.233.17-node.49
pool len: 16 | cold canary OK: true
after 2000 JSON.parse-only iters: canary bad = true, first bad at iter = 104
final canary keys:
"\f" [ '0c' ]
"\\" [ '5c' ]
How often does it reproduce? Is there a required condition?
Always reproducible with node v24.13.1 and v24.16.0 (Windows and Linux x64).
NOT reproducible with node v16.17.0, v20.14.0 and v22.16.0 (Linux x64).
What is the expected behavior? Why is that the expected behavior?
The 2nd key of parsed object is always 0x0A or other expected value (e.g. '\r') no matter how many times we call JSON.parse with whatever argument.
isStateBroken always returns false.
What do you see instead?
The 2nd key become 0x5C (or other values? all I see is 0x5C for now) after passing the trigger sequence to JSON.parse.
And isStateBroken returns true.
Additional information
No response
Activity
3zrv commentedon Jun 8, 2026
This is a V8 bug, not a node bug, reported here: https://issues.chromium.org/issues/521080746
xWTF commentedon Jun 8, 2026
Okay, thanks for help in forwarding this to V8 devs, I'll close this issue when that issue is sorted out.
docs(test): cite the upstream V8/Node issues for the JSON.parse escap…
Stvad commentedon Jul 17, 2026
Confirming this reproduces on Node 26.5.0 (V8 14.6.202.34) as well as Node 24.15.0 (V8 13.6.233.17), macOS arm64 — the minimal snippet here prints
BROKENon both, so it's not fixed on the current V8 line.I hit the same bug independently via property-based fuzzing and filed #64546 (now closed as a duplicate of this). A
"-key variant reduces to the same trigger/canary structure:Same signature as the
\r/\f/\ncases — an escaped key decoding to\after the cache is poisoned.Merge pull request #399 from Stvad/claude/fuzz-quarantine-repoint-63785
docs(fuzz): re-point V8 quarantine to canonical upstream nodejs/node#…
ahzf commentedon Aug 20, 2026
Still reproduces on Node v26.3.0 (V8 14.6.202.34-node.20), Windows x64. Found independently through property-based testing — a round-trip property that looked "flaky" for two days, until the ground under it turned out to be the problem. A third sighting, for whatever that is worth to prioritisation.
Two narrowings I did not find stated above, both using the trigger/canary shape from this issue.
Only a key ending in
\\poisons. Every other trailing escape is harmless as the poison, and every trailing escape is vulnerable as the canary:POISON= the canary's second key came back as the poison object's second key. The top-left cell readsokonly because there the poison key happens to be the right answer anyway.The escape has to be last in the key. What precedes it does not matter.
So it is neither a length bound nor a one-character-key effect: what is lost is the final escaped character, and only ever in a key — the same escape in a value is always read correctly.
Self-contained, no dependencies. Each case uses its own first key, so the cases cannot disturb one another:
Investigation AI-assisted; every claim above is one
node repro.mjsaway from being checked or refuted.Report it upstream, and correct what we had written
Correct section 16, and say where the report went
ahzf commentedon Aug 21, 2026
A version sweep, since the thread has single data points rather than a range. The reduced trigger above, run as a CI matrix on
ubuntu-latestx64, one fresh process per version:Two things follow. It is not fixed on the current line — v26.7.0 is the newest release as I write this. And the regression falls between V8 12.4 and 13.6, which brackets it to the 22 → 24 jump rather than to anything recent.
That agrees with the original report here (Windows and Linux on 24.16.0, not reproducible on 20 or 22) and with the macOS arm64 confirmations on 24.15.0 and 26.5.0, now from a third platform.
"5 of 36" is the poison × canary matrix from my previous comment: of the six trailing escapes, five come back as the poisoning key and the sixth is the
\\that does the poisoning, where the wrong answer happens to be the right one.This runs nightly in our repository against 20, 22, 24 and latest, and reports rather than fails — there is nothing on our side to fix. So a version that stops losing keys will turn up in that matrix, and I will say so here.
test(repo): keep worker-options law keys clear of the V8 JSON key-cac…
fix(repo): repair the vm runner and config subpath after 11.0.0 (#110)
test(maximal-cli): avoid V8 escaped-key JSON.parse bug in property test
test(maximal-cli): avoid V8 escaped-key JSON.parse bug in property test
test(maximal-cli): avoid V8 escaped-key JSON.parse bug in property te…
panos-dim commentedon Oct 5, 2026
V8 has fixed this on main: v8/v8@93cd21e (2026-09-17, "skip fast transition path for escaped keys", crbug 521080746). Could it be backported to v24.x (V8 13.6) and v26.x (V8 14.6)?
The bug makes
JSON.parsea parser differential. Code that checks raw JSON text, or hands the same text to another parser, can disagree with Node about which value a duplicated key holds. Here is a two-line repro on 24.19.0, which printsuserwhere V8 12.4 (Node 22.22.3) printsadmin:node -e 'const B=String.fromCharCode(92); JSON.parse("{\"role\":\"x\",\"rol"+B+B+"\":0}"); console.log(JSON.parse("{\"role\":\"user\",\"rol"+B+"u0065\":\"admin\"}").role)'It still reproduces on the latest releases, 24.21.0 (V8 13.6.233.17-node.53) and 26.10.0 (V8 14.6.202.34-node.34), on macOS arm64. The V8 backports in 24.20.0 and 24.21.0 are #63865 and #64631, which are unrelated.
fix(e2e-core): key npm locks and manifests by npm names and lock paths
fix(stryker-js-vitest-runner): ship the Node 24 floor as a major and …
docs(solutions): state the V8 range the nodejs/node#63785 sweep measured
fix(stryker-js-vitest-runner): require Node 24 and document the V8 JS…
fix(e2e-core): read every manifest key past the V8 JSON.parse key bug
fix(e2e-core): refuse fixture-manifest keys JSON must escape (#306)
fix(stryker-js): read report file keys exactly on Node 24 and refuse …