CVE-2026-106233
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/page_load_metrics/browser/metrics_web_contents_observer.cc |
modified | |
BindRepeatingcomponents/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc |
modified | |
forcomponents/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc |
modified | |
TEST_Pcomponents/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.cccomponents/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
Patch
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(
Regression Test / PoC
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(
Original Bug Report
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.patchapplied; 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
- Build Chrome with
poc.patch(one renderer file; simulates the compromised renderer, since a provisional frame has no script context for MojoJS). - Seed the profile with a one-rule ad-tagging list (stands in for the Subresource Filter component download).
- 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)