Medium watch CVSS 9.6 chrome Input Validation 🔧 Commit mapped

Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Transactions Platform
DescriptionImproper input validation in Transactions Platform
ComponentTransactions Platform
Sub-componentAutofill
Bug ClassInput Validation
Action tierwatch — ub-class only — no demonstrated corruption path
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.
Tracker513790581
Fix commit57bfec561762 (chromium/src) +147/-54
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-06-30 (public git)
Disclosed2026-09-03
Patch gap65 days — fix public in source before the advisory

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 RemoteFrameToken with a predecessor index 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 -1 means “before any field.”
`ExtractChildFrames` / `ExtractRemoteFrameToken`
helpers that read the renderer-supplied child_frames list out of the form JSON and populate the child_frames vector on FormData.

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.

Key insight
The single mistake was trusting renderer-supplied 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

  1. Craft malicious form JSON A malicious page (or compromised renderer) serves a form whose injected-JS extraction produces a child_frames list with attacker-chosen predecessor values, e.g. negative numbers below -1, out-of-range magnitudes, or values in non-ascending order.
  2. Browser parses unchecked On the pre-patch path, ExtractRemoteFrameToken copies each predecessor double directly and the extraction loop appends every entry, so the crafted values land unmodified in the FormData child_frames vector.
  3. Invariant broken for downstream indexing Downstream Autofill code that treats predecessor as an index into the form’s field list now operates on out-of-range or unsorted indices, breaking its ordering assumption (inferred).
  4. 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

An attacker who controls page content feeds crafted child-frame metadata into the iOS browser-process Autofill parser, and pre-patch the values were accepted without validation. The advisory classifies this as improper input validation in the Transactions Platform component on iOS and does not report in-the-wild exploitation; any escalation to an out-of-bounds read/write or further corruption via the mis-indexed 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

FunctionChangeNotes
if
components/autofill/ios/browser/autofill_util.mm
modified
for
components/autofill/ios/browser/autofill_util.mm
modified
TEST_F
components/autofill/ios/browser/autofill_util_unittest.mm
modified

Files Changed

  • components/autofill/ios/browser/autofill_util.h
  • components/autofill/ios/browser/autofill_util_unittest.mm
  • components/autofill/ios/browser/autofill_util.mm

Audit Directions

  • Renderer-supplied indices used without bounds
    Look for other Autofill/iOS JSON fields (offsets, indices, counts) that are copied from FindDouble/FindInt into C++ state with no range or sign validation before being used to index containers.
  • Per-element validation without collection-level invariants
    Flag parsers that validate each list element in isolation but never check cross-element invariants (ordering, uniqueness, monotonicity) the consumers depend on.
  • Sentinel-value handling
    Audit 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.
From 57bfec56176222bcdf18fbf64d0c1c34489ef064 Mon Sep 17 00:00:00 2001
From: Christoph Schwering <schwering@google.com>
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 <vincb@google.com>
Commit-Queue: Christoph Schwering <schwering@google.com>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.