Critical patch-now ✔︎ verified CVSS 9.6 chrome UAF 🔧 Commit mapped 🧬 A nested callout frees the object, then it is used

Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in AdFilter
DescriptionUse after free in AdFilter
ComponentAdFilter
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Diff and commit confirm the stored-iterator reentrancy double-erase, fixed via map::extract. UAF matches advisory; RCE escalation is inferred. Heavy-ad intervention is the default-enabled trigger.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker562151598
Fix commite6669fc24986 (chromium/src) +66/-4
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedXinyang Ge
Reported2026-09-16 (per vendor advisory)
Fix landed2026-09-16 (public git)
Disclosed2026-09-22
Patch gap6 days — fix public in source before the advisory

Background

`AdsPageLoadMetricsObserver`
a browser-process page_load_metrics observer that tracks per-frame resource usage to attribute bytes to ad frames.
`ongoing_navigation_resources_`
a map keyed by content::FrameTreeNodeId holding 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::NavigationRequest when the frame exceeds resource thresholds, synchronously dispatching DidFinishNavigation.
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.

Key insight
A stored container iterator is only valid until the container is mutated, and any synchronous call that can dispatch navigation events can re-enter and mutate the very map being iterated. Extracting the node up front transfers ownership out of the map, so no live iterator survives the reentrant callout.

Attack Path

  1. 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.
  2. Trigger processing A navigation event drives ProcessOngoingNavigationResource, which grabs a live iterator into ongoing_navigation_resources_ for the frame.
  3. Fire intervention ProcessResourceForFrame crosses the heavy-ad threshold and destroys the frame’s content::NavigationRequest, synchronously dispatching DidFinishNavigation.
  4. Re-enter and erase The dispatch re-enters ProcessOngoingNavigationResource for the same FrameTreeNodeId, erasing the map entry and invalidating the outer iterator.
  5. Double erase / UAF The outer call resumes and erases via the now-dangling iterator, producing a browser-process use-after-free.

Impact Assessment

This is a browser-process use-after-free reachable by a hostile web page that arms the heavy-ad intervention, giving the attacker control over freed heap memory in the most privileged renderer-adjacent process. Depending on heap grooming it can lead to memory corruption and potentially remote code execution outside the renderer sandbox. Because it is web-triggerable with no user interaction beyond visiting a page, it is a high-severity, remotely exploitable defect.

Changed Functions

FunctionChangeNotes
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/observers/ad_metrics/ads_page_load_metrics_observer.cc
  • components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer_unittest.cc

Audit Directions

  • Iterator held across synchronous callout
    Flag 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 mutation
    Audit methods that both look up and erase a container entry around a synchronous helper that could re-invoke the same method on the same key.
  • Heavy-ad / NavigationRequest destruction paths
    Trace all DidFinishNavigation dispatch sites that destroy an in-flight content::NavigationRequest to confirm callers tolerate synchronous re-entry into their own state.
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(
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 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(
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.