nodejs / nodejs/node

JSON.parse returns unexpected keys after decoding specified JSON key

オープン
#63,785 コメント 5 件 リアクション 2 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

v8 engine
主要言語
JavaScript
スター
122k
フォーク
37.3k
平均マージ
4日 2時間
マージ済み PR(30日)
283

説明

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

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

まず Node 24.13.1 または 24.16.0 で最小再現スニペットを実行し、Node 20 または 22 と比較してから、エスケープシーケンスのデコードに関係する JSON.parse のランタイムパスを調査します。トリガーシーケンスをパースした後も canary の 2 番目のキーが期待される制御文字のままであり、報告されたバージョンと挙動をカバーする回帰テストがあれば完了です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
javascript, node.js
領域
backend
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
52/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。