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 <jkarlin@chromium.org>
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 <csharrison@chromium.org>
Commit-Queue: Josh Karlin <jkarlin@chromium.org>
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(