Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Network
DescriptionUse after free in Network
ComponentNetwork
Bug ClassUAF
Tracker533446300
Fix commit7b15c3bbc963 (chromium/src) +66/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-16

Changed Functions

FunctionChangeNotes
TEST_P
net/disk_cache/backend_unittest.cc
modified
for
net/disk_cache/backend_unittest.cc
modified

Files Changed

  • net/disk_cache/backend_unittest.cc
  • net/disk_cache/simple/simple_backend_impl.cc
From 7b15c3bbc9632e9868e4c10a32133cd82a024f12 Mon Sep 17 00:00:00 2001
From: Maks Orlovich <morlovich@chromium.org>
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 <jkarlin@chromium.org>
Commit-Queue: Maks Orlovich <morlovich@chromium.org>
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);
 }
 
Loading diff…

Regression Test / PoC

shipped with the fix
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,
Loading diff…

Original Bug Report

reported by aw...@chromium.org

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.cc
  • net/disk_cache/simple/post_operation_waiter.cc
  • net/disk_cache/simple/simple_entry_impl.cc
  • net/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:

  1. Attacker Setup: The attacker controls a web page and calls caches.open('test').
  2. Trigger Mass Eviction: The attacker populates the cache with enough entries to exceed the Simple cache index high_watermark_.
  3. Doom Entries Initiated: This triggers SimpleIndex::StartEvictionIfNeeded, which selects multiple entries (e.g., Hash 1 and Hash 2) for eviction and calls delegate_->DoomEntries(hashes).
  4. Backend Processes Doom: SimpleBackendImpl::DoomEntries prepares these hashes for doom by inserting an empty vector for each into post_doom_waiting_.
  5. Worker Task Posted: DoomEntries posts the DeleteEntrySetFiles task to a worker thread and sets SimpleBackendImpl::DoomEntriesComplete as the reply callback.
  6. Concurrent Match Request: The attacker concurrently calls cache.match(url1) corresponding to Hash 1.
  7. Query Cache Scheduled: The Match operation reaches CacheStorageCache::MatchImpl and schedules a QueryCache operation. To keep the cache alive, the operation callback is wrapped via WrapCallbackWithHandle (in cache_storage_cache.h:570), creating an internal CacheStorageCacheHandle.
  8. Operation Deferred: The Match operation calls SimpleBackendImpl::OpenEntry(Hash 1). Detecting that Hash 1 is actively being doomed (present in post_doom_waiting_), it queues a RunEntryResultOperationAndCallback retry closure inside post_doom_waiting_[Hash 1].
  9. 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 external CacheStorageCacheHandle and CacheStorageHandle.
  10. Single Handle Remaining: At this point, the ONLY remaining reference to the cache is the internal CacheStorageCacheHandle held by the pending Match operation’s wrapped callback.
  11. Worker Completes: The worker task DeleteEntrySetFiles finishes and posts its reply back to the main/IO thread.
  12. Main Thread Execution: SimpleBackendImpl::DoomEntriesComplete begins 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);
    }
    
  13. Loop Iteration 1: The loop starts its first iteration for Hash 1 and executes post_doom_waiting_->OnOperationComplete(Hash 1).
  14. Closure Execution: SimplePostOperationWaiterTable::OnOperationComplete swaps the vector of deferred closures and executes them synchronously.
  15. Retry OpenEntry: The deferred RunEntryResultOperationAndCallback closure executes, re-running OpenEntry(Hash 1). Since the entry was removed from the index by the doom operation, OpenEntry synchronously returns net::ERR_FAILED.
  16. Bypass PostTask: RunEntryResultOperationAndCallback observes the non-ERR_IO_PENDING return value and executes the Match callback (QueryCacheDidOpenFastPath) inline, bypassing the PostTask discipline.
  17. Callback Unwinding: QueryCacheDidOpenFastPath handles the error and unwinds to MatchAllDidQueryCache, which runs RunWithHandle.
  18. Handle Destruction: RunWithHandle completes, and its local CacheStorageCacheHandle goes out of scope. This decrements the CacheStorageCache reference count to 0.
  19. Unreferenced Path: The handle’s destructor calls CacheStorageCache::DropHandleRef, which delegates to CacheStorage::CacheUnreferenced.
  20. ReleaseUnreferencedCaches: CacheStorage detects that the parent CacheStorageHandle count has dropped to 0 and calls ReleaseUnreferencedCaches(). This method iterates over cache_map_ and synchronously calls cache_map_it->second.reset().
  21. Backend Destruction: The unique_ptr<CacheStorageCache> is destroyed, which synchronously calls backend_.reset().
  22. Memory Freed: SimpleBackendImpl is destructed, freeing its memory back to PartitionAlloc.
  23. Stack Unwinds: The entire synchronous destruction chain unwinds back to the DoomEntriesComplete loop.
  24. Loop Iteration 2 (Use-After-Free): The loop advances to the second iteration for Hash 2. It evaluates post_doom_waiting_->OnOperationComplete(Hash 2) using the implicit this pointer that was just freed.
  25. MiraclePtr Bypass: Because this is a bare pointer held in a register/stack frame for the duration of the DoomEntriesComplete function (the extraction window), MiraclePtr/BackupRefPtr does not quarantine the chunk or crash on access.
  26. RCE Hijack: An attacker spraying the heap with carefully crafted chunks of the same size class can forge the freed SimpleBackendImpl and control the post_doom_waiting_ pointer. OnOperationComplete is then called on the forged waiter table, executing a forged OnceClosure and 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:

  • CacheStorageCache Sync Deletion Proof: CacheStorageCache is managed via CacheStorageCacheHandle (CacheStorageRef). When the last handle drops, CacheStorageCache::DropHandleRef() is invoked (cache_storage_cache.cc:654), which calls CacheStorage::CacheUnreferenced(this). If the parent CacheStorage has no other active references, CacheStorage::DropHandleRef() invokes ReleaseUnreferencedCaches() (cache_storage.cc:994). This synchronously calls .reset() on the cache in cache_map_. The CacheStorageCache destructor explicitly invokes backend_.reset(); (or allows the compiler-generated unique_ptr destructor to do so), synchronously destroying SimpleBackendImpl.

  • DoomEntriesComplete Extraction Window Proof: SimpleBackendImpl::DoomEntriesComplete is bound via weak_ptr_factory_.GetWeakPtr() (simple_backend_impl.cc:361). This validates that this is alive at entry to the function. However, the iteration loop (simple_backend_impl.cc:916-917) uses the implicit this pointer (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 SimpleBackendImpl is destroyed during an iteration (via the synchronous OnOperationComplete -> RunEntryResultOperationAndCallback -> CacheStorageCache callback -> Handle Drop -> backend_.reset() path), the this pointer 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) bypass raw_ptr protections.

  • 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 contradicts simple_entry_impl.cc:676 which explicitly uses PostTask to avoid reentrancy.

  • Process Boundary: The UAF occurs in the Browser Process, as CacheStorageCache and SimpleBackendImpl execute 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.

View on issue tracker