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 <[email protected]> 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 <[email protected]> Reviewed-by: Michal Mocny <[email protected]> Reviewed-by: Johannes Henkel <[email protected]> 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)