Skip to content

[BUG] JSONReaderASCII decodes field names as UTF-8, corrupting nameLength — valid JSON crashes #7808

Description

@Namron2000

Version: 2.0.64 (reproduced against the published jar)
Classes: JSONReaderASCII, JSONReaderUTF8
Related: #3928 (closed, fixed) — same defect, fixed for the UTF-8 reader only

Valid JSON throws ArrayIndexOutOfBoundsException:

JSON.parse("[{\"\\t\u00e0\u00aa\u00ae\":1}]");   // 13 bytes: [{"\tમ":1}]
java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
    at com.alibaba.fastjson2.JSONReaderASCII.getFieldName(JSONReaderASCII.java:876)
    at com.alibaba.fastjson2.reader.ObjectReaderImplObject.readObject(ObjectReaderImplObject.java:169)
    at com.alibaba.fastjson2.JSONReader.read(JSONReader.java:3281)
    at com.alibaba.fastjson2.JSON.parse(JSON.java:138)

Three ingredients are needed, and removing any one makes it work:

input result
[{"\tમ":1}] AIOOBE
[{"\tabc":1}] parses — no bytes ≥ 0x80
[{"aમ":1}] parses — no escape
{"\tમ":1} parses — not inside an array

So: an object inside an array (which routes through ObjectReaderImplObject
readFieldNameHashCode() + getFieldName()), a field name with a backslash escape,
and a field name byte ≥ 0x80.

Cause

JSONReaderASCII treats the buffer as latin1 — one byte is one character — but it does
not define readFieldNameHashCode0(). It inherits JSONReaderUTF8's, which decodes
UTF-8 (JSONReaderUTF8.java:2700-2722):

if (ch >= 0) {
    offset++;
} else {
    switch (ch >> 4) {
        case 12: case 13:
            ch = char2_utf8(ch, bytes[offset + 1], offset);
            offset += 2;            // 2 bytes -> 1 character
            break;
        case 14:
            ch = char2_utf8(ch, bytes[offset + 1], bytes[offset + 2], offset);
            offset += 3;            // 3 bytes -> 1 character
            break;
        default:
            throw new JSONException("malformed input around byte " + offset);
    }
}

The counter that becomes nameLength (line 2694) therefore counts characters, while
the buffer is latin1. For the name \tમ, the bytes E0 AA AE are taken as one 3-byte
character, so nameLength = 2 for a name spanning 5 bytes.

JSONReaderASCII.getFieldName() then walks the same bytes as latin1
(JSONReaderASCII.java:828-877):

byte[] chars = new byte[nameLength];   // 2
for (int i = 0, end = this.end; offset < nameEnd; ++i) {
    ...
    chars[i] = b;                      // line 876 -> index 2 into a length-2 array
    offset++;
}

Instrumenting the two calls shows the disagreement directly:

readFieldNameHashCode -> nameBegin=3 nameEnd=8 nameLength=2 escape=true
getFieldName          -> ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2

nameEnd - nameBegin is 5 bytes; nameLength claims 2.

The escape is required because it forces this slow path — without a backslash,
JSONReaderASCII.readFieldNameHashCode() (line 156) uses name_len = index - start, a
byte count, which is correct for latin1.

The same cause also rejects valid JSON

When the high bytes do not form a valid UTF-8 sequence, the inherited decoder throws
rather than miscounting. Identical documents are then accepted at top level and rejected
inside an array:

document top level inside an array
{"\tà":1} parses JSONException: malformed input around byte 5
{"a\tbé":1} parses JSONException: malformed input around byte 7

This is the JSONReaderASCII half of #3928

#3928"Exception on parsing object
key with emoji and escape character"
— reported the same thing: a field name containing
an escape plus non-ASCII characters breaks name-length accounting and throws
JSONException: malformed input around byte 2. It was fixed by #3929 and closed on
2026-02-07.

That fix works, but only on the UTF-8 path. On 2.0.64:

// #3928's own test case -- now passes
Map<String,String> m = Map.of("\uD83D\uDE07\\", "");
JSON.parseObject(JSON.toJSONBytes(m, StandardCharsets.UTF_8), Map.class);   // OK

// same shape, latin1-backed String -> JSONReaderASCII -- still broken
JSON.parse("[{\"\\t\u00e0\u00aa\u00ae\":1}]");   // ArrayIndexOutOfBoundsException
JSON.parse("[{\"\\t\u00e0\":1}]");             // JSONException: malformed input

The reason is structural: JSONReaderASCII does not define readFieldNameHashCode0().
It inherits JSONReaderUTF8's, which decodes UTF-8, so the fix that taught that method
about 4-byte sequences never made the method correct for a latin1 buffer. Whatever it
counts, it is counting characters of a different encoding than the one
JSONReaderASCII.getFieldName() then walks.

Fix

A conservative fix for the crash — size the buffer from the byte span and trim to what
was written (escapes only ever shrink the output):

--- a/core/src/main/java/com/alibaba/fastjson2/JSONReaderASCII.java
+++ b/core/src/main/java/com/alibaba/fastjson2/JSONReaderASCII.java
@@ -825,9 +825,14 @@
         if (JDKUtils.STRING_CREATOR_JDK11 != null) {
-            byte[] chars = new byte[nameLength];
+            byte[] chars = new byte[nameEnd - nameBegin];
+            int i = 0;
             forStmt:
-            for (int i = 0, end = this.end; offset < nameEnd; ++i) {
+            for (int end = this.end; offset < nameEnd; ++i) {
@@ -878,7 +883,8 @@
             if (chars != null) {
-                return STRING_CREATOR_JDK11.apply(chars, LATIN1);
+                return STRING_CREATOR_JDK11.apply(
+                        i == chars.length ? chars : java.util.Arrays.copyOf(chars, i), LATIN1);
             }

Verified against 2.0.64: [{"\tમ":1}] now parses to [{"\tમ":1}], and these are
unchanged — [{"\tabc":1}], [{"aમ":1}], {"\tમ":1}, [{"a":1},{"b":2}],
[{"\u00e9":1}], [{"a\\b":1}], [{"a\"b":1}], [{"":1}], [{"\n\t\r":1}].

This fixes the crash only. The "malformed input" rejections above still happen,
because they come from the inherited UTF-8 decoder itself. The real fix is for
JSONReaderASCII to stop using UTF-8 name decoding — either an override of
readFieldNameHashCode0() for latin1, or a branch on the reader's encoding in the shared
code. That seemed like your call rather than something to force in a patch, so it is not
included.

Not a security issue

Recording this so it is not mistaken for one. The out-of-range access is a write index
(chars[i] = b), but the JVM bounds check stops it — no memory is corrupted or
disclosed. We also checked whether the UTF-8/latin1 split could make a key hash as one
name and stringify as another (which would allow binding to an unintended field): it does
not. A key of bytes C3 A9 does not bind to a field named é, and the map key comes
back as é. The mismatch corrupts a length, not an identity.

The practical impact is that valid JSON either throws an unchecked exception past a
caller's catch (JSONException), or is rejected as malformed.


Found by the CISPA Fandango Team

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions