Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Metrics
DescriptionUse after free in Metrics
ComponentMetrics
Sub-componentSpeed>Metrics
Bug ClassUAF
Tracker565797213
Fix commit3992ced9343e (chromium/src) +133/-3
CISA KEVNot listed
CreditedXinyang Ge (Anthropic), assisted by Claude
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
components/page_load_metrics/browser/metrics_web_contents_observer.cc
modified
BindRepeating
components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
modified
for
components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
modified
TEST_P
components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
modified

Files Changed

  • components/page_load_metrics/browser/metrics_web_contents_observer.cc
  • components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
From 3992ced9343e1415c17fd697b10e0d6250dd29de Mon Sep 17 00:00:00 2001
From: Josh Karlin <jkarlin@google.com>
Date: Tue, 29 Sep 2026 10:39:37 -0700
Subject: [PATCH] Ignore metrics updates from speculative frames

The renderer is supposed to queue up page load metrics messages from
speculative/kPendingCommit frames and send them upon commit. If they
come before then on an RFH, it's a compromised renderer, and should be
dropped.

Bug: 565797213
Change-Id: I1ce66a52651367b99215f1debd65c4612c7c24b8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8465503
Commit-Queue: Josh Karlin <jkarlin@chromium.org>
Reviewed-by: Michal Mocny <mmocny@chromium.org>
Reviewed-by: Johannes Henkel <johannes@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1707252}
---
diff --git a/components/page_load_metrics/browser/metrics_web_contents_observer.cc b/components/page_load_metrics/browser/metrics_web_contents_observer.cc
index bf0c6029..2af29261 100644
--- a/components/page_load_metrics/browser/metrics_web_contents_observer.cc
+++ b/components/page_load_metrics/browser/metrics_web_contents_observer.cc
@@ -1442,9 +1442,20 @@
   // subframe's timing update.
   PageLoadTracker* tracker = GetPageLoadTrackerLegacy(render_frame_host);
   // We may receive notifications from frames that have been navigated away
-  // from. In that case the PageLoadTracker is already destroyed in
-  // DidFinishNavigation (unless it's stored in bfcache). We simply ignore them.
-  if (!tracker && !render_frame_host->GetMainFrame()->IsActive()) {
+  // from or have not yet committed. In that case the PageLoadTracker is
+  // already destroyed in DidFinishNavigation (unless it's stored in bfcache),
+  // or the frame itself is not active. We simply ignore them.
+  const bool is_prerendering_or_bfcached_or_unloading =
+      render_frame_host->IsInLifecycleState(
+          content::RenderFrameHost::LifecycleState::kPrerendering) ||
+      render_frame_host->IsInLifecycleState(
+          content::RenderFrameHost::LifecycleState::kInBackForwardCache) ||
+      render_frame_host->IsInLifecycleState(
+          content::RenderFrameHost::LifecycleState::kPendingDeletion);
+  const bool is_valid_frame =
+      render_frame_host->IsActive() ||
+      (tracker && is_prerendering_or_bfcached_or_unloading);
+  if (!is_valid_frame) {
     RecordInternalError(ERR_IPC_FROM_WRONG_FRAME);
     return nullptr;
   }
diff --git a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
index ff35d8322..17e10988 100644
--- a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
+++ b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
@@ -2446,6 +2446,125 @@
             1);
 }
 
+// Verify that metrics/timing IPCs from a pending-commit (uncommitted /
+// non-active) RenderFrameHost on a FrameTreeNode that already has an ad frame
+// are ignored.
+TEST_P(AdsPageLoadMetricsObserverTest,
+       HeavyAdIntervention_PendingCommitSubframeResourceUpdateIgnored) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {heavy_ad_intervention::features::kHeavyAdIntervention},
+      {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations});
+
+  RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl);
+  RenderFrameHost* ad_subframe = CreateAndNavigateSubFrame(kAdUrl, main_frame);
+
+  // Start a cross-site navigation in that ad subframe and advance to
+  // ReadyToCommit() so that a kPendingCommit RenderFrameHost exists on the same
+  // FrameTreeNode.
+  auto nav =
+      CreateNavigationSimulator("https://another-ad-site.com/ad", ad_subframe);
+  nav->Start();
+  nav->ReadyToCommit();
+
+  RenderFrameHost* pending_commit_rfh = nav->GetFinalRenderFrameHost();
+  ASSERT_NE(pending_commit_rfh, ad_subframe);
+  ASSERT_FALSE(pending_commit_rfh->IsActive());
+  ASSERT_TRUE(pending_commit_rfh->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kPendingCommit));
+
+  // Simulate a multi-resource update targeting the pending-commit RFH.
+  std::vector<mojom::ResourceDataUpdatePtr> resources;
+  mojom::ResourceDataUpdatePtr resource1 = mojom::ResourceDataUpdate::New();
+  resource1->received_data_length =
+      heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1);
+  resource1->delta_bytes = resource1->received_data_length;
+  resource1->encoded_body_length = resource1->received_data_length;
+  resource1->cache_type = mojom::CacheType::kNotCached;
+  resource1->is_complete = true;
+  resources.push_back(std::move(resource1));
+
+  mojom::ResourceDataUpdatePtr resource2 = mojom::ResourceDataUpdate::New();
+  resource2->received_data_length = base::KiB(10);
+  resource2->delta_bytes = resource2->received_data_length;
+  resource2->encoded_body_length = resource2->received_data_length;
+  resource2->cache_type = mojom::CacheType::kNotCached;
+  resource2->is_complete = true;
+  resources.push_back(std::move(resource2));
+
+  tester()->SimulateResourceDataUseUpdate(resources, pending_commit_rfh);
+
+  // Also simulate CPU timing update targeting pending_commit_rfh.
+  mojom::CpuTiming cpu_timing(
+      base::Milliseconds(heavy_ad_thresholds::kMaxCpuTime + 1));
+  tester()->SimulateCpuTimingUpdate(cpu_timing, pending_commit_rfh);
+
+  // Verify that the pending-commit RFH IPCs were ignored: no intervention fired
+  // and no reports were sent.
+  EXPECT_FALSE(HasInterventionReportsAfterFlush(ad_subframe));
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.InterventionType2"), 0);
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 0);
+}
+
+// Updates from a speculative RenderFrameHost (a navigation that hasn't been
+// asked to commit yet) must be ignored.
+TEST_P(AdsPageLoadMetricsObserverTest,
+       HeavyAdIntervention_SpeculativeRfhUpdateIgnored) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {heavy_ad_intervention::features::kHeavyAdIntervention},
+      {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations});
+
+  RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl);
+  RenderFrameHost* ad_frame = CreateAndNavigateSubFrame(kAdUrl, main_frame);
+
+  // Defer a cross-site navigation of the ad frame at WillProcessResponse, so
+  // that a speculative RenderFrameHost exists but hasn't been asked to commit.
+  content::TestNavigationThrottleInserter throttle_inserter(
+      web_contents(),
+      base::BindRepeating([](content::NavigationThrottleRegistry& registry) {
+        auto throttle = std::make_unique<TestNavigationThrottle>(registry);
+        throttle->SetResponse(TestNavigationThrottle::WILL_PROCESS_RESPONSE,
+                              TestNavigationThrottle::SYNCHRONOUS,
+                              content::NavigationThrottle::DEFER);
+        registry.AddThrottle(std::move(throttle));
+      }));
+  std::unique_ptr<NavigationSimulator> navigation =
+      NavigationSimulator::CreateRendererInitiated(GURL(kOtherAdUrl), ad_frame);
+  navigation->Start();
+  navigation->SetAutoAdvance(false);
+  navigation->ReadyToCommit();
+  ASSERT_TRUE(navigation->IsDeferred());
+  RenderFrameHost* speculative_rfh =
+      navigation->GetNavigationHandle()->GetRenderFrameHost();
+  ASSERT_NE(speculative_rfh, ad_frame);
+  ASSERT_FALSE(speculative_rfh->IsActive());
+  ASSERT_FALSE(speculative_rfh->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kPendingCommit));
+  content::RenderFrameDeletedObserver deleted_observer(speculative_rfh);
+
+  // Two resources, each over the heavy ad network limit.
+  std::vector<mojom::ResourceDataUpdatePtr> resources;
+  for (int i = 0; i < 2; ++i) {
+    mojom::ResourceDataUpdatePtr resource = mojom::ResourceDataUpdate::New();
+    resource->request_id = 1000 + i;
+    resource->received_data_length =
+        heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1);
+    resource->delta_bytes = resource->received_data_length;
+    resource->encoded_body_length = resource->received_data_length;
+    resource->cache_type = mojom::CacheType::kNotCached;
+    resources.push_back(std::move(resource));
+  }
+  tester()->SimulateResourceDataUseUpdate(resources, speculative_rfh);
+
+  EXPECT_FALSE(deleted_observer.deleted());
+  EXPECT_TRUE(navigation->IsDeferred());
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.InterventionType2"), 0);
+}
+
 TEST_P(AdsPageLoadMetricsObserverTest, HeavyAdNetworkUsage_InterventionFired) {
   base::test::ScopedFeatureList feature_list;
   feature_list.InitAndEnableFeature(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
index ff35d8322..17e10988 100644
--- a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
+++ b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
@@ -2446,6 +2446,125 @@
             1);
 }
 
