alibaba / alibaba/fastjson2

[BUG] `ArrayIndexOutOfBoundsException` on a truncated exponent — `JSON.parse("1e")`

Open
#7,807 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Java
Stars
4.4k
Forks
613
Avg merge
1d 22h
Merged PRs (30d)
6

Description

**Version:** 2.0.64 (current release, reproduced against the published jar)
**Classes:** `com.alibaba.fastjson2.JSONReaderUTF8`, `com.alibaba.fastjson2.JSONReaderUTF16`
**Related:** #3883 (closed, fixed in 2.0.61) — this is the same method, three reads it missed
**Related:** #7790 (open) — same class of defect, different method

Parsing a JSON document that ends inside a number's exponent throws
`ArrayIndexOutOfBoundsException` instead of `JSONException`.

```java
JSON.parse("1e");
```

```
java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
at com.alibaba.fastjson2.JSONReaderUTF8.readNumber0(JSONReaderUTF8.java:5487)
at com.alibaba.fastjson2.JSONReader.readNumber(JSONReader.java:2259)
at com.alibaba.fastjson2.JSONReader.read(JSONReader.java:3531)
at com.alibaba.fastjson2.JSON.parse(JSON.java:138)
```

Two characters are enough. All of these throw the same way:

| input | line |
|---|---|
| `1e`, `1E`, `-2.2e`, `[1e`, `{"a":1e` | `JSONReaderUTF8.java:5487` |
| `1e-` | `JSONReaderUTF8.java:5491` |
| `1e+` | `JSONReaderUTF8.java:5493` |
| `["中",1e` (UTF16-backed input) | `JSONReaderUTF16.java:4008` |

This matters because `ArrayIndexOutOfBoundsException` is not something a caller can be
expected to catch. The documented contract for malformed input is `JSONException`, so
code written as

```java
try { JSON.parse(untrusted); } catch (JSONException e) { /* reject */ }
```

is bypassed, and the exception propagates as an unchecked runtime error.

## Cause

`readNumber0()` reads the character after `e`/`E` without checking whether the buffer
still has one — `JSONReaderUTF8.java:5484-5494`:

```java
if (ch == 'e' || ch == 'E') {
boolean negativeExp = false;
int expValue = 0;
ch = bytes[offset++]; // 5487 -- no bounds check

if (ch == '-') {
negativeExp = true;
ch = bytes[offset++]; // 5491 -- no bounds check
} else if (ch == '+') {
ch = (char) bytes[offset++]; // 5493 -- no bounds check
}
```

Every other read in the same method guards first — the pattern appears **16 times** in
`readNumber0()` alone, including in the exponent's own digit loop eleven lines below at
5504:

```java
if (offset == end) {
ch = EOI;
break;
}
ch = bytes[offset++];
```

So the three reads above are the only ones in this method that skip the check.

## Fix

```diff
--- a/core/src/main/java/com/alibaba/fastjson2/JSONReaderUTF8.java
+++ b/core/src/main/java/com/alibaba/fastjson2/JSONReaderUTF8.java
@@ -5484,13 +5484,13 @@
if (ch == 'e' || ch == 'E') {
boolean negativeExp = false;
int expValue = 0;
- ch = bytes[offset++];
+ ch = offset == end ? EOI : (char) bytes[offset++];

if (ch == '-') {
negativeExp = true;
- ch = bytes[offset++];
+ ch = offset == end ? EOI : (char) bytes[offset++];
} else if (ch == '+') {
- ch = (char) bytes[offset++];
+ ch = offset == end ? EOI : (char) bytes[offset++];
}
```

`offset == end ? EOI : bytes[offset++]` is the idiom already used at
`JSONReaderUTF8.java:5535` and elsewhere in this class.

`JSONReaderUTF16.readNumber0()` carries the identical omission at lines 4008, 4012 and
4014 — character for character the same block, over `chars[]` instead of `bytes[]` — and
`fix.patch` fixes both. `JSONReaderASCII` extends `JSONReaderUTF8` and inherits the fix.

### Deliberately not adding strictness

A digit-less exponent that is *not* truncated is already accepted today:

```java
JSON.parse("{\"a\":1e}"); // -> {"a":1}
JSON.parse("[1e+]"); // -> [1]
```

The patch keeps that behaviour — with it, `1e` parses to `1`, matching `[1e]` → `[1]`.
Making a digit-less exponent an error would be a defensible separate change, but it is a
behaviour change rather than a bug fix, so it is left out here.

## Verification

Built the patched class against the 2.0.64 jar and ran both.

Fixed:

| input | before | after |
|---|---|---|
| `1e`, `1E` | AIOOBE at 5487 | `1` |
| `-2.2e` | AIOOBE at 5487 | `-2.2` |
| `1e-` | AIOOBE at 5491 | `1` |
| `1e+` | AIOOBE at 5493 | `1` |
| `[1e`, `{"a":1e` | AIOOBE at 5487 | `JSONException` (unterminated) |

Unchanged:

| input | before and after |
|---|---|
| `{"a":1e}` | `{"a":1}` |
| `[1e]`, `[1e+]`, `[1e-]` | `[1]` |
| `[1e5]` | `[100000.0]` |
| `1.5e-3` | `0.0015` |
| `1e309` | `Infinity` |
| `123456789012345678901234567890` | unchanged |
| `[1,2,3]`, `{"a":1}` | unchanged |

