CVE-2026-85047
Overview
Background
- Bling / iOS Autofill
- Chrome’s iOS Autofill stack, where injected JavaScript serializes form structure into a JSON dictionary that the browser-side native code (
autofill_util.mm) parses into C++ objects. - `FrameTokenWithPredecessor`
- a struct pairing a child frame’s
RemoteFrameTokenwith apredecessorindex that records where the child frame sits relative to the form’s field ordering. - `predecessor`
- the ordinal position of the last form field preceding a given child frame, used downstream to interleave child frames with fields; the sentinel
-1means “before any field.” - `ExtractChildFrames` / `ExtractRemoteFrameToken`
- helpers that read the renderer-supplied
child_frameslist out of the form JSON and populate thechild_framesvector onFormData.
Root Cause Analysis
The renderer-controlled child_frames list was parsed by ExtractRemoteFrameToken, which read predecessor as a raw double via FindDouble("predecessor") and copied it straight into token_with_predecessor->predecessor with no range or sign checking. The inline extraction loop in the include_frame_metadata branch then pushed every successfully parsed entry into the child_frames vector without verifying any ordering relationship among the predecessor values. Because the JSON originates from JavaScript in the renderer, a malicious or compromised page could supply arbitrary predecessor values — large positive numbers, negative numbers other than the -1 sentinel, or values that are not monotonically increasing — violating the invariant that predecessors are non-negative-or--1 and appear in ascending order, which downstream consumers rely on when using predecessor as a field index (inferred).
The fix reworks ExtractRemoteFrameToken to saturated_cast the value to int and reject anything below -1, and adds ExtractChildFrames, which clears the entire vector via std::ranges::is_sorted if the predecessors are not in ascending order. This restores the invariant before the data reaches any indexing logic, and the accompanying tests pin -1 as the only permitted negative value and require sorted order.
predecessor indices verbatim — accepting any double with no bounds and no ordering guarantee. The fix enforces both a lower bound (>= -1) per entry and a global ascending-order invariant across the list, rejecting malformed input at the parsing boundary.Attack Path
- Craft malicious form JSON
A malicious page (or compromised renderer) serves a form whose injected-JS extraction produces a
child_frameslist with attacker-chosenpredecessorvalues, e.g. negative numbers below-1, out-of-range magnitudes, or values in non-ascending order. - Browser parses unchecked
On the pre-patch path,
ExtractRemoteFrameTokencopies eachpredecessordouble directly and the extraction loop appends every entry, so the crafted values land unmodified in theFormDatachild_framesvector. - Invariant broken for downstream indexing
Downstream Autofill code that treats
predecessoras an index into the form’s field list now operates on out-of-range or unsorted indices, breaking its ordering assumption (inferred). - Improper input validation triggers memory/logic error The out-of-range index is used as a field-list position, leading to an out-of-bounds access or corrupted frame/field association (inferred), consistent with the advisory’s “improper input validation” classification.
Impact Assessment
predecessor is (inferred) and not demonstrated by the patch itself. Preconditions are that the victim interacts with an attacker-influenced form on the affected iOS build with frame metadata extraction enabled.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/autofill/ios/browser/autofill_util.mm |
modified | |
forcomponents/autofill/ios/browser/autofill_util.mm |
modified | |
TEST_Fcomponents/autofill/ios/browser/autofill_util_unittest.mm |
modified |
Files Changed
components/autofill/ios/browser/autofill_util.hcomponents/autofill/ios/browser/autofill_util_unittest.mmcomponents/autofill/ios/browser/autofill_util.mm
Audit Directions
- Renderer-supplied indices used without boundsLook for other Autofill/iOS JSON fields (offsets, indices, counts) that are copied from
FindDouble/FindIntinto C++ state with no range or sign validation before being used to index containers. - Per-element validation without collection-level invariantsFlag parsers that validate each list element in isolation but never check cross-element invariants (ordering, uniqueness, monotonicity) the consumers depend on.
- Sentinel-value handlingAudit code that uses a special negative sentinel (like
-1) alongside otherwise non-negative indices to ensure only the exact sentinel, not arbitrary negatives, is accepted.
Patch
From 57bfec56176222bcdf18fbf64d0c1c34489ef064 Mon Sep 17 00:00:00 2001 From: Christoph Schwering <[email protected]> Date: Tue, 30 Jun 2026 14:39:30 -0700 Subject: [PATCH] [Autofill] Validate child frame predecessor index on Bling Bug: 513790581 Change-Id: If56c123329c002de88317915b7aba32e0337e27a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8004591 Reviewed-by: Vincent Boisselle <[email protected]> Commit-Queue: Christoph Schwering <[email protected]> Cr-Commit-Position: refs/heads/main@{#1655061} --- diff --git a/components/autofill/ios/browser/autofill_util.h b/components/autofill/ios/browser/autofill_util.h index 9e3fd331..eff9c0f0 100644 --- a/components/autofill/ios/browser/autofill_util.h +++ b/components/autofill/ios/browser/autofill_util.h @@ -134,12 +134,6 @@ const FieldDataManager& field_data_manager, FormFieldData* field_data); -// Extracts a single child frame's data from the JSON dictionary into a -// FrameTokenWithPredecessor object. Returns false if the data could not be -// extracted. -bool ExtractRemoteFrameToken(const base::DictValue& frame_data, - FrameTokenWithPredecessor* token_with_predecessor); - typedef base::OnceCallback<void(const base::Value*)> JavaScriptResultCallback; // Creates a callback for a string JS function return type. @@ -196,6 +190,13 @@ // works. web::WebFramesManager* GetWebFramesManagerForAutofill(web::WebState* web_state); +std::vector<FrameTokenWithPredecessor> ExtractChildFramesForTest( + const base::DictValue& form); + +bool ExtractRemoteFrameTokenForTest( + const base::DictValue& frame_data, + FrameTokenWithPredecessor* token_with_predecessor); + } // namespace autofill #endif // COMPONENTS_AUTOFILL_IOS_BROWSER_AUTOFILL_UTIL_H_ diff --git a/components/autofill/ios/browser/autofill_util.mm b/components/autofill/ios/browser/autofill_util.mm index 3588dca..b3065cf 100644 --- a/components/autofill/ios/browser/autofill_util.mm +++ b/components/autofill/ios/browser/autofill_util.mm @@ -70,6 +70,59 @@ std::move(callback).Run(result); } +// Extracts a single child frame's data from the JSON dictionary into a +// FrameTokenWithPredecessor object. Returns false if the data could not be +// extracted. +bool ExtractRemoteFrameToken( + const base::DictValue& frame_data, + FrameTokenWithPredecessor* token_with_predecessor) { + const std::string* frame_id = frame_data.FindString("token"); + if (!frame_id) { + return false; + } + + std::optional<base::UnguessableToken> token = + DeserializeJavaScriptFrameId(*frame_id); + if (!token) { + return false; + } + + const std::optional<int> predecessor = + frame_data.FindDouble("predecessor").transform([](double x) { + return base::saturated_cast<int>(x); + }); + if (!predecessor || *predecessor < -1) { + return false; + } + + token_with_predecessor->token = RemoteFrameToken(*token); + token_with_predecessor->predecessor = *predecessor; + return true; +} + +// Extracts the child frames from the JSON dictionary. Returns an empty vector +// if the data could not be extracted. +std::vector<FrameTokenWithPredecessor> ExtractChildFrames( + const base::DictValue& form) { + std::vector<FrameTokenWithPredecessor> child_frames; + if (const base::ListValue* child_frames_list = + form.FindList("child_frames")) { + for (const auto& frame_dict : *child_frames_list) { + if (FrameTokenWithPredecessor token; + frame_dict.is_dict() && + ExtractRemoteFrameToken(frame_dict.GetDict(), &token)) { + child_frames.push_back(std::move(token)); + } + } + } + // Validate that the child frames occur in ascending order. + if (!std::ranges::is_sorted(child_frames, {}, + &FrameTokenWithPredecessor::predecessor)) { + child_frames.clear(); + } + return child_frames; +} + } // namespace bool IsContextSecureForWebState(web::WebState* web_state) { @@ -257,19 +310,7 @@ } if (include_frame_metadata) { - // Child frame tokens, optional. - if (const base::ListValue* child_frames_list = - form.FindList("child_frames")) { - std::vector<FrameTokenWithPredecessor> child_frames; - for (const auto& frame_dict : *child_frames_list) { - FrameTokenWithPredecessor token; - if (frame_dict.is_dict() && - ExtractRemoteFrameToken(frame_dict.GetDict(), &token)) { - child_frames.push_back(std::move(token)); - } - } - form_data.set_child_frames(std::move(child_frames)); - } + form_data.set_child_frames(ExtractChildFrames(form)); } // Field list (mandatory) is extracted. @@ -436,31 +477,6 @@ return true; } -bool ExtractRemoteFrameToken( - const base::DictValue& frame_data, - FrameTokenWithPredecessor* token_with_predecessor) { - const std::string* frame_id = frame_data.FindString("token"); - if (!frame_id) { - return false; - } - - std::optional<base::UnguessableToken> token = - DeserializeJavaScriptFrameId(*frame_id); - if (!token) { - return false; - } - - const std::optional<double> predecessor = - frame_data.FindDouble("predecessor"); - if (!predecessor) { - return false; - } - - token_with_predecessor->token = RemoteFrameToken(*token); - token_with_predecessor->predecessor = *predecessor; - return true; -} - JavaScriptResultCallback CreateStringCallback( void (^completionHandler)(NSString*)) { return CreateStringCallback(base::BindOnce(completionHandler)); @@ -531,4 +547,15 @@ ContentWorldForAutofillJavascriptFeatures()); } +std::vector<FrameTokenWithPredecessor> ExtractChildFramesForTest( // IN-TEST + const base::DictValue& form) { + return ExtractChildFrames(form); +} + +bool ExtractRemoteFrameTokenForTest( // IN-TEST + const base::DictValue& frame_data, + FrameTokenWithPredecessor* token_with_predecessor) { + return ExtractRemoteFrameToken(frame_data, token_with_predecessor); +} + } // namespace autofill diff --git a/components/autofill/ios/browser/autofill_util_unittest.mm b/components/autofill/ios/browser/autofill_util_unittest.mm index 6dfb3af0..019909b 100644 --- a/components/autofill/ios/browser/autofill_util_unittest.mm +++ b/components/autofill/ios/browser/autofill_util_unittest.mm @@ -16,6 +16,8 @@ #import "components/autofill/core/common/form_field_data.h" #import "components/autofill/core/common/unique_ids.h" #import "components/autofill/ios/common/features.h" +#import "testing/gmock/include/gmock/gmock.h" +#import "testing/gtest/include/gtest/gtest.h" #import "testing/platform_test.h" #import "url/gurl.h" #import "url/origin.h" @@ -30,6 +32,8 @@ using ::autofill::ExtractIDs; using ::autofill::FieldRendererId; using ::base::ASCIIToUTF16; +using ::testing::IsEmpty; +using ::testing::SizeIs; TEST_F(AutofillUtilTest, ExtractFormData_FullUrl) { base::test::ScopedFeatureList scoped_feature_list; @@ -179,17 +183,16 @@ EXPECT_FALSE(token.has_value()); } -// Test that the properties mask is extracted from the form field data. +// Test that the child frames is extracted from the form field data. TEST_F(AutofillUtilTest, ExtractRemoteFrameToken) { - base::DictValue remote_frame_token_dict; - remote_frame_token_dict.Set("token", - base::Value("beefbeefbeefbeefcafecafecafecafe")); - remote_frame_token_dict.Set("predecessor", base::Value(64)); + base::DictValue wellformed1; + wellformed1.Set("token", base::Value("beefbeefbeefbeefcafecafecafecafe")); + wellformed1.Set("predecessor", base::Value(64)); autofill::FrameTokenWithPredecessor token_with_predecessor; - ASSERT_TRUE(ExtractRemoteFrameToken(remote_frame_token_dict, - &token_with_predecessor)); + ASSERT_TRUE( + ExtractRemoteFrameTokenForTest(wellformed1, &token_with_predecessor)); EXPECT_EQ(base::ToLowerASCII(std::get<autofill::RemoteFrameToken>( token_with_predecessor.token) .ToString()), @@ -198,16 +201,78 @@ base::DictValue malformed1; malformed1.Set("garbage", base::Value("garbage")); - EXPECT_FALSE(ExtractRemoteFrameToken(malformed1, &token_with_predecessor)); + EXPECT_FALSE( + ExtractRemoteFrameTokenForTest(malformed1, &token_with_predecessor)); base::DictValue malformed2; malformed2.Set("token", base::Value("garbage")); - EXPECT_FALSE(ExtractRemoteFrameToken(malformed2, &token_with_predecessor)); + EXPECT_FALSE( + ExtractRemoteFrameTokenForTest(malformed2, &token_with_predecessor)); base::DictValue malformed3; malformed3.Set("token", base::Value("beefbeefbeefbeefcafecafecafecafe")); malformed3.Set("predecessor", base::Value("garbage")); - EXPECT_FALSE(ExtractRemoteFrameToken(malformed3, &token_with_predecessor)); + EXPECT_FALSE( + ExtractRemoteFrameTokenForTest(malformed3, &token_with_predecessor)); + + // Test that -1 is the only negative number supported for `predecessor`. + base::DictValue wellformed2 = wellformed1.Clone(); + wellformed2.Set("predecessor", base::Value(-1)); + EXPECT_TRUE( + ExtractRemoteFrameTokenForTest(wellformed2, &token_with_predecessor)); + + base::DictValue malformed4 = wellformed1.Clone(); + malformed4.Set("predecessor", base::Value(-5)); + EXPECT_FALSE( + ExtractRemoteFrameTokenForTest(malformed4, &token_with_predecessor)); +} + +// Tests that ExtractChildFrames() only accepts predecessors that in ascending +// order. +TEST_F(AutofillUtilTest, ExtractChildFrames_PredecessorsMustBeSorted) { + auto create_child = [](std::string token, int predecessor) { + base::DictValue child; + child.Set("token", base::Value(std::move(token))); + child.Set("predecessor", base::Value(predecessor)); + return child; + }; + auto create_children = [](auto&&... children) { + base::ListValue list; + (list.Append(std::move(children)), ...); + return list; + }; + + base::DictValue form; + EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty()); + + form.Set("child_frames", base::ListValue()); + EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty()); + + form.Set("child_frames", create_children()); + EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty()); + + form.Set("child_frames", create_children(create_child( + "aeefbeefbeefbeefcafecafecafecafe", 12))); + EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(1)); + + form.Set( + "child_frames", + create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", 12), + create_child("beefbeefbeefbeefcafecafecafecafe", 23))); + EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(2)); + + form.Set( + "child_frames", + create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", -1), + create_child("beefbeefbeefbeefcafecafecafecafe", 12), + create_child("ceefbeefbeefbeefcafecafecafecafe", 23))); + EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(3)); + + form.Set( + "child_frames", + create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", 99), + create_child("beefbeefbeefbeefcafecafecafecafe", 1))); + EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty()); } } // namespace
Regression Test / PoC
diff --git a/components/autofill/ios/browser/autofill_util_unittest.mm b/components/autofill/ios/browser/autofill_util_unittest.mm
index 6dfb3af0..019909b 100644
--- a/components/autofill/ios/browser/autofill_util_unittest.mm
+++ b/components/autofill/ios/browser/autofill_util_unittest.mm
@@ -16,6 +16,8 @@
#import "components/autofill/core/common/form_field_data.h"
#import "components/autofill/core/common/unique_ids.h"
#import "components/autofill/ios/common/features.h"
+#import "testing/gmock/include/gmock/gmock.h"
+#import "testing/gtest/include/gtest/gtest.h"
#import "testing/platform_test.h"
#import "url/gurl.h"
#import "url/origin.h"
@@ -30,6 +32,8 @@
using ::autofill::ExtractIDs;
using ::autofill::FieldRendererId;
using ::base::ASCIIToUTF16;
+using ::testing::IsEmpty;
+using ::testing::SizeIs;
TEST_F(AutofillUtilTest, ExtractFormData_FullUrl) {
base::test::ScopedFeatureList scoped_feature_list;
@@ -179,17 +183,16 @@
EXPECT_FALSE(token.has_value());
}
-// Test that the properties mask is extracted from the form field data.
+// Test that the child frames is extracted from the form field data.
TEST_F(AutofillUtilTest, ExtractRemoteFrameToken) {
- base::DictValue remote_frame_token_dict;
- remote_frame_token_dict.Set("token",
- base::Value("beefbeefbeefbeefcafecafecafecafe"));
- remote_frame_token_dict.Set("predecessor", base::Value(64));
+ base::DictValue wellformed1;
+ wellformed1.Set("token", base::Value("beefbeefbeefbeefcafecafecafecafe"));
+ wellformed1.Set("predecessor", base::Value(64));
autofill::FrameTokenWithPredecessor token_with_predecessor;
- ASSERT_TRUE(ExtractRemoteFrameToken(remote_frame_token_dict,
- &token_with_predecessor));
+ ASSERT_TRUE(
+ ExtractRemoteFrameTokenForTest(wellformed1, &token_with_predecessor));
EXPECT_EQ(base::ToLowerASCII(std::get<autofill::RemoteFrameToken>(
token_with_predecessor.token)
.ToString()),
@@ -198,16 +201,78 @@
base::DictValue malformed1;
malformed1.Set("garbage", base::Value("garbage"));
- EXPECT_FALSE(ExtractRemoteFrameToken(malformed1, &token_with_predecessor));
+ EXPECT_FALSE(
+ ExtractRemoteFrameTokenForTest(malformed1, &token_with_predecessor));
base::DictValue malformed2;
malformed2.Set("token", base::Value("garbage"));
- EXPECT_FALSE(ExtractRemoteFrameToken(malformed2, &token_with_predecessor));
+ EXPECT_FALSE(
+ ExtractRemoteFrameTokenForTest(malformed2, &token_with_predecessor));
base::DictValue malformed3;
malformed3.Set("token", base::Value("beefbeefbeefbeefcafecafecafecafe"));
malformed3.Set("predecessor", base::Value("garbage"));
- EXPECT_FALSE(ExtractRemoteFrameToken(malformed3, &token_with_predecessor));
+ EXPECT_FALSE(
+ ExtractRemoteFrameTokenForTest(malformed3, &token_with_predecessor));
+
+ // Test that -1 is the only negative number supported for `predecessor`.
+ base::DictValue wellformed2 = wellformed1.Clone();
+ wellformed2.Set("predecessor", base::Value(-1));
+ EXPECT_TRUE(
+ ExtractRemoteFrameTokenForTest(wellformed2, &token_with_predecessor));
+
+ base::DictValue malformed4 = wellformed1.Clone();
+ malformed4.Set("predecessor", base::Value(-5));
+ EXPECT_FALSE(
+ ExtractRemoteFrameTokenForTest(malformed4, &token_with_predecessor));
+}
+
+// Tests that ExtractChildFrames() only accepts predecessors that in ascending
+// order.
+TEST_F(AutofillUtilTest, ExtractChildFrames_PredecessorsMustBeSorted) {
+ auto create_child = [](std::string token, int predecessor) {
+ base::DictValue child;
+ child.Set("token", base::Value(std::move(token)));
+ child.Set("predecessor", base::Value(predecessor));
+ return child;
+ };
+ auto create_children = [](auto&&... children) {
+ base::ListValue list;
+ (list.Append(std::move(children)), ...);
+ return list;
+ };
+
+ base::DictValue form;
+ EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty());
+
+ form.Set("child_frames", base::ListValue());
+ EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty());
+
+ form.Set("child_frames", create_children());
+ EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty());
+
+ form.Set("child_frames", create_children(create_child(
+ "aeefbeefbeefbeefcafecafecafecafe", 12)));
+ EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(1));
+
+ form.Set(
+ "child_frames",
+ create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", 12),
+ create_child("beefbeefbeefbeefcafecafecafecafe", 23)));
+ EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(2));
+
+ form.Set(
+ "child_frames",
+ create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", -1),
+ create_child("beefbeefbeefbeefcafecafecafecafe", 12),
+ create_child("ceefbeefbeefbeefcafecafecafecafe", 23)));
+ EXPECT_THAT(ExtractChildFramesForTest(form), SizeIs(3));
+
+ form.Set(
+ "child_frames",
+ create_children(create_child("aeefbeefbeefbeefcafecafecafecafe", 99),
+ create_child("beefbeefbeefbeefcafecafecafecafe", 1)));
+ EXPECT_THAT(ExtractChildFramesForTest(form), IsEmpty());
}
} // namespace