+// Verify that metrics/timing IPCs from a pending-commit (uncommitted /
+// non-active) RenderFrameHost on a FrameTreeNode that already has an ad frame
+// are ignored.
+TEST_P(AdsPageLoadMetricsObserverTest,
+       HeavyAdIntervention_PendingCommitSubframeResourceUpdateIgnored) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {heavy_ad_intervention::features::kHeavyAdIntervention},
+      {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations});
+
+  RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl);
+  RenderFrameHost* ad_subframe = CreateAndNavigateSubFrame(kAdUrl, main_frame);
+
+  // Start a cross-site navigation in that ad subframe and advance to
+  // ReadyToCommit() so that a kPendingCommit RenderFrameHost exists on the same
+  // FrameTreeNode.
+  auto nav =
+      CreateNavigationSimulator("https://another-ad-site.com/ad", ad_subframe);
+  nav->Start();
+  nav->ReadyToCommit();
+
+  RenderFrameHost* pending_commit_rfh = nav->GetFinalRenderFrameHost();
+  ASSERT_NE(pending_commit_rfh, ad_subframe);
+  ASSERT_FALSE(pending_commit_rfh->IsActive());
+  ASSERT_TRUE(pending_commit_rfh->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kPendingCommit));
+
+  // Simulate a multi-resource update targeting the pending-commit RFH.
+  std::vector<mojom::ResourceDataUpdatePtr> resources;
+  mojom::ResourceDataUpdatePtr resource1 = mojom::ResourceDataUpdate::New();
+  resource1->received_data_length =
+      heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1);
+  resource1->delta_bytes = resource1->received_data_length;
+  resource1->encoded_body_length = resource1->received_data_length;
+  resource1->cache_type = mojom::CacheType::kNotCached;
+  resource1->is_complete = true;
+  resources.push_back(std::move(resource1));
+
+  mojom::ResourceDataUpdatePtr resource2 = mojom::ResourceDataUpdate::New();
+  resource2->received_data_length = base::KiB(10);
+  resource2->delta_bytes = resource2->received_data_length;
+  resource2->encoded_body_length = resource2->received_data_length;
+  resource2->cache_type = mojom::CacheType::kNotCached;
+  resource2->is_complete = true;
+  resources.push_back(std::move(resource2));
+
+  tester()->SimulateResourceDataUseUpdate(resources, pending_commit_rfh);
+
+  // Also simulate CPU timing update targeting pending_commit_rfh.
+  mojom::CpuTiming cpu_timing(
+      base::Milliseconds(heavy_ad_thresholds::kMaxCpuTime + 1));
+  tester()->SimulateCpuTimingUpdate(cpu_timing, pending_commit_rfh);
+
+  // Verify that the pending-commit RFH IPCs were ignored: no intervention fired
+  // and no reports were sent.
+  EXPECT_FALSE(HasInterventionReportsAfterFlush(ad_subframe));
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.InterventionType2"), 0);
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 0);
+}
+
+// Updates from a speculative RenderFrameHost (a navigation that hasn't been
+// asked to commit yet) must be ignored.
+TEST_P(AdsPageLoadMetricsObserverTest,
+       HeavyAdIntervention_SpeculativeRfhUpdateIgnored) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {heavy_ad_intervention::features::kHeavyAdIntervention},
+      {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations});
+
+  RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl);
+  RenderFrameHost* ad_frame = CreateAndNavigateSubFrame(kAdUrl, main_frame);
+
+  // Defer a cross-site navigation of the ad frame at WillProcessResponse, so
+  // that a speculative RenderFrameHost exists but hasn't been asked to commit.
+  content::TestNavigationThrottleInserter throttle_inserter(
+      web_contents(),
+      base::BindRepeating([](content::NavigationThrottleRegistry& registry) {
+        auto throttle = std::make_unique<TestNavigationThrottle>(registry);
+        throttle->SetResponse(TestNavigationThrottle::WILL_PROCESS_RESPONSE,
+                              TestNavigationThrottle::SYNCHRONOUS,
+                              content::NavigationThrottle::DEFER);
+        registry.AddThrottle(std::move(throttle));
+      }));
+  std::unique_ptr<NavigationSimulator> navigation =
+      NavigationSimulator::CreateRendererInitiated(GURL(kOtherAdUrl), ad_frame);
+  navigation->Start();
+  navigation->SetAutoAdvance(false);
+  navigation->ReadyToCommit();
+  ASSERT_TRUE(navigation->IsDeferred());
+  RenderFrameHost* speculative_rfh =
+      navigation->GetNavigationHandle()->GetRenderFrameHost();
+  ASSERT_NE(speculative_rfh, ad_frame);
+  ASSERT_FALSE(speculative_rfh->IsActive());
+  ASSERT_FALSE(speculative_rfh->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kPendingCommit));
+  content::RenderFrameDeletedObserver deleted_observer(speculative_rfh);
+
+  // Two resources, each over the heavy ad network limit.
+  std::vector<mojom::ResourceDataUpdatePtr> resources;
+  for (int i = 0; i < 2; ++i) {
+    mojom::ResourceDataUpdatePtr resource = mojom::ResourceDataUpdate::New();
+    resource->request_id = 1000 + i;
+    resource->received_data_length =
+        heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1);
+    resource->delta_bytes = resource->received_data_length;
+    resource->encoded_body_length = resource->received_data_length;
+    resource->cache_type = mojom::CacheType::kNotCached;
+    resources.push_back(std::move(resource));
+  }
+  tester()->SimulateResourceDataUseUpdate(resources, speculative_rfh);
+
+  EXPECT_FALSE(deleted_observer.deleted());
+  EXPECT_TRUE(navigation->IsDeferred());
+  histogram_tester().ExpectTotalCount(
+      SuffixedHistogram("HeavyAds.InterventionType2"), 0);
+}
+
 TEST_P(AdsPageLoadMetricsObserverTest, HeavyAdNetworkUsage_InterventionFired) {
   base::test::ScopedFeatureList feature_list;
   feature_list.InitAndEnableFeature(
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Heap-use-after-free in AdsPageLoadMetricsObserver::OnResourceDataUseObserved

Summary

A renderer can send PageLoadMetrics.UpdateTiming from the provisional frame of a speculative RenderFrameHost. If that frame is ad-tagged and the reported bytes cross the heavy-ad limit, MaybeTriggerHeavyAdIntervention() navigates the frame, which synchronously discards the speculative RenderFrameHost that sent the message. OnResourceDataUseObserved() then keeps using it. Browser process, reachable from a compromised renderer.

Environment

  • Chromium 155.0.8038.0 (d81900a3939b6) and main b7c07f35773a, each with poc.patch applied; the affected file is unchanged at main
  • Ubuntu 24.04, x86-64
  • Evidence build: ASan, Chromium 155.0.8038.0 with poc.patch
  • Not reproducible on Google’s release binaries by construction (needs a modified renderer)

Reproduction

python3 poc.py seed /tmp/poc-profile
python3 poc.py 8000
chrome --user-data-dir=/tmp/poc-profile --no-first-run http://a.localhost:8000/poc.html
  1. Build Chrome with poc.patch (one renderer file; simulates the compromised renderer, since a provisional frame has no script context for MojoJS).
  2. Seed the profile with a one-rule ad-tagging list (stands in for the Subresource Filter component download).
  3. Serve and load the page. No interaction; the browser crashes after about 14 s.

Rate: ASan 7/7; ASan build of main 3/3.

Evidence

Browser process, UI thread. Full report: asan.txt.

heap-use-after-free READ of size 8 (5760-byte RenderFrameHostImpl)
  #0 AdsPageLoadMetricsObserver::OnResourceDataUseObserved  :889
freed: DiscardUnusedFrame <- DiscardSpeculativeRFH <- GetFrameHostForNavigation
  <- MaybeTriggerHeavyAdIntervention :1488 <- OnResourceDataUseObserved :669
MiraclePtr Status: NOT PROTECTED

Vulnerable code

components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc

667  for (auto const& resource : resources) {
668    ProcessResourceForPage(rfh, resource);    [2]
669    ProcessResourceForFrame(rfh, resource);   [1]

[1] Can trigger the heavy-ad navigation that frees rfh. [2] The next iteration uses the freed rfh.

Impact

A compromised renderer, no user interaction; ad tagging and heavy-ad intervention are on by default. Browser-process use-after-free. Reporter’s estimate: High (S1).

Discovery and validation

Found by AI-assisted source review and dynamic testing (Claude).

Credit

Xinyang Ge (Anthropic), assisted by Claude

Attachments: poc.py, poc.patch, asan.txt, report.md (full write-up)

View on issue tracker