## This is what #3883 missed

[#3883](https://github.com/alibaba/fastjson2/issues/3883) — *"Multiple
ArrayIndexOutOfBoundsException in JSONReaderUTF8.java, JSONReaderASCII.java,
JSONReader.java"*, also found by fuzzing — was closed on 2026-02-07 as fixed in 2.0.61.
It reported the same defect in **the same method**: `readNumber0()` reading past the end
of a truncated document, triggered by `JSON.parse("\0.")`.

That fix works. On 2.0.64:

```java
JSON.parse("\0."); // JSONException at readNumber0:5441 <- #3883, fixed
JSON.parse("1e"); // ArrayIndexOutOfBoundsException at readNumber0:5487 <- still broken
```

The guard was added to the reads around the decimal point, but the three reads that
consume the character after `e`/`E` — lines 5487, 5491 and 5493 of the same method —
were not touched. So this is not a new defect so much as the remainder of #3883.

**One case from #3883 is also still open.** Its comment thread reports
`JSON.parse("[[N")`, which was never fixed and still throws on 2.0.64:

```
java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
at com.alibaba.fastjson2.JSONReaderUTF8.readNaN(JSONReaderUTF8.java:6006)
```

Since #3883 is closed, that one is no longer tracked anywhere.

## Relationship to issue #7790

https://github.com/alibaba/fastjson2/issues/7790 (open, 2.0.64) reports
`ArrayIndexOutOfBoundsException` from `JSONReaderUTF16.readString()` at line 3287 on a
truncated string escape (`"\`). **Same class of defect, different site — not a
duplicate:**

| | #7790 | this issue |
|---|---|---|
| reader | `JSONReaderUTF16` | `JSONReaderUTF8` (and `JSONReaderUTF16`) |
| method | `readString()` | `readNumber0()` |
| line | 3287 | 5487 / 5491 / 5493 (UTF8), 4008 / 4012 / 4014 (UTF16) |
| trigger | input ends after `\` in a string | input ends after `e`/`E` in a number |

Both were confirmed against 2.0.64 here, and each is unaffected by the other's fix.

Two things worth noting for #7790's framing. Its report says "the UTF8 reader correctly
throws JSONException for the same input", suggesting UTF16 is the outlier — but the
truncated-exponent case shows **the UTF8 reader has the same defect in a different
method**, so this is a pattern across readers rather than one reader lagging behind.

Also, reproducing #7790 from a Java `String` requires the string to be UTF16-backed. An
ASCII-only `String` is latin1-backed on modern JVMs, so `JSON.parse("\"\\")` takes the
UTF8/ASCII path and correctly throws `JSONException`; adding a non-latin1 character
(`JSON.parse("\"\u4e2d\\")`) routes it to `JSONReaderUTF16` and reproduces the AIOOBE.
That may explain any difficulty reproducing it.

## The same omission exists at several other sites

Fuzzing this parser produced `ArrayIndexOutOfBoundsException` at six further places. Each
was delta-debug minimised to the shortest input that keeps the exact same crash line.
**Every one is a truncated document** — the input stops where the parser assumed another
character — so they look like the same missing bounds check in different methods:

| crash site | minimal reproducer | what is truncated |
|---|---|---|
| `JSONReaderUTF8:5979` | `{"":[false],"":[true],"":[""n` | a `null` / `true` literal |
| `JSONReaderASCII:982` | `{"":"","":null,"":{R{"": ` | stops after `": ` |
| `JSONReaderASCII:1401` | `{"":[],"":"\\\"\` | a string escape |
| `JSONReaderASCII:949` | `{"":7,"":8,"":{"\` | a string escape |
| `JSONReaderASCII:876` | `[{\"\\t\u00e0\u00aa\u00ae\":` (Java literal) | stops after `:` |
| `JSONReader:1342` | (not reproduced through `JSON.parse(String)`) | — |

Each reproducer above was re-run against 2.0.64 and confirmed to produce the crash line
shown. The patch in this issue fixes only the exponent case; the rest still throw.

Note that the two `JSONReaderASCII` escape cases (`:949`, `:1401`) are the **same trigger
as #7790** — a string ending in a lone `\` — just in a different reader.

Counting #7790, that is at least **seven** places where the guarded
`offset == end ? EOI : buf[offset++]` idiom is missing. Rather than filing these
one at a time, a sweep of the unguarded `bytes[offset++]` / `chars[offset++]` reads in
`JSONReaderUTF8`, `JSONReaderASCII` and `JSONReaderUTF16` would likely close all of them
at once.
---
Found by the CISPA Fandango Team

Contributor guide

Open the contributing guide

Research direction

Start with JSON.parse and readNumber0() in core/src/main/java/com/alibaba/fastjson2/JSONReaderUTF8.java, then compare the corresponding block in JSONReaderUTF16.java. Reproduce the listed truncated-exponent inputs on 2.0.64 and verify that UTF8 and UTF16 no longer throw ArrayIndexOutOfBoundsException while the documented parsing outcomes remain unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.