Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Prerender
DescriptionUse after free in Prerender
ComponentPrerender
Bug ClassUAF
Tracker497053588
Fix commit8c1ead5a699f (chromium/src) +66/-14
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-15

Files Changed

  • content/browser/preloading/prerender/prerender_browsertest.cc
  • content/browser/preloading/prerender/prerender_host_registry.cc
  • content/browser/preloading/prerender/prerender_host_registry.h
  • content/browser/preloading/prerender/prerender_new_tab_handle.cc
  • content/browser/preloading/prerender/prerender_new_tab_handle.h
From 8c1ead5a699f53f1915f3187d2bcfac725c46815 Mon Sep 17 00:00:00 2001
From: Hiroki Nakagawa <nhiroki@chromium.org>
Date: Sun, 29 Mar 2026 18:12:59 -0700
Subject: [PATCH] Prerender: Update PrerenderNewTabHandle destruction

Updates PrerenderNewTabHandle::CancelPrerendering to be a static method
CancelPrerenderingAndDestroy that takes ownership of the handle.

Defers the destruction of PrerenderNewTabHandle using DeleteSoon() to
ensure the owned WebContentsImpl is not destroyed synchronously during
processing or iteration of handles.

Adds a browser test NewTabPrerenderCancellationByBrowsingDataRemover to
verify safe destruction.

Bug: 497053588
Change-Id: I97944ce9a5d1df486adb3e2438a8b381ee9f298e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7710193
Reviewed-by: Huanpo Lin <robertlin@chromium.org>
Commit-Queue: Hiroki Nakagawa <nhiroki@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1606854}
---

diff --git a/content/browser/preloading/prerender/prerender_browsertest.cc b/content/browser/preloading/prerender/prerender_browsertest.cc
index 4f953e31..5376a026 100644
--- a/content/browser/preloading/prerender/prerender_browsertest.cc
+++ b/content/browser/preloading/prerender/prerender_browsertest.cc
@@ -4312,6 +4312,39 @@
       PrerenderFinalStatus::kTabClosedWithoutUserGesture);
 }
 
+// Tests that trigger cancellation via BrowsingDataRemover (e.g.,
+// Clear-Site-Data) is handled safely without causing Use-After-Free due to
+// synchronous destruction of the WebContentsImpl during iteration.
+IN_PROC_BROWSER_TEST_F(PrerenderTargetHintBrowserTest,
+                       NewTabPrerenderCancellationByBrowsingDataRemover) {
+  const GURL initial_url = GetUrl("/empty.html");
+  const GURL prerendering_url = GetUrl("/empty.html?prerender");
+
+  // Navigate to an initial page.
+  ASSERT_TRUE(NavigateToURL(shell(), initial_url));
+
+  // Start prerendering.
+  PrerenderHostId host_id = prerender_helper()->AddPrerender(
+      prerendering_url, /*eagerness=*/std::nullopt, "_blank");
+  auto* prerender_web_contents =
+      test::PrerenderTestHelper::GetPrerenderWebContents(host_id);
+  WebContentsDestroyedWatcher wc_destroyed_watcher(prerender_web_contents);
+
+  // Trigger browsing data removal which will call CancelHostsByOriginFilter.
+  BrowsingDataRemover* remover =
+      web_contents_impl()->GetBrowserContext()->GetBrowsingDataRemover();
+  BrowsingDataRemoverCompletionObserver completion_observer(remover);
+  remover->RemoveAndReply(base::Time::Min(), base::Time::Max(),
+                          BrowsingDataRemover::DATA_TYPE_CACHE,
+                          BrowsingDataRemover::ORIGIN_TYPE_UNPROTECTED_WEB,
+                          &completion_observer);
+  completion_observer.BlockUntilCompletion();
+
+  // WebContents created for the new-tab trigger will be destroyed safely.
+  wc_destroyed_watcher.Wait();
+  EXPECT_FALSE(prerender_helper()->HasNewTabHandle(host_id));
+}
+
 // Tests that prerendering is cancelled if a network request for the
 // navigation results in an empty response with 404 status.
 IN_PROC_BROWSER_TEST_P(PrerenderTargetAgnosticBrowserTest,
diff --git a/content/browser/preloading/prerender/prerender_host_registry.cc b/content/browser/preloading/prerender/prerender_host_registry.cc
index 74f68ef..90258672 100644
--- a/content/browser/preloading/prerender/prerender_host_registry.cc
+++ b/content/browser/preloading/prerender/prerender_host_registry.cc
@@ -1114,14 +1114,8 @@
   prerender_new_tab_handle_by_id_.erase(iter);
   NotifyCancel(handle->prerender_host_id(), reason);
 
-  if (reason.final_status() == PrerenderFinalStatus::kSpeculationRuleRemoved) {
-    auto& new_tab_registry = handle->GetPrerenderHostRegistry();
-    new_tab_registry.SchedulePendingDeletionPrerenderNewTabHandle(
-        std::move(handle));
-    new_tab_registry.CancelHost(prerender_host_id, reason);
-  } else {
-    handle->CancelPrerendering(reason);
-  }
+  PrerenderNewTabHandle::CancelPrerenderingAndDestroy(std::move(handle),
+                                                      reason);
 
   return true;
 }
