CVE-2026-15901
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pnet/disk_cache/backend_unittest.cc |
modified | |
fornet/disk_cache/backend_unittest.cc |
modified |
Files Changed
net/disk_cache/backend_unittest.ccnet/disk_cache/simple/simple_backend_impl.cc
Patch
From 7b15c3bbc9632e9868e4c10a32133cd82a024f12 Mon Sep 17 00:00:00 2001 From: Maks Orlovich <[email protected]> Date: Mon, 13 Jul 2026 07:45:31 -0700 Subject: [PATCH] SimpleCache: fix problems with self-deletion in post-doom callbacks Fixed: 533446300 Change-Id: I130245edb091f1d48437d74a59aa012667a6227b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8074044 Reviewed-by: Josh Karlin <[email protected]> Commit-Queue: Maks Orlovich <[email protected]> Cr-Commit-Position: refs/heads/main@{#1661113} --- diff --git a/net/disk_cache/backend_unittest.cc b/net/disk_cache/backend_unittest.cc index f6f049b2..d80bba0 100644 --- a/net/disk_cache/backend_unittest.cc +++ b/net/disk_cache/backend_unittest.cc @@ -6063,6 +6063,67 @@ EXPECT_EQ(cache_->GetMaxBytesForTesting(), base::ByteSize(0)); } +TEST_P(DiskCacheGenericBackendTest, DeleteBackendWithMassDoom) { + if (backend_to_test() == BackendToTest::kMemory) { + // Uninteresting w/memory since the delete is synchronous. + return; + } + + SetCacheType(net::APP_CACHE); // no optimistic ops. + + InitCache(); + for (int i = 0; i < 100; ++i) { + disk_cache::Entry* entry = nullptr; + ASSERT_THAT(CreateEntry(base::NumberToString(i), &entry), IsOk()); + entry->Close(); + } + // Get closes to actually close. + FlushQueueForTest(); + net::TestCompletionCallback cb; + + // Kick off a doom. + int rv = cache_->DoomAllEntries(cb.callback()); + EXPECT_EQ(net::ERR_IO_PENDING, rv); + // We need to go to event loop since DoomAllEntries in Simple has async index + // readiness hop, but we don't want to flush all the threads. + base::RunLoop().RunUntilIdle(); + + base::RunLoop run_loop; + + // Try to open a couple of entries, and delete it in the first callback that + // gets invoked. The second open should be safe since we don't go to event + // loop between the calls, so the callback can't be delivered yet. Also only + // one of the callbacks should be invoked per the cancellation semantics. + EntryResult result0 = cache_->OpenEntry( + "0", net::HIGHEST, base::BindLambdaForTesting([&](EntryResult result) { + EXPECT_EQ(net::ERR_FAILED, result.net_error()); + TakeCache(); + run_loop.Quit(); + })); + if (result0.net_error() == net::ERR_FAILED) { + // If the delete finished already to the point the open fails synchronously, + // we can't really test anything, so don't proceed. + return; + } + EXPECT_EQ(result0.net_error(), net::ERR_IO_PENDING); + + EntryResult result1 = cache_->OpenEntry( + "1", net::HIGHEST, base::BindLambdaForTesting([&](EntryResult result) { + EXPECT_EQ(net::ERR_FAILED, result.net_error()); + TakeCache(); + run_loop.Quit(); + })); + if (result1.net_error() == net::ERR_FAILED) { + // If the delete finished already to the point the open fails synchronously, + // we can't really test anything, so don't proceed. + return; + } + EXPECT_EQ(result1.net_error(), net::ERR_IO_PENDING); + + EXPECT_EQ(net::OK, cb.GetResult(rv)); + run_loop.Run(); +} + INSTANTIATE_TEST_SUITE_P( /* no name */, DiskCacheGenericBackendTest, diff --git a/net/disk_cache/simple/simple_backend_impl.cc b/net/disk_cache/simple/simple_backend_impl.cc index 518105a..f307268 100644 --- a/net/disk_cache/simple/simple_backend_impl.cc +++ b/net/disk_cache/simple/simple_backend_impl.cc @@ -913,8 +913,12 @@ std::unique_ptr<std::vector<uint64_t>> entry_hashes, CompletionOnceCallback callback, int result) { + // Save `post_doom_waiting_` locally in case something invoked from us + // deletes `this`. + scoped_refptr<SimplePostOperationWaiterTable> post_doom_waiting = + post_doom_waiting_; for (const uint64_t& entry_hash : *entry_hashes) - post_doom_waiting_->OnOperationComplete(entry_hash); + post_doom_waiting->OnOperationComplete(entry_hash); std::move(callback).Run(result); }
Regression Test / PoC
diff --git a/net/disk_cache/backend_unittest.cc b/net/disk_cache/backend_unittest.cc
index f6f049b2..d80bba0 100644
--- a/net/disk_cache/backend_unittest.cc
+++ b/net/disk_cache/backend_unittest.cc
@@ -6063,6 +6063,67 @@
EXPECT_EQ(cache_->GetMaxBytesForTesting(), base::ByteSize(0));
}
+TEST_P(DiskCacheGenericBackendTest, DeleteBackendWithMassDoom) {
+ if (backend_to_test() == BackendToTest::kMemory) {
+ // Uninteresting w/memory since the delete is synchronous.
+ return;
+ }
+
+ SetCacheType(net::APP_CACHE); // no optimistic ops.
+
+ InitCache();
+ for (int i = 0; i < 100; ++i) {
+ disk_cache::Entry* entry = nullptr;
+ ASSERT_THAT(CreateEntry(base::NumberToString(i), &entry), IsOk());
+ entry->Close();
+ }
+ // Get closes to actually close.
+ FlushQueueForTest();
+ net::TestCompletionCallback cb;
+
+ // Kick off a doom.
+ int rv = cache_->DoomAllEntries(cb.callback());
+ EXPECT_EQ(net::ERR_IO_PENDING, rv);
+ // We need to go to event loop since DoomAllEntries in Simple has async index
+ // readiness hop, but we don't want to flush all the threads.
+ base::RunLoop().RunUntilIdle();
+
+ base::RunLoop run_loop;
+
+ // Try to open a couple of entries, and delete it in the first callback that
+ // gets invoked. The second open should be safe since we don't go to event
+ // loop between the calls, so the callback can't be delivered yet. Also only
+ // one of the callbacks should be invoked per the cancellation semantics.
+ EntryResult result0 = cache_->OpenEntry(
+ "0", net::HIGHEST, base::BindLambdaForTesting([&](EntryResult result) {
+ EXPECT_EQ(net::ERR_FAILED, result.net_error());
+ TakeCache();
+ run_loop.Quit();
+ }));
+ if (result0.net_error() == net::ERR_FAILED) {
+ // If the delete finished already to the point the open fails synchronously,
+ // we can't really test anything, so don't proceed.
+ return;
+ }
+ EXPECT_EQ(result0.net_error(), net::ERR_IO_PENDING);
+
+ EntryResult result1 = cache_->OpenEntry(
+ "1", net::HIGHEST, base::BindLambdaForTesting([&](EntryResult result) {
+ EXPECT_EQ(net::ERR_FAILED, result.net_error());
+ TakeCache();
+ run_loop.Quit();
+ }));
+ if (result1.net_error() == net::ERR_FAILED) {
+ // If the delete finished already to the point the open fails synchronously,
+ // we can't really test anything, so don't proceed.
+ return;
+ }
+ EXPECT_EQ(result1.net_error(), net::ERR_IO_PENDING);
+
+ EXPECT_EQ(net::OK, cb.GetResult(rv));
+ run_loop.Run();
+}
+
INSTANTIATE_TEST_SUITE_P(
/* no name */,
DiskCacheGenericBackendTest,
Original Bug Report
Potential Critical UAF in SimpleBackendImpl::DoomEntriesComplete via sync CacheStorageCache deletion
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: SimplePostOperationWaiterTable::OnOperationComplete synchronously executes retry callbacks, bypassing the PostTask discipline. When CacheStorageCache is the consumer, this synchronous callback can drop the last handle to the cache, resulting in the synchronous destruction of SimpleBackendImpl. This leads to a Use-After-Free on the implicit this pointer in SimpleBackendImpl::DoomEntriesComplete, enabling potential RCE in the Browser Process.
Affected files:
net/disk_cache/simple/simple_backend_impl.ccnet/disk_cache/simple/post_operation_waiter.ccnet/disk_cache/simple/simple_entry_impl.ccnet/disk_cache/simple/post_operation_waiter.h
Estimated timestamp from git blame: 2023-11-27
1. Summary of the Issue (Meant for Human Triage)
An architectural invariant within Chromium’s Simple Disk Cache is that client-provided completion callbacks should never be executed synchronously on the active stack of an internal state transition. This is normally prevented by wrapping callback dispatches via PostClientCallback (in net/disk_cache/simple/simple_entry_impl.cc).
However, when operations are queued within the SimplePostOperationWaiterTable and then subsequently retried via RunEntryResultOperationAndCallback (inside SimplePostOperationWaiterTable::OnOperationComplete), this asynchronous boundary is bypassed. If the retried cache operation completes synchronously (such as during an index miss after doom), the consumer’s callback is executed inline.
While the original vulnerability report assumed that no in-tree consumer deletes the disk cache backend synchronously within transaction callbacks, this assumption is incorrect. CacheStorageCache in the Browser Process (which uses the Simple Cache backend) does synchronously delete its disk_cache::Backend when its last CacheStorageCacheHandle is dropped. By carefully staging a mass-doom eviction racing with a cache.match() operation, an attacker can trigger the destruction of SimpleBackendImpl from within the SimpleBackendImpl::DoomEntriesComplete iteration loop.
Because the this pointer in DoomEntriesComplete is an implicit bare pointer on the stack, this Use-After-Free falls into the MiraclePtr extraction window and is not protected by BackupRefPtr. An attacker can spray the PartitionAlloc heap to overwrite the freed SimpleBackendImpl chunk, hijack the post_doom_waiting_ pointer, and gain arbitrary code execution (RCE) in the Browser Process.
2. Proof-of-Concept & Detailed Execution Flow
The vulnerability is reached through a sequence of web platform API calls that manipulate the CacheStorageCache reference counts and the Simple Cache waiter table.
Potential Step-by-Step Trigger Path:
- Attacker Setup: The attacker controls a web page and calls
caches.open('test'). - Trigger Mass Eviction: The attacker populates the cache with enough entries to exceed the Simple cache index
high_watermark_. - Doom Entries Initiated: This triggers
SimpleIndex::StartEvictionIfNeeded, which selects multiple entries (e.g.,Hash 1andHash 2) for eviction and callsdelegate_->DoomEntries(hashes). - Backend Processes Doom:
SimpleBackendImpl::DoomEntriesprepares these hashes for doom by inserting an empty vector for each intopost_doom_waiting_. - Worker Task Posted:
DoomEntriesposts theDeleteEntrySetFilestask to a worker thread and setsSimpleBackendImpl::DoomEntriesCompleteas the reply callback. - Concurrent Match Request: The attacker concurrently calls
cache.match(url1)corresponding toHash 1. - Query Cache Scheduled: The
Matchoperation reachesCacheStorageCache::MatchImpland schedules aQueryCacheoperation. To keep the cache alive, the operation callback is wrapped viaWrapCallbackWithHandle(incache_storage_cache.h:570), creating an internalCacheStorageCacheHandle. - Operation Deferred: The
Matchoperation callsSimpleBackendImpl::OpenEntry(Hash 1). Detecting thatHash 1is actively being doomed (present inpost_doom_waiting_), it queues aRunEntryResultOperationAndCallbackretry closure insidepost_doom_waiting_[Hash 1]. - Drop Reference: The attacker navigates the iframe away or drops all JavaScript references to the cache (without explicitly calling
caches.delete). The Mojo connection closes, dropping the primary externalCacheStorageCacheHandleandCacheStorageHandle. - Single Handle Remaining: At this point, the ONLY remaining reference to the cache is the internal
CacheStorageCacheHandleheld by the pendingMatchoperation’s wrapped callback. - Worker Completes: The worker task
DeleteEntrySetFilesfinishes and posts its reply back to the main/IO thread. - Main Thread Execution:
SimpleBackendImpl::DoomEntriesCompletebegins executing on the main thread.// net/disk_cache/simple/simple_backend_impl.cc:912-919 void SimpleBackendImpl::DoomEntriesComplete(...) { for (const uint64_t& entry_hash : *entry_hashes) post_doom_waiting_->OnOperationComplete(entry_hash); // <-- Sync execution inside loop std::move(callback).Run(result); } - Loop Iteration 1: The loop starts its first iteration for
Hash 1and executespost_doom_waiting_->OnOperationComplete(Hash 1). - Closure Execution:
SimplePostOperationWaiterTable::OnOperationCompleteswaps the vector of deferred closures and executes them synchronously. - Retry OpenEntry: The deferred
RunEntryResultOperationAndCallbackclosure executes, re-runningOpenEntry(Hash 1). Since the entry was removed from the index by the doom operation,OpenEntrysynchronously returnsnet::ERR_FAILED. - Bypass PostTask:
RunEntryResultOperationAndCallbackobserves the non-ERR_IO_PENDINGreturn value and executes theMatchcallback (QueryCacheDidOpenFastPath) inline, bypassing the PostTask discipline. - Callback Unwinding:
QueryCacheDidOpenFastPathhandles the error and unwinds toMatchAllDidQueryCache, which runsRunWithHandle. - Handle Destruction:
RunWithHandlecompletes, and its localCacheStorageCacheHandlegoes out of scope. This decrements theCacheStorageCachereference count to 0. - Unreferenced Path: The handle’s destructor calls
CacheStorageCache::DropHandleRef, which delegates toCacheStorage::CacheUnreferenced. - ReleaseUnreferencedCaches:
CacheStoragedetects that the parentCacheStorageHandlecount has dropped to 0 and callsReleaseUnreferencedCaches(). This method iterates overcache_map_and synchronously callscache_map_it->second.reset(). - Backend Destruction: The
unique_ptr<CacheStorageCache>is destroyed, which synchronously callsbackend_.reset(). - Memory Freed:
SimpleBackendImplis destructed, freeing its memory back to PartitionAlloc. - Stack Unwinds: The entire synchronous destruction chain unwinds back to the
DoomEntriesCompleteloop. - Loop Iteration 2 (Use-After-Free): The loop advances to the second iteration for
Hash 2. It evaluatespost_doom_waiting_->OnOperationComplete(Hash 2)using the implicitthispointer that was just freed. - MiraclePtr Bypass: Because
thisis a bare pointer held in a register/stack frame for the duration of theDoomEntriesCompletefunction (the extraction window), MiraclePtr/BackupRefPtr does not quarantine the chunk or crash on access. - RCE Hijack: An attacker spraying the heap with carefully crafted chunks of the same size class can forge the freed
SimpleBackendImpland control thepost_doom_waiting_pointer.OnOperationCompleteis then called on the forged waiter table, executing a forgedOnceClosureand granting the attacker arbitrary code execution (RCE) in the Browser Process.
Suggested Fix:
The PostTask discipline must be enforced. In net/disk_cache/simple/simple_backend_impl.cc, the RunEntryResultOperationAndCallback and RunOperationAndCallback thunks must use base::SequencedTaskRunner::GetCurrentDefault()->PostTask to schedule the operation_callback instead of running it inline when net_error() != net::ERR_IO_PENDING. Additionally, DoomEntriesComplete should take a local scoped_refptr to post_doom_waiting_ and use a WeakPtr re-check loop if this liveness cannot be guaranteed.
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
* **Severity:** Critical (S0)
* **Brief Notes / Reasoning:**
The vulnerability is a Critical (S0) Use-After-Free in the Browser Process. The original report correctly identified the invariant violation (sync execution bypassing PostTask) and the unsafe `this` access loop, but incorrectly assumed no in-tree consumer synchronously deletes the backend. `CacheStorageCache` DOES synchronously delete its backend via `CacheStorage::CacheUnreferenced` when its last handle is dropped. Because this occurs inside `DoomEntriesComplete` where the implicit `this` pointer is a raw pointer (extraction window), MiraclePtr does not protect against the UAF. This allows arbitrary code execution (RCE) in the browser process.
Exhaustive Evidence Ledger:
-
CacheStorageCacheSync Deletion Proof:CacheStorageCacheis managed viaCacheStorageCacheHandle(CacheStorageRef). When the last handle drops,CacheStorageCache::DropHandleRef()is invoked (cache_storage_cache.cc:654), which callsCacheStorage::CacheUnreferenced(this). If the parentCacheStoragehas no other active references,CacheStorage::DropHandleRef()invokesReleaseUnreferencedCaches()(cache_storage.cc:994). This synchronously calls.reset()on the cache incache_map_. TheCacheStorageCachedestructor explicitly invokesbackend_.reset();(or allows the compiler-generated unique_ptr destructor to do so), synchronously destroyingSimpleBackendImpl. -
DoomEntriesCompleteExtraction Window Proof:SimpleBackendImpl::DoomEntriesCompleteis bound viaweak_ptr_factory_.GetWeakPtr()(simple_backend_impl.cc:361). This validates thatthisis alive at entry to the function. However, the iteration loop (simple_backend_impl.cc:916-917) uses the implicitthispointer (this->post_doom_waiting_).void SimpleBackendImpl::DoomEntriesComplete(...) { for (const uint64_t& entry_hash : *entry_hashes) post_doom_waiting_->OnOperationComplete(entry_hash); // Evaluates this->post_doom_waiting_If
SimpleBackendImplis destroyed during an iteration (via the synchronousOnOperationComplete->RunEntryResultOperationAndCallback->CacheStorageCachecallback -> Handle Drop ->backend_.reset()path), thethispointer remains a bare dangling pointer on the stack. According to the MiraclePtr guidelines, extraction window UAFs (where a pointer is extracted and used repeatedly across a free) bypassraw_ptrprotections. -
Synchronous Callback Execution Proof:
RunEntryResultOperationAndCallback(simple_backend_impl.cc:145-158) executes the split callback synchronously:std::move(split_callback.second).Run(std::move(operation_result));. This contradictssimple_entry_impl.cc:676which explicitly usesPostTaskto avoid reentrancy. -
Process Boundary: The UAF occurs in the Browser Process, as
CacheStorageCacheandSimpleBackendImplexecute there. -
Attacker Model: Web Adversary serving malicious JavaScript to a sandboxed renderer process.
-
Severity Evaluation: Browser-process memory corruption without MiraclePtr protection yields a base severity of Critical (S0).
Evaluated with Chrome root at commit: f4cb78b4ec077b7f51b504af9350cdc166d10c2f
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.