Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Network
DescriptionUse after free in Network
ComponentNetwork
Sub-componentSimpleCache
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 matches: post_doom_waiting_ hoisted to a stack scoped_refptr before the re-entrant OnOperationComplete loop. Web-reachability of the self-delete path is consumer/lifecycle-driven and not directly shown by the diff.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker533446300
Fix commit7b15c3bbc963 (chromium/src) +66/-1
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
CreditedGoogle
Reported2026-07-10 (per vendor advisory)
Fix landed2026-07-13 (public git)
Disclosed2026-07-16
Patch gap3 days — fix public in source before the advisory

Background

`SimpleBackendImpl`
The Simple disk cache backend in the network service that owns entries and drives mass-doom operations over their hashes.
`SimplePostOperationWaiterTable`
Ref-counted table (post_doom_waiting_) that queues callbacks waiting on a per-hash operation and fires them via OnOperationComplete.
Post-doom callback
Consumer-supplied completion run when a doomed entry’s hash clears; it executes arbitrary logic and may destroy the owning SimpleBackendImpl.
Use-after-free (UAF)
Dereferencing this or its members after the object has been freed, yielding corrupted or attacker-influenceable memory access.

Root Cause Analysis

SimpleBackendImpl::DoomEntriesComplete finishes a mass-doom by iterating *entry_hashes and calling post_doom_waiting_->OnOperationComplete(entry_hash) for each hash. OnOperationComplete synchronously dispatches queued post-doom callbacks, which run arbitrary consumer code that is allowed to delete the SimpleBackendImpl (e.g. the cache owner tears down the backend from within a failure/completion callback). Once this is freed, the loop’s next iteration still reads the member post_doom_waiting_ (and implicitly this), dereferencing freed memory — a classic re-entrancy UAF.

The root cause is accessing an owned member across a callback that can destroy the owner, without keeping that member independently alive.

The fix hoists a scoped_refptr<SimplePostOperationWaiterTable> onto the stack before the loop so the waiter table survives this’s destruction and is dereferenced independently of the freed backend.

Key insight
A synchronously-invoked completion callback can free the object mid-loop, so any member touched after the callback returns must be pinned to the stack first. The ref-counted post_doom_waiting_ was safe to outlive this, but only if a strong reference was taken before the re-entrant call, not read fresh through the freed this.

Attack Path

  1. Attacker (e.g. a compromised or malicious renderer driving cache activity, or a page triggering cache eviction/clearing) induces a mass-doom such as DoomAllEntries on the Simple cache.
  2. The consumer registers post-doom callbacks whose completion path destroys the SimpleBackendImpl (the DeleteBackendWithMassDoom scenario: a failing open callback tears down the backend via TakeCache).
  3. DoomEntriesComplete iterates the entry hashes and fires the first callback, which frees this.
  4. The loop continues and dereferences post_doom_waiting_ through the now-freed this, reading freed heap memory.
  5. If the freed region is reclaimed and shaped by attacker-controlled allocations, subsequent callback dispatch operates on corrupted state, enabling controlled memory corruption in the network service.

Impact Assessment

Use-after-free of the disk cache backend in the network service (network component), reachable through ordinary cache operations that many web-facing flows exercise. Exploitation can yield memory corruption and potentially remote code execution within the network service sandbox; at minimum it causes reliable crashes. The re-entrancy is consumer-driven but the triggering doom operations are within reach of untrusted web content.

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

Audit Directions

  • Re-entrant self-deletion in callback loops
    Audit every loop that invokes consumer callbacks (OnOperationComplete, Run, observer notifications) and then touches this or its members on a later iteration; pin needed members to the stack before the first dispatch.
  • Member access after `std::move(callback).Run()`
    Search backend and network completion paths where a member is dereferenced after running a caller-supplied callback that could destroy the owner, and convert owned ref-counted members to local scoped_refptr guards.
  • Weak/strong-ref discipline around teardown APIs
    Review TakeCache-style ownership-transfer and backend-destruction entry points reachable from completion callbacks to confirm no code path resumes work on the freed object.
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);
 }
 
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 [email protected]

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