CVE-2026-6299
Overview
Background
- Use-after-free (UAF)
- accessing an object after it has been freed, which can corrupt memory or divert control flow.
- `PrerenderNewTabHandle`
- the browser-side object that owns the
WebContentsImplcreated to host a prerender triggered into a new tab (_blank/ target-hint prerendering). - `BrowsingDataRemover` iteration
- clearing browsing data (e.g. via Clear-Site-Data) snapshots all live
WebContentsinto a list of raw pointers and iterates it, so anyWebContentsdestroyed synchronously during that walk dangles. - `DeleteSoon` / deferred destruction
- posting an object’s deletion to the task runner so it is destroyed after the current call stack unwinds instead of synchronously.
Root Cause Analysis
Cancellation of a new-tab prerender routed through PrerenderNewTabHandle::CancelPrerendering, which (for the non-kSpeculationRuleRemoved path) destroyed the handle — and therefore its owned WebContentsImpl — synchronously as part of handling the cancellation. When the cancellation was driven by BrowsingDataRemoverImpl::RemoveImpl, that code is in the middle of iterating a snapshot of raw WebContents pointers; synchronously freeing one of those WebContents leaves a dangling raw pointer in the snapshot that the remover continues to dereference, a use-after-free.
The fix replaces the instance method with a static PrerenderNewTabHandle::CancelPrerenderingAndDestroy that takes ownership of the std::unique_ptr<PrerenderNewTabHandle> and, for the default path, defers its destruction via base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(), so the WebContentsImpl is torn down only after the iteration unwinds. The speculation-rule-removed path still routes through SchedulePendingDeletionPrerenderNewTabHandle (now gated by a base::PassKey<PrerenderNewTabHandle>), and CancelHost is invoked after ownership of the handle has been safely transferred.
WebContentsImpl synchronously while an outer caller (the browsing-data remover) was still iterating a raw-pointer snapshot of live WebContents; the fix defers that destruction with DeleteSoon() so the object outlives the iteration.Attack Path
- Prime a new-tab prerender
a page starts a prerender into a new tab (
_blanktarget hint), creating aPrerenderNewTabHandlethat owns a prerenderWebContentsImpl. - Trigger a browsing-data clear
a navigation or response (e.g.
Clear-Site-Data) causesBrowsingDataRemoverto run, which snapshots all liveWebContentsas raw pointers and calls into prerender cancellation (CancelHostsByOriginFilter). - Synchronous free during iteration
the old
CancelPrerenderingdestroys the handle and itsWebContentsImplinline, invalidating an entry still referenced by the remover’s in-progress snapshot. - Dangling-pointer use
the browsing-data remover continues iterating and dereferences the freed
WebContents, a use-after-free (specific downstream read/write is(inferred)).
Impact Assessment
Clear-Site-Data plus speculation-rules prerendering) to free a WebContentsImpl mid-iteration and cause a use-after-free in the browser process, which the advisory rates critical. Preconditions are web-content-reachable: a prerender triggered into a new tab and a cancellation path driven by the browsing-data remover; no extension, flag, or special privilege is indicated by the diff. Any escalation beyond the memory-safety error is (inferred).Files Changed
content/browser/preloading/prerender/prerender_browsertest.cccontent/browser/preloading/prerender/prerender_host_registry.cccontent/browser/preloading/prerender/prerender_host_registry.hcontent/browser/preloading/prerender/prerender_new_tab_handle.cccontent/browser/preloading/prerender/prerender_new_tab_handle.h
Audit Directions
- Synchronous destruction during container iterationaudit callbacks reached from
BrowsingDataRemover, observer lists, and other code that snapshots rawWebContents/object pointers and iterates, ensuring none can free a snapshot entry inline. - Owning handles freed inside cancellation pathslook for
std::unique_ptr-owning handles (prerender, preloading, tab) whose cancel/close methods delete the ownedWebContentssynchronously; preferDeleteSoonwhen an outer frame may still reference it. - Instance methods that delete `this`/their ownerflag methods that can trigger destruction of the object (or its owner) they are called on; converting to a static method that takes ownership makes the lifetime explicit and is a good audit signal.
Patch
From 8c1ead5a699f53f1915f3187d2bcfac725c46815 Mon Sep 17 00:00:00 2001 From: Hiroki Nakagawa <[email protected]> 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 <[email protected]> Commit-Queue: Hiroki Nakagawa <[email protected]> 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.
Regression Test / PoC
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,
Original Bug Report
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
- Serve an attacker-controlled page at https://attacker.example/index.html.
- 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.
- 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).
- 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.
- At browsing_data_remover_impl.cc:611, GetAllWebContents() returns a raw-pointer snapshot including both the initiator tab and the new-tab-prerender WebContents.
- 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.
- 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.
- 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.
- 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.