CVE-2026-95310
Overview
Background
- `AdsPageLoadMetricsObserver`
- a browser-process
page_load_metricsobserver that tracks per-frame resource usage to attribute bytes to ad frames. - `ongoing_navigation_resources_`
- a map keyed by
content::FrameTreeNodeIdholding the in-flight navigation resource for each frame, from which entries are looked up and erased. - Heavy-ad intervention
- a mechanism that destroys a frame’s in-flight
content::NavigationRequestwhen the frame exceeds resource thresholds, synchronously dispatchingDidFinishNavigation. - UAF (use-after-free)
- dereferencing or erasing via memory (here, an invalidated iterator) whose backing entry has already been freed.
Root Cause Analysis
AdsPageLoadMetricsObserver::ProcessOngoingNavigationResource obtained an iterator (frame_id_and_request) into ongoing_navigation_resources_ and held it live across a call to the synchronous helper ProcessResourceForFrame. That helper can trigger the heavy-ad intervention, which destroys the frame’s in-flight content::NavigationRequest and synchronously dispatches DidFinishNavigation, re-entering ProcessOngoingNavigationResource for the same FrameTreeNodeId. The re-entrant call looks up and erases the same map entry, invalidating the iterator still held by the outer frame. When control returns to the outer call, its ongoing_navigation_resources_.erase(frame_id_and_request) operates on a dangling/invalidated iterator, causing a double erase and use-after-free.
The bug is a classic reentrancy-plus-stored-iterator hazard where a synchronous callout mutates a container the caller assumes stable.
Attack Path
- Load ad-heavy page The attacker serves a page whose ad frame consumes enough bandwidth/CPU to arm the heavy-ad intervention while a navigation resource is still in flight.
- Trigger processing
A navigation event drives
ProcessOngoingNavigationResource, which grabs a live iterator intoongoing_navigation_resources_for the frame. - Fire intervention
ProcessResourceForFramecrosses the heavy-ad threshold and destroys the frame’scontent::NavigationRequest, synchronously dispatchingDidFinishNavigation. - Re-enter and erase
The dispatch re-enters
ProcessOngoingNavigationResourcefor the sameFrameTreeNodeId, erasing the map entry and invalidating the outer iterator. - Double erase / UAF The outer call resumes and erases via the now-dangling iterator, producing a browser-process use-after-free.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pcomponents/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc |
modified |
Files Changed
components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cccomponents/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc
Audit Directions
- Iterator held across synchronous calloutFlag any stored container iterator or reference kept live across a call that can dispatch navigation, IPC, or observer events; prefer
extract/copy-out ownership before the call. - Reentrant map mutationAudit methods that both look up and
erasea container entry around a synchronous helper that could re-invoke the same method on the same key. - Heavy-ad / NavigationRequest destruction pathsTrace all
DidFinishNavigationdispatch sites that destroy an in-flightcontent::NavigationRequestto confirm callers tolerate synchronous re-entry into their own state.
Patch
From e6669fc2498677ef677e97372bcfed1e57e4d20c Mon Sep 17 00:00:00 2001 From: Josh Karlin <[email protected]> Date: Wed, 16 Sep 2026 08:08:36 -0700 Subject: [PATCH] Fix reentrancy issue with iterator AdsPageLoadMetricsObserver held an iterator and later erased it while calling a synchronous function that could (via a roundabout way) synchronously reenter the same function and cause a double erasure. Fixed by removing from the map before calling the sync function. Change-Id: I4f38e771d98238253277f185e704b7d931f144ca Bug: 562151598 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8414996 Reviewed-by: Charles Harrison <[email protected]> Commit-Queue: Josh Karlin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698945} --- diff --git a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc index c48802f..e27b11d95 100644 --- a/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc +++ b/components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc @@ -1288,9 +1288,14 @@ void AdsPageLoadMetricsObserver::ProcessOngoingNavigationResource( content::NavigationHandle* navigation_handle) { - const auto& frame_id_and_request = ongoing_navigation_resources_.find( + // Extract the entry up front so the resource is owned locally for the rest + // of this call. Processing it can synchronously re-enter this method: the + // heavy ad intervention destroys the frame's in-flight NavigationRequest, + // which dispatches DidFinishNavigation. The re-entrant call must not observe + // or remove this entry again. + auto request_node = ongoing_navigation_resources_.extract( navigation_handle->GetFrameTreeNodeId()); - if (frame_id_and_request == ongoing_navigation_resources_.end()) { + if (request_node.empty()) { return; } @@ -1302,8 +1307,7 @@ : content::RenderFrameHost::FromID( navigation_handle->GetPreviousRenderFrameHostId()); - ProcessResourceForFrame(rfh, frame_id_and_request->second); - ongoing_navigation_resources_.erase(frame_id_and_request); + ProcessResourceForFrame(rfh, request_node.mapped()); } FrameTreeData* AdsPageLoadMetricsObserver::FindFrameData( 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 ab9b56d..30ff222 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 @@ -2388,6 +2388,64 @@ SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 0); } +// Checks re-entrancy: replaying a stashed navigation resource can trigger the +// heavy ad intervention, which synchronously destroys another in-flight +// NavigationRequest in the same frame. The resulting DidFinishNavigation +// re-enters ProcessOngoingNavigationResource for the same frame while the +// outer call is still in progress. +TEST_P(AdsPageLoadMetricsObserverTest, + HeavyAdIntervention_ReentrantNavigationFinish) { + base::test::ScopedFeatureList feature_list; + feature_list.InitWithFeatures( + {heavy_ad_intervention::features::kHeavyAdIntervention}, + {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations}); + + RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl); + RenderFrameHost* subframe = AppendChildFrame(main_frame); + + // Start a navigation in the subframe but don't commit it yet. + auto nav1 = CreateNavigationSimulator(kAdUrl, subframe); + nav1->Start(); + nav1->ReadyToCommit(); + + // Start a second navigation in the same subframe to give us an in-flight + // NavigationRequest that will be cancelled when the intervention triggers. + auto nav2 = content::NavigationSimulator::CreateRendererInitiated( + GURL(kAdUrl), subframe); + nav2->Start(); + + // Stash a complete primary frame resource for the subframe under `nav1` that + // exceeds the heavy-ad network limit. It is replayed when nav1 finishes. + ResourceDataUpdate(subframe, ResourceCached::kNotCached, + heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1)); + + // The intervention triggers synchronously during nav1's commit, canceling + // nav2 and navigating the frame to the error page. `waiter` confirms it. + ErrorPageWaiter waiter(web_contents()); + nav1->Commit(); + + RenderFrameHost* final_ad_frame = nav1->GetFinalRenderFrameHost(); + waiter.WaitForError(); + + // The stashed resource must be processed exactly once, even though the + // intervention re-enters this code path for the same frame. + histogram_tester().ExpectUniqueSample( + SuffixedHistogram("HeavyAds.InterventionType2"), HeavyAdStatus::kNetwork, + 1); + histogram_tester().ExpectTotalCount( + SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 1); + + content::RenderFrameHostTester* rfh_tester = + content::RenderFrameHostTester::For(final_ad_frame); + EXPECT_EQ( + rfh_tester->GetHeavyAdIssueCount( + content::RenderFrameHostTester::HeavyAdIssueType::kNetworkTotal), + 1); + EXPECT_EQ(rfh_tester->GetHeavyAdIssueCount( + content::RenderFrameHostTester::HeavyAdIssueType::kAll), + 1); +} + 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 ab9b56d..30ff222 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
@@ -2388,6 +2388,64 @@
SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 0);
}
+// Checks re-entrancy: replaying a stashed navigation resource can trigger the
+// heavy ad intervention, which synchronously destroys another in-flight
+// NavigationRequest in the same frame. The resulting DidFinishNavigation
+// re-enters ProcessOngoingNavigationResource for the same frame while the
+// outer call is still in progress.
+TEST_P(AdsPageLoadMetricsObserverTest,
+ HeavyAdIntervention_ReentrantNavigationFinish) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitWithFeatures(
+ {heavy_ad_intervention::features::kHeavyAdIntervention},
+ {heavy_ad_intervention::features::kHeavyAdPrivacyMitigations});
+
+ RenderFrameHost* main_frame = NavigateMainFrame(kNonAdUrl);
+ RenderFrameHost* subframe = AppendChildFrame(main_frame);
+
+ // Start a navigation in the subframe but don't commit it yet.
+ auto nav1 = CreateNavigationSimulator(kAdUrl, subframe);
+ nav1->Start();
+ nav1->ReadyToCommit();
+
+ // Start a second navigation in the same subframe to give us an in-flight
+ // NavigationRequest that will be cancelled when the intervention triggers.
+ auto nav2 = content::NavigationSimulator::CreateRendererInitiated(
+ GURL(kAdUrl), subframe);
+ nav2->Start();
+
+ // Stash a complete primary frame resource for the subframe under `nav1` that
+ // exceeds the heavy-ad network limit. It is replayed when nav1 finishes.
+ ResourceDataUpdate(subframe, ResourceCached::kNotCached,
+ heavy_ad_thresholds::kMaxNetworkBytes + base::KiB(1));
+
+ // The intervention triggers synchronously during nav1's commit, canceling
+ // nav2 and navigating the frame to the error page. `waiter` confirms it.
+ ErrorPageWaiter waiter(web_contents());
+ nav1->Commit();
+
+ RenderFrameHost* final_ad_frame = nav1->GetFinalRenderFrameHost();
+ waiter.WaitForError();
+
+ // The stashed resource must be processed exactly once, even though the
+ // intervention re-enters this code path for the same frame.
+ histogram_tester().ExpectUniqueSample(
+ SuffixedHistogram("HeavyAds.InterventionType2"), HeavyAdStatus::kNetwork,
+ 1);
+ histogram_tester().ExpectTotalCount(
+ SuffixedHistogram("HeavyAds.NetworkBytesAtFrameUnload"), 1);
+
+ content::RenderFrameHostTester* rfh_tester =
+ content::RenderFrameHostTester::For(final_ad_frame);
+ EXPECT_EQ(
+ rfh_tester->GetHeavyAdIssueCount(
+ content::RenderFrameHostTester::HeavyAdIssueType::kNetworkTotal),
+ 1);
+ EXPECT_EQ(rfh_tester->GetHeavyAdIssueCount(
+ content::RenderFrameHostTester::HeavyAdIssueType::kAll),
+ 1);
+}
+
TEST_P(AdsPageLoadMetricsObserverTest, HeavyAdNetworkUsage_InterventionFired) {
base::test::ScopedFeatureList feature_list;
feature_list.InitAndEnableFeature(