@@ -1804,6 +1798,7 @@
 }
 
 void PrerenderHostRegistry::SchedulePendingDeletionPrerenderNewTabHandle(
+    base::PassKey<PrerenderNewTabHandle>,
     std::unique_ptr<PrerenderNewTabHandle> handle) {
   CHECK(!pending_deletion_new_tab_prerender_handle_);
   pending_deletion_new_tab_prerender_handle_ = std::move(handle);
diff --git a/content/browser/preloading/prerender/prerender_host_registry.h b/content/browser/preloading/prerender/prerender_host_registry.h
index 4eaf15d2..e42bc2f 100644
--- a/content/browser/preloading/prerender/prerender_host_registry.h
+++ b/content/browser/preloading/prerender/prerender_host_registry.h
@@ -286,6 +286,9 @@
 
   PrerenderHostId GetPrerenderHostIdForNavigation(
       NavigationRequest* navigation_request);
+  void SchedulePendingDeletionPrerenderNewTabHandle(
+      base::PassKey<PrerenderNewTabHandle>,
+      std::unique_ptr<PrerenderNewTabHandle> handle);
 
  private:
   // WebContentsObserver implementation:
@@ -308,8 +311,6 @@
   void ScheduleToDeleteAbandonedHost(
       std::unique_ptr<PrerenderHost> prerender_host,
       const PrerenderCancellationReason& cancellation_reason);
-  void SchedulePendingDeletionPrerenderNewTabHandle(
-      std::unique_ptr<PrerenderNewTabHandle> handle);
 
   void DeleteAbandonedHosts();
 
diff --git a/content/browser/preloading/prerender/prerender_new_tab_handle.cc b/content/browser/preloading/prerender/prerender_new_tab_handle.cc
index 5c4bc5e..99b5bd9 100644
--- a/content/browser/preloading/prerender/prerender_new_tab_handle.cc
+++ b/content/browser/preloading/prerender/prerender_new_tab_handle.cc
@@ -10,6 +10,7 @@
 #include "content/browser/preloading/preloading_data_impl.h"
 #include "content/browser/preloading/prerender/prerender_host.h"
 #include "content/browser/preloading/prerender/prerender_host_registry.h"
+#include "content/browser/preloading/prerender/prerender_metrics.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/common/frame.mojom.h"
 #include "content/public/browser/web_contents_delegate.h"
@@ -103,9 +104,29 @@
   return prerender_host_id_;
 }
 
-void PrerenderNewTabHandle::CancelPrerendering(
+// static
+void PrerenderNewTabHandle::CancelPrerenderingAndDestroy(
+    std::unique_ptr<PrerenderNewTabHandle> handle,
     const PrerenderCancellationReason& reason) {
-  GetPrerenderHostRegistry().CancelHost(prerender_host_id_, reason);
+  auto& registry = handle->GetPrerenderHostRegistry();
+  PrerenderHostId host_id = handle->prerender_host_id();
+
+  if (reason.final_status() == PrerenderFinalStatus::kSpeculationRuleRemoved) {
+    // Defer destruction of the handle until the pagehide event is fired in a
+    // prerendered page in a new tab. The event is fired only when prerendering
+    // is intentionally cancelled by an initiator page (i.e., Speculation rule
+    // is removed).
+    registry.SchedulePendingDeletionPrerenderNewTabHandle(
+        base::PassKey<PrerenderNewTabHandle>(), std::move(handle));
+  } else {
+    // Defer destruction of the handle to avoid synchronous destruction of the
+    // owned WebContentsImpl. This prevents Use-After-Free if this is called
+    // while iterating over a snapshot of raw pointers to all WebContents (e.g.,
+    // in BrowsingDataRemoverImpl::RemoveImpl).
+    base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(
+        FROM_HERE, std::move(handle));
+  }
+  registry.CancelHost(host_id, reason);
 }
 
 std::unique_ptr<WebContentsImpl>
diff --git a/content/browser/preloading/prerender/prerender_new_tab_handle.h b/content/browser/preloading/prerender/prerender_new_tab_handle.h
index 7f36ebf..8d4f3f4 100644
--- a/content/browser/preloading/prerender/prerender_new_tab_handle.h
+++ b/content/browser/preloading/prerender/prerender_new_tab_handle.h
@@ -49,8 +49,10 @@
       const PreloadingPredictor& enacting_predictor,
       PreloadingConfidence confidence);
 
-  // Cancels prerendering started in `web_contents_`.
-  void CancelPrerendering(const PrerenderCancellationReason& reason);
+  // Cancels prerendering and schedules the destruction of the handle.
+  static void CancelPrerenderingAndDestroy(
+      std::unique_ptr<PrerenderNewTabHandle> handle,
+      const PrerenderCancellationReason& reason);
 
   // Passes the ownership of `web_contents_` to the caller if it's available for
   // new tab navigation with given params.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/preloading/prerender/prerender_browsertest.cc b/content/browser/preloading/prerender/prerender_browsertest.cc
index 4f953e31..5376a026 100644
--- a/content/browser/preloading/prerender/prerender_browsertest.cc
+++ b/content/browser/preloading/prerender/prerender_browsertest.cc
@@ -4312,6 +4312,39 @@
       PrerenderFinalStatus::kTabClosedWithoutUserGesture);
 }
 
+// Tests that trigger cancellation via BrowsingDataRemover (e.g.,
+// Clear-Site-Data) is handled safely without causing Use-After-Free due to
+// synchronous destruction of the WebContentsImpl during iteration.
+IN_PROC_BROWSER_TEST_F(PrerenderTargetHintBrowserTest,
+                       NewTabPrerenderCancellationByBrowsingDataRemover) {
+  const GURL initial_url = GetUrl("/empty.html");
+  const GURL prerendering_url = GetUrl("/empty.html?prerender");
+
+  // Navigate to an initial page.
+  ASSERT_TRUE(NavigateToURL(shell(), initial_url));
+
+  // Start prerendering.
+  PrerenderHostId host_id = prerender_helper()->AddPrerender(
+      prerendering_url, /*eagerness=*/std::nullopt, "_blank");
+  auto* prerender_web_contents =
+      test::PrerenderTestHelper::GetPrerenderWebContents(host_id);
+  WebContentsDestroyedWatcher wc_destroyed_watcher(prerender_web_contents);
+
+  // Trigger browsing data removal which will call CancelHostsByOriginFilter.
+  BrowsingDataRemover* remover =
+      web_contents_impl()->GetBrowserContext()->GetBrowsingDataRemover();
+  BrowsingDataRemoverCompletionObserver completion_observer(remover);
+  remover->RemoveAndReply(base::Time::Min(), base::Time::Max(),
+                          BrowsingDataRemover::DATA_TYPE_CACHE,
+                          BrowsingDataRemover::ORIGIN_TYPE_UNPROTECTED_WEB,
+                          &completion_observer);
+  completion_observer.BlockUntilCompletion();
+
+  // WebContents created for the new-tab trigger will be destroyed safely.
+  wc_destroyed_watcher.Wait();
+  EXPECT_FALSE(prerender_helper()->HasNewTabHandle(host_id));
+}
+
 // Tests that prerendering is cancelled if a network request for the
 // navigation results in an empty response with 404 status.
 IN_PROC_BROWSER_TEST_P(PrerenderTargetAgnosticBrowserTest,
Loading diff…

Original Bug Report

reported by vm...@google.com

Browser-process UAF in BrowsingDataRemoverImpl prerender-cache loop via synchronous destruction of new-tab prerender WebContents

Project Fortify, an experimental security project, has identified the following potential security issue.

Summary

BrowsingDataRemoverImpl::RemoveImpl (content/browser/browsing_data/browsing_data_remover_impl.cc:609-620) materializes a std::vector<WebContentsImpl*> snapshot of raw pointers via WebContentsImpl::GetAllWebContents() and iterates it with a range-for. For each entry it calls GetPrerenderHostRegistry()->CancelHostsByOriginFilter(). The snapshot includes both (a) the initiator tab’s WebContents and (b) any WebContentsImpl owned by PrerenderNewTabHandle (created via WebContents::Create() in prerender_new_tab_handle.cc:44, with a primary RVH that passes the IsMainFrameActive() filter in GetRenderWidgetHosts()).

When the loop reaches the INITIATOR WebContents first (iteration order is non-deterministic — absl::flat_hash_map keyed by (process_id, routing_id) at render_widget_host_impl.cc:185-188), CancelHostsByOriginFilter (prerender_host_registry.cc:2006-2034) collects the new-tab handle’s ID from prerender_new_tab_handle_by_id_ and calls CancelHosts(). CancelHosts (line 966-990) synchronously invokes CancelNewTabHostInternal (line 979).

CancelNewTabHostInternal (lines 1100-1127) moves the unique_ptr<PrerenderNewTabHandle> into a local variable ‘handle’ (line 1113). Because the final_status is kBrowsingDataRemoved (not kSpeculationRuleRemoved), execution takes the else branch (line 1122-1124): it calls handle->CancelPrerendering() which only parks the PrerenderHost for async deletion in the new-tab registry’s to_be_deleted_hosts_ — it does NOT transfer ownership of the handle anywhere. When the function returns, the local ‘handle’ goes out of scope, invoking ~PrerenderNewTabHandle() (prerender_new_tab_handle.cc:62-66). The destructor body only nulls the delegate, but the member std::unique_ptr<WebContentsImpl> web_contents_ (prerender_new_tab_handle.h:90) is then destroyed, synchronously running ~WebContentsImpl().

Control returns to the outer range-for in browsing_data_remover_impl.cc. The next iteration dereferences the now-dangling WebContentsImpl* from the snapshot vector at line 612: web_contents->GetBrowserContext(). GetBrowserContext() is pure virtual (content/public/browser/web_contents.h:458), so this is a vtable read from freed memory. With heap grooming to reclaim the freed WebContentsImpl allocation, an attacker controls the vtable pointer → arbitrary virtual call → browser-process RCE.

MiraclePtr does NOT protect this: the snapshot is std::vector<WebContentsImpl*> using raw T* (web_contents_impl.cc:1599, web_contents_impl.h:229), not raw_ptr<T>, and the loop variable is a stack-local raw pointer.

The trigger is fully web-accessible with no user interaction: (1) attacker page at origin A inserts a speculation-rules script with target_hint:"_blank" to spawn a new-tab prerender via CreateAndStartHostForNewTab (prerender_host_registry.cc:859-889); (2) attacker serves a subresource or navigation response with Clear-Site-Data: “prerenderCache” (feature kClearSiteDataPrefetchPrerenderCache is FEATURE_ENABLED_BY_DEFAULT at third_party/blink/common/features.cc:392-393) OR the standard Clear-Site-Data: “cache” header (both satisfy the DATA_TYPE_PRERENDER_CACHE | DATA_TYPE_CACHE mask at line 609). The origin filter matches because initiator_origin is the attacker’s origin (checked at prerender_host_registry.cc:2021-2025). Iteration-order dependence gives ~50% success per attempt; attacker can retry, or spawn multiple new-tab prerenders so that at least one freed pointer appears after the initiator in iteration order with high probability.

Location

content/browser/browsing_data/browsing_data_remover_impl.cc:609-620

Steps to Reproduce / Trigger Condition

  1. Serve an attacker-controlled page at https://attacker.example/index.html.
  2. The page inserts a <script type=“speculationrules”> with a prerender rule using target_hint: “blank” pointing to https://attacker.example/prerender-target.html with eagerness: “immediate”. This causes PrerenderHostRegistry::CreateAndStartHostForNewTab to be invoked on the initiator’s registry, which constructs a PrerenderNewTabHandle owning a fresh WebContentsImpl and stores it in prerender_new_tab_handle_by_id.
  3. Wait briefly for the new-tab prerender WebContents to be created (its primary RVH is registered in g_routing_id_widget_map with is_active()==true).
  4. The page issues fetch(‘https://attacker.example/trigger', {credentials:‘include’}) where /trigger responds with header Clear-Site-Data: “prerenderCache” (or “cache”). This invokes ClearSiteDataHandler → SiteDataClearer → BrowsingDataRemoverImpl::RemoveImpl on the UI thread with DATA_TYPE_PRERENDER_CACHE set.
  5. At browsing_data_remover_impl.cc:611, GetAllWebContents() returns a raw-pointer snapshot including both the initiator tab and the new-tab-prerender WebContents.
  6. If hash-map iteration processes the initiator first: CancelHostsByOriginFilter on the initiator’s registry finds the handle (initiator_origin matches attacker.example), calls CancelHosts → CancelNewTabHostInternal, which moves the handle to a local unique_ptr and, for kBrowsingDataRemoved, takes the else branch. The local handle goes out of scope → ~PrerenderNewTabHandle → ~WebContentsImpl runs synchronously.
  7. The outer loop advances to the (now-freed) new-tab WebContentsImpl* and calls web_contents->GetBrowserContext() — a virtual call through a freed object’s vtable pointer. ASAN reports heap-use-after-free.
  8. For exploitation: between the free and the dereference, groom the heap (e.g., via large ArrayBuffer/Blob allocations from another renderer, or by racing Mojo messages that allocate browser-process objects of similar size to WebContentsImpl) to reclaim the freed slot with attacker-controlled bytes. The first 8 bytes become the vtable pointer; the GetBrowserContext vtable-slot offset determines the hijacked call target.
  9. If iteration order is unfavorable, repeat steps 2-4 (remove/re-insert speculation rules and re-trigger Clear-Site-Data); expected success within a few attempts. Spawning multiple _blank prerenders (within the per-origin limit) increases per-attempt success probability since any one of several freed pointers appearing after the initiator suffices.
View on issue tracker