fix: bound jsonb array item count to remaining input - #7737
fix: bound jsonb array item count to remaining input#7737Support-itecz wants to merge 3 commits into
Conversation
A declared BC_ARRAY/BC_BINARY item count larger than the bytes left in the input sized allocations directly and let element reads run past end.
|
|
wenshao
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
— qwen3.8-max-preview via Qwen Code /review
| @Test | ||
| public void testInt32ValueArrayItemCountOverflow() { | ||
| assertThrows(JSONException.class, () -> JSONB.parseObject(ARRAY_MAX_CNT, int[].class)); |
There was a problem hiding this comment.
[Suggestion] The BC_BINARY branch in startArray() (line 1438 of JSONReaderJSONB.java) has no test coverage. All 12 tests use BC_ARRAY (0xA4) inputs; none exercise BC_BINARY (0x91). The mutation "delete checkItemCnt from the BC_BINARY line" survives every test in this file. — Concrete cost: a future refactor that accidentally drops the guard on the BC_BINARY path would reintroduce the OOM vector for payloads like {0x91, 0x48, 0x7F, 0xFF, 0xFF, 0xFF} with no test to catch it.
| @Test | |
| public void testInt32ValueArrayItemCountOverflow() { | |
| assertThrows(JSONException.class, () -> JSONB.parseObject(ARRAY_MAX_CNT, int[].class)); | |
| @Test | |
| public void testInt32ValueArrayItemCountOverflow() { | |
| assertThrows(JSONException.class, () -> JSONB.parseObject(ARRAY_MAX_CNT, int[].class)); | |
| } | |
| @Test | |
| public void testBinaryArrayItemCountOverflow() { | |
| // 0x91 = BC_BINARY, 0x48 = BC_INT32, 0x7FFFFFFF = Integer.MAX_VALUE | |
| byte[] binaryMaxCnt = { | |
| (byte) 0x91, | |
| (byte) 0x48, | |
| (byte) 0x7F, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF | |
| }; | |
| assertThrows(JSONException.class, () -> JSONB.parseObject(binaryMaxCnt, int[].class)); | |
| } |
— qwen3.8-max-preview via Qwen Code /review
There was a problem hiding this comment.
Good catch. Added testBinaryArrayItemCountOverflow with the 0x91 payload; int[].class routes through the BC_BINARY branch in startArray(), so dropping that guard now fails the test instead of surviving.
| private int checkItemCnt(int itemCnt) { | ||
| if (itemCnt < 0 || itemCnt > end - offset) { | ||
| throw new JSONException("array item count out of range: " + itemCnt + ", available: " + (end - offset)); |
There was a problem hiding this comment.
[Suggestion] No test exercises the boundary itemCnt == end - offset. The off-by-one mutation > → >= survives all 12 tests and would silently reject valid arrays whose declared count exactly equals the remaining bytes. — Concrete cost: a future "simplification" of this condition would break parsing of tightly-packed single-byte-element arrays (e.g. {0xA4, 0x03, 0x01, 0x02, 0x03}) with no test to catch it.
Consider adding a boundary test:
@Test
public void testItemCountExactlyMatchesRemainingBytes() {
// BC_ARRAY, count=3 (single-byte int 0x03), three 1-byte items
byte[] buffer = {
(byte) 0xA4, 0x03,
0x01, 0x02, 0x03
};
assertArrayEquals(new int[]{1, 2, 3}, JSONB.parseObject(buffer, int[].class));
}— qwen3.8-max-preview via Qwen Code /review
There was a problem hiding this comment.
Added as testItemCountExactlyMatchesRemainingBytes, so the > vs >= boundary is pinned and a tightly packed array still parses. All 14 tests pass locally.
wenshao
left a comment
There was a problem hiding this comment.
[Critical] R1-1 skipValue()'s BC_ARRAY branch (line 2559) reads array item count via bare readInt32Value() without checkItemCnt, leaving the exact vulnerability class this PR fixes (OOM/OOB read from malformed count) reachable through any bean deserialization path that skips an unrecognized field containing an array. Probe confirmed: AIOOBE on unmodified code, clean JSONException with the one-line fix. Suggested fix: itemCnt = checkItemCnt(readInt32Value());
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| ? checkItemCnt(readLength()) | ||
| : valueType - BC_ARRAY_FIX_MIN; |
There was a problem hiding this comment.
[Suggestion] No error-path test covers this checkItemCnt call site (the nested-array element path inside readArray()). All other four call sites have dedicated overflow tests. — Concrete cost: if a future refactor accidentally drops checkItemCnt from this site only, new JSONArray(0x0FFFFFFF) at line 1107 would attempt a ~268M-element allocation (OOM), and no test in this PR would fail.
Suggested test: outer BC_ARRAY with count 2, first element a small int (0x01), second element BC_ARRAY + BC_INT32 + 0x0FFFFFFF. Assert JSONException.
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
There was a problem hiding this comment.
Added as testNestedArrayItemCountOverflow. One note: with a List.class target the elements route through readAny(), which has its own guard, so the test goes through JSONB.parseArray to actually land on this branch. Confirmed via stack trace that the exception comes from the readArray() site.
| len = itemType; | ||
| } else { | ||
| len = readLength(); | ||
| len = checkItemCnt(readLength()); |
There was a problem hiding this comment.
[Suggestion] No error-path test covers this checkItemCnt call site (the readObject() field-value branch). testReadObjectItemCountOverflow uses Map.class, which routes through ObjectReaderImplMap → startArray() (line 1445) — it does not reach this line. — Concrete cost: if a future refactor drops checkItemCnt here, JSONB.parseObject(bytes) (the no-type overload) with a malformed object reaches new JSONArray(0x0FFFFFFF) → OOM, and no test catches it. Probe confirmed: removing the guard flips from pass to OutOfMemoryError.
Suggested test: assertThrows(JSONException.class, () -> JSONB.parseObject(OBJECT_ARRAY_LARGE_CNT));
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
There was a problem hiding this comment.
Added as testReadObjectNoTypeItemCountOverflow using the no-type overload. Stack trace confirms it lands on the field-value branch here rather than startArray().
|
Good catch on skipValue(). Its BC_ARRAY branch now runs the count through checkItemCnt() as well, and testSkipValueItemCountOverflow covers it through a bean with an unknown field holding a truncated array. Without the guard that path reproduces the AIOOBE you describe, with it a clean JSONException. Core suite (7964 tests) and checkstyle both pass. |
What this PR does / why we need it?
JSONReaderJSONBsizes array allocations from the item count on the wire without checking it against the input.startArray()returns theBC_ARRAY/BC_BINARYcount fromreadInt32Value()unbounded, andreadObject(),readAny()andreadArray()each size aJSONArray/ArrayListfrom aBC_ARRAYlength. The 6 byte payloadA4 48 7F FF FF FFdeclaresInteger.MAX_VALUEitems, soJSONB.parseObject(payload, long[].class)reachesnew long[Integer.MAX_VALUE]and dies withOutOfMemoryError; every primitive, boxed and object array reader behaves the same, and0x0FFFFFFFstays under the 256MB cap inreadLength()while doing the same throughJSONB.parseandList/Maptargets.The count is also what keeps element reads inside the message. Under
JSONB.parseObject(byte[], off, len, type)the reader'sendis the frame boundary, not the end of the buffer, so the two byte frameA4 08declaring eight items runs the item loop pastendand returns the eight bytes that follow it. For a framed protocol decoding messages out of a shared or pooled buffer, that hands the caller data belonging to the neighboring frame.Summary of your change
A JSONB item always costs at least one byte, so a count larger than the bytes left in the input is malformed by construction.
checkItemCnt()rejects counts that are negative or larger thanend - offset, applied at the four places a count comes off the wire, which also covers the array readers that reach it throughstartArray().The bound sits in the reader rather than in each
ObjectReaderImpl*Arraybecause that is whereendis known, and it follows the existingcheckBigintLenguard.JSONBArrayItemCountTestcovers the four paths, the read pastend, and valid arrays including a 100k element one; without the fix it exhausts the forked test JVM, with it the core suite stays green.Please indicate you've done the following: