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
Version: 2.0.64 (reproduced against the published jar)
Classes:
JSONReaderASCII,JSONReaderUTF8Related: #3928 (closed, fixed) — same defect, fixed for the UTF-8 reader only
Valid JSON throws
ArrayIndexOutOfBoundsException:Three ingredients are needed, and removing any one makes it work:
[{"\tમ":1}][{"\tabc":1}][{"aમ":1}]{"\tમ":1}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
JSONReaderASCIItreats the buffer as latin1 — one byte is one character — but it doesnot define
readFieldNameHashCode0(). It inheritsJSONReaderUTF8's, which decodesUTF-8 (
JSONReaderUTF8.java:2700-2722):The counter that becomes
nameLength(line 2694) therefore counts characters, whilethe buffer is latin1. For the name
\tમ, the bytesE0 AA AEare taken as one 3-bytecharacter, so
nameLength = 2for a name spanning 5 bytes.JSONReaderASCII.getFieldName()then walks the same bytes as latin1(
JSONReaderASCII.java:828-877):Instrumenting the two calls shows the disagreement directly:
nameEnd - nameBeginis 5 bytes;nameLengthclaims 2.The escape is required because it forces this slow path — without a backslash,
JSONReaderASCII.readFieldNameHashCode()(line 156) usesname_len = index - start, abyte 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:
{"\tà":1}JSONException: malformed input around byte 5{"a\tbé":1}JSONException: malformed input around byte 7This is the
JSONReaderASCIIhalf 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 on2026-02-07.
That fix works, but only on the UTF-8 path. On 2.0.64:
The reason is structural:
JSONReaderASCIIdoes not definereadFieldNameHashCode0().It inherits
JSONReaderUTF8's, which decodes UTF-8, so the fix that taught that methodabout 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):
Verified against 2.0.64:
[{"\tમ":1}]now parses to[{"\tમ":1}], and these areunchanged —
[{"\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
JSONReaderASCIIto stop using UTF-8 name decoding — either an override ofreadFieldNameHashCode0()for latin1, or a branch on the reader's encoding in the sharedcode. 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 ordisclosed. 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 A9does not bind to a field namedé, and the map key comesback 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