Medium watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Cache
DescriptionIncorrect reference resolution in Cache
ComponentCache
Sub-componentNet/disk_cache/sql
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker518062961
Fix commit1d8f576aeea3 (chromium/src) +110/-4
CWECWE-706
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-30 (per vendor advisory)
Fix landed2026-07-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

SQL disk cache backend
the net/disk_cache/sql/ implementation of the HTTP disk cache, which stores entries as database rows (identified by a res_id and a cache_key) and is driven through SqlBackendImpl and SqlPersistentStore.
In-memory index
an optional index_ held by the backend shard that maps a CacheEntryKey::Hash to res_ids; it is keyed by hash only, so two distinct keys with colliding hashes share a bucket, and kHashNotFound results are used as fast paths that skip the database.
Optimistic index removal
BackendShard::DoomEntry removes the (hash, res_id) pair from index_ before the database update runs, and relies on a completion callback to re-insert it if the database did not actually doom the row.
`Error::kNotFound`
the store result returned when the doom update modified no row, for example when the res_id taken from the hash bucket belongs to a row whose cache_key is a different key that happens to share the hash.

Root Cause Analysis

The commit message says BackendShard::DoomEntry in sql_persistent_store_backend_shard.cc optimistically removes the (hash, res_id) pair from the in-memory index and then posts the database doom. The edited code is a completion lambda (bound with weak_ptr, need_recovery_on_failure, hash, res_id). The hunk does not show the enclosing function’s name, so the link to BackendShard::DoomEntry comes from the commit message.

Before the fix, the lambda re-inserted the index entry only when result.result was neither Error::kOk nor Error::kNotFound. It treated kNotFound like success, as if the row were already gone. But kNotFound also comes back when the res_id found in the hash bucket belongs to a different key with a colliding hash. In that case the database row is untouched and still doomed=0, yet the index no longer lists it. That breaks the rule that the index must match the live rows on disk. From then on, kHashNotFound fast paths force a miss for the existing key, and SpeculativeCreateEntry can write a second live row for the same key.

The fix deletes the result.result != Error::kNotFound clause, so any result other than kOk now rolls back the optimistic removal by calling index_->Insert(hash, res_id). This makes the check stricter: more failure results now trigger recovery.

Key insight
The recovery branch treated kNotFound (“no row was changed”) as if the row had been doomed. Because the index is keyed by hash, a doom for one key could silently drop a different key’s index entry. The fix rolls back the index removal on every non-kOk result, including kNotFound.

Attack Path

  1. Existing entry indexed the SQL backend is in use with its in-memory index loaded, and a cached entry for key A is the only occupant of its CacheEntryKey::Hash bucket.
  2. Doom a colliding key something triggers DoomEntry for a key B that has no cache entry but has the same hash as A. The hash bucket resolves to A’s res_id, so the index pair is removed optimistically.
  3. Database rejects the mismatch the database update finds that the row’s cache_key is A, not B, modifies nothing, and returns Error::kNotFound. The pre-fix lambda skipped re-insertion for this result, so A drops out of the index while its row stays live.
  4. Inconsistent lookups later lookups for A hit the kHashNotFound fast path and report a miss. OpenOrCreateEntry / SpeculativeCreateEntry can then create a duplicate live row for A, which the regression test checks by asserting opened() and an entry count of 1.
  5. Downstream cache confusion (inferred) with two live rows for one key, a later lookup could resolve to the wrong row or return stale data. The patch shows only the index/row inconsistency and the duplicate-row risk, not a specific memory-safety sink.

Impact Assessment

The bug corrupts the HTTP disk cache’s state: a cached key goes missing from the index while still live on disk, and duplicate live rows for one key become possible. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Several preconditions apply: the SQL disk cache backend must be in use, which may depend on a feature or field-trial configuration (inferred); its in-memory index must be loaded; and the caller must trigger a doom for a key whose CacheEntryKey::hash() collides with an existing entry’s key. How hard such collisions are to find is not shown in the patch. Any security effect beyond cache inconsistency, such as serving or mixing the wrong cached response, is (inferred).

Changed Functions

FunctionChangeNotes
if
net/disk_cache/sql/sql_persistent_store_backend_shard.cc
modified
TEST_F
net/disk_cache/sql/sql_backend_impl_unittest.cc
modified
TEST_P
net/disk_cache/sql/sql_persistent_store_unittest.cc
modified

Files Changed

  • net/disk_cache/sql/sql_backend_impl_unittest.cc
  • net/disk_cache/sql/sql_persistent_store_backend_shard.cc
  • net/disk_cache/sql/sql_persistent_store_unittest.cc

Audit Directions

  • Optimistic-update rollback conditions
    find in-memory structures (indexes, size counters, active-entry maps) that are changed before an asynchronous database write. Check that every result meaning “nothing changed”, including not-found and no-op results, rolls the change back.
  • Hash-only keyed indexes
    wherever a lookup resolves a key to an ID by hash alone, check that the full key is verified before acting on that ID. Also check that a mismatch does not change state for the colliding key.
  • Fast-path negative lookups
    review kHashNotFound-style shortcuts that skip the database. If the index can lose entries, these shortcuts turn index drift into cache misses and duplicate rows, so look for other paths (eviction, optimistic-write failure) that can drop entries without a matching database change.
From 1d8f576aeea3ee0daeb3d680392a30a05e1c4f48 Mon Sep 17 00:00:00 2001
From: Tsuyoshi Horo <horo@chromium.org>
Date: Sun, 05 Jul 2026 21:30:44 -0700
Subject: [PATCH] net/disk_cache/sql: Re-insert in-memory index entry on kNotFound

BackendShard::DoomEntry optimistically removes the (hash, res_id) pair
from the in-memory index before posting the database update. The
recovery branch re-inserts it on database failure but excluded
Error::kNotFound. kNotFound is returned when the database row was not
modified (e.g. when res_id is present in the index for the key's hash
bucket but the row's cache_key belongs to a different key with a
colliding hash), so the index removal must be rolled back in that case
as well; otherwise the entry stays absent from the index while still
doomed=0 on disk, the kHashNotFound fast paths force a miss for that
key, and SpeculativeCreateEntry can write a duplicate live row for it.

Add unit tests that exercise the hash-collision case at the
SqlPersistentStore and SqlBackendImpl layers.

Bug: 518062961
Change-Id: I5c5413098c646b234addcb61871d8250892f5df2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8037960
Commit-Queue: Tsuyoshi Horo <horo@chromium.org>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1656973}
---
diff --git a/net/disk_cache/sql/sql_persistent_store_backend_shard.cc b/net/disk_cache/sql/sql_persistent_store_backend_shard.cc
index 99ac962..7db97f3 100644
--- a/net/disk_cache/sql/sql_persistent_store_backend_shard.cc
+++ b/net/disk_cache/sql/sql_persistent_store_backend_shard.cc
@@ -122,15 +122,17 @@
              bool need_recovery_on_failure, CacheEntryKey::Hash hash,
              ResId res_id, ErrorCallback callback, ErrorAndStoreStatus result) {
             if (weak_ptr) {
-              // If the DoomEntry operation fails in the database, the entry
-              // needs to be re-inserted into the in-memory index to maintain
-              // consistency.
+              // If the DoomEntry operation did not mark the row as doomed in
+              // the database (including `kNotFound`, which can occur when the
+              // `res_id` belongs to a different `cache_key` whose hash
+              // collides with `key`'s hash), the entry needs to be re-inserted
+              // into the in-memory index so that the index remains consistent
+              // with the unchanged database state.
               // Note: Optimistic write failure may trigger a call to DoomEntry,
               // which occurs without exclusive control. In this case, if
               // eviction runs immediately after Backend::DoomEntry, the index
               // might be missing.
               if (need_recovery_on_failure && result.result != Error::kOk &&
-                  result.result != Error::kNotFound &&
                   weak_ptr->index_.has_value()) {
                 weak_ptr->index_->Insert(hash, res_id);
               }
diff --git a/net/disk_cache/sql/sql_backend_impl_unittest.cc b/net/disk_cache/sql/sql_backend_impl_unittest.cc
index 0fc1c454..e36d61a 100644
--- a/net/disk_cache/sql/sql_backend_impl_unittest.cc
+++ b/net/disk_cache/sql/sql_backend_impl_unittest.cc
@@ -2408,6 +2408,73 @@
   EXPECT_THAT(open_result.net_error(), IsError(net::ERR_FAILED));
 }
 
+// Tests that dooming a non-existent key whose hash collides with an existing
+// entry's key does not affect the existing entry. The in-memory index is keyed
+// by hash only, so a single-entry hash bucket may resolve to a different key's
+// `res_id`; the backend must still keep the existing entry openable and avoid
+// creating duplicate rows for it.
+TEST_F(SqlBackendImplTest, DoomEntryWithInMemoryIndexHashCollision) {
+  // Two distinct keys with the same `CacheEntryKey::hash()`.
+  const std::string kExistingKey = "colliding-key-2018";
+  const std::string kCollidingKey = "colliding-key-3000";
+  const CacheEntryKey kExistingEntryKey(kExistingKey);
+  ASSERT_EQ(kExistingEntryKey.hash(), CacheEntryKey(kCollidingKey).hash());
+
+  auto backend = CreateBackendAndInit();
+
+  // 1. Create an entry for `kExistingKey` and close it so it is no longer
+  //    active.
+  TestEntryResultCompletionCallback cb_create;
+  disk_cache::EntryResult create_result = cb_create.GetResult(
+      backend->CreateEntry(kExistingKey, net::HIGHEST, cb_create.callback()));
+  ASSERT_THAT(create_result.net_error(), IsOk());
+  create_result.ReleaseEntry()->Close();
+  backend->RunUntilAllTasksCompleteForTest();
+
+  // 2. Load the in-memory index. `kExistingKey` is the sole occupant of its
+  //    hash bucket.
+  ASSERT_TRUE(LoadInMemoryIndex(*backend));
+  ASSERT_EQ(backend->GetSqlStoreForTest()->GetIndexStateForHash(
+                kExistingEntryKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // 3. Doom `kCollidingKey`, which does not exist but shares its hash with
+  //    `kExistingKey`.
+  net::TestCompletionCallback cb_doom;
+  EXPECT_THAT(cb_doom.GetResult(backend->DoomEntry(kCollidingKey, net::HIGHEST,
+                                                   cb_doom.callback())),
+              IsOk());
+  backend->RunUntilAllTasksCompleteForTest();
+
+  // 4. The existing entry must remain in the in-memory index.
+  EXPECT_EQ(backend->GetSqlStoreForTest()->GetIndexStateForHash(
+                kExistingEntryKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // 5. Opening `kExistingKey` must still succeed.
+  TestEntryResultCompletionCallback cb_open;
+  disk_cache::EntryResult open_result = cb_open.GetResult(
+      backend->OpenEntry(kExistingKey, net::HIGHEST, cb_open.callback()));
+  ASSERT_THAT(open_result.net_error(), IsOk());
+  open_result.ReleaseEntry()->Close();
+
+  // 6. OpenOrCreateEntry must open the existing entry rather than creating a
+  //    duplicate row.
+  TestEntryResultCompletionCallback cb_ooc;
+  disk_cache::EntryResult ooc_result =
+      cb_ooc.GetResult(backend->OpenOrCreateEntry(kExistingKey, net::HIGHEST,
+                                                  cb_ooc.callback()));
+  ASSERT_THAT(ooc_result.net_error(), IsOk());
+  EXPECT_TRUE(ooc_result.opened());
+  ooc_result.ReleaseEntry()->Close();
+  backend->RunUntilAllTasksCompleteForTest();
+
+  base::test::TestFuture<int32_t> count_future;
+  EXPECT_EQ(backend->GetEntryCount(count_future.GetCallback()),
+            base::unexpected(net::ERR_IO_PENDING));
+  EXPECT_EQ(count_future.Get(), 1);
+}
+
 TEST_F(SqlBackendImplTest, SetDataHintsAndDoomAndWriteOptimistically) {
   auto backend = CreateBackendAndInit();
   const std::string kKey = "my-key";
diff --git a/net/disk_cache/sql/sql_persistent_store_unittest.cc b/net/disk_cache/sql/sql_persistent_store_unittest.cc
index 15fe60f6..42cdece 100644
--- a/net/disk_cache/sql/sql_persistent_store_unittest.cc
+++ b/net/disk_cache/sql/sql_persistent_store_unittest.cc
@@ -5727,6 +5727,43 @@
   EXPECT_FALSE(open_result->has_value());
 }
 
+// Tests that when `DoomEntry` is called with a `res_id` that is present in the
+// in-memory index for the key's hash bucket but belongs to a different
+// `cache_key` in the database (so the operation returns `kNotFound`), the
+// optimistic in-memory index removal is rolled back to keep the index
+// consistent with the database.
+TEST_P(SqlPersistentStoreTest, DoomEntryRecoversIndexOnNotFound) {
+  CreateAndInitStore();
+  ASSERT_TRUE(LoadInMemoryIndex());
+
+  // Two distinct keys with the same `CacheEntryKey::hash()`.
+  const CacheEntryKey kExistingKey("colliding-key-2018");
+  const CacheEntryKey kCollidingKey("colliding-key-3000");
+  ASSERT_NE(kExistingKey, kCollidingKey);
+  ASSERT_EQ(kExistingKey.hash(), kCollidingKey.hash());
+
+  const auto res_id = CreateEntryAndGetResId(kExistingKey);
+  ASSERT_EQ(store_->GetIndexStateForHash(kExistingKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // Attempt to doom `kCollidingKey` using `kExistingKey`'s `res_id`. The
+  // database row's `cache_key` does not match, so the operation must report
+  // `kNotFound` without modifying the database.
+  EXPECT_EQ(DoomEntry(kCollidingKey, res_id),
+            SqlPersistentStore::Error::kNotFound);
+
+  // The existing entry must remain in the in-memory index.
+  EXPECT_EQ(store_->GetIndexStateForHash(kExistingKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // The existing entry must remain unaffected in the database as well.
+  EXPECT_EQ(GetEntryCount(), 1);
+  auto open_result = OpenEntry(kExistingKey);
+  ASSERT_TRUE(open_result.has_value());
+  ASSERT_TRUE(open_result->has_value());
+  EXPECT_EQ((*open_result)->res_id, res_id);
+}
+
 TEST_P(SqlPersistentStoreTest,
        DoomedEntryDuringEvictionRemovedFromIndexAfterReturn) {
   const int64_t kMaxBytes = 10000;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/disk_cache/sql/sql_backend_impl_unittest.cc b/net/disk_cache/sql/sql_backend_impl_unittest.cc
index 0fc1c454..e36d61a 100644
--- a/net/disk_cache/sql/sql_backend_impl_unittest.cc
+++ b/net/disk_cache/sql/sql_backend_impl_unittest.cc
@@ -2408,6 +2408,73 @@
   EXPECT_THAT(open_result.net_error(), IsError(net::ERR_FAILED));
 }
 
+// Tests that dooming a non-existent key whose hash collides with an existing
+// entry's key does not affect the existing entry. The in-memory index is keyed
+// by hash only, so a single-entry hash bucket may resolve to a different key's
+// `res_id`; the backend must still keep the existing entry openable and avoid
+// creating duplicate rows for it.
+TEST_F(SqlBackendImplTest, DoomEntryWithInMemoryIndexHashCollision) {
+  // Two distinct keys with the same `CacheEntryKey::hash()`.
+  const std::string kExistingKey = "colliding-key-2018";
+  const std::string kCollidingKey = "colliding-key-3000";
+  const CacheEntryKey kExistingEntryKey(kExistingKey);
+  ASSERT_EQ(kExistingEntryKey.hash(), CacheEntryKey(kCollidingKey).hash());
+
+  auto backend = CreateBackendAndInit();
+
+  // 1. Create an entry for `kExistingKey` and close it so it is no longer
+  //    active.
+  TestEntryResultCompletionCallback cb_create;
+  disk_cache::EntryResult create_result = cb_create.GetResult(
+      backend->CreateEntry(kExistingKey, net::HIGHEST, cb_create.callback()));
+  ASSERT_THAT(create_result.net_error(), IsOk());
+  create_result.ReleaseEntry()->Close();
+  backend->RunUntilAllTasksCompleteForTest();
+
+  // 2. Load the in-memory index. `kExistingKey` is the sole occupant of its
+  //    hash bucket.
+  ASSERT_TRUE(LoadInMemoryIndex(*backend));
+  ASSERT_EQ(backend->GetSqlStoreForTest()->GetIndexStateForHash(
+                kExistingEntryKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // 3. Doom `kCollidingKey`, which does not exist but shares its hash with
+  //    `kExistingKey`.
+  net::TestCompletionCallback cb_doom;
+  EXPECT_THAT(cb_doom.GetResult(backend->DoomEntry(kCollidingKey, net::HIGHEST,
+                                                   cb_doom.callback())),
+              IsOk());
+  backend->RunUntilAllTasksCompleteForTest();
+
+  // 4. The existing entry must remain in the in-memory index.
+  EXPECT_EQ(backend->GetSqlStoreForTest()->GetIndexStateForHash(
+                kExistingEntryKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // 5. Opening `kExistingKey` must still succeed.
+  TestEntryResultCompletionCallback cb_open;
+  disk_cache::EntryResult open_result = cb_open.GetResult(
+      backend->OpenEntry(kExistingKey, net::HIGHEST, cb_open.callback()));
+  ASSERT_THAT(open_result.net_error(), IsOk());
+  open_result.ReleaseEntry()->Close();
+
+  // 6. OpenOrCreateEntry must open the existing entry rather than creating a
+  //    duplicate row.
+  TestEntryResultCompletionCallback cb_ooc;
+  disk_cache::EntryResult ooc_result =
+      cb_ooc.GetResult(backend->OpenOrCreateEntry(kExistingKey, net::HIGHEST,
+                                                  cb_ooc.callback()));
+  ASSERT_THAT(ooc_result.net_error(), IsOk());
+  EXPECT_TRUE(ooc_result.opened());
+  ooc_result.ReleaseEntry()->Close();
+  backend->RunUntilAllTasksCompleteForTest();
+
+  base::test::TestFuture<int32_t> count_future;
+  EXPECT_EQ(backend->GetEntryCount(count_future.GetCallback()),
+            base::unexpected(net::ERR_IO_PENDING));
+  EXPECT_EQ(count_future.Get(), 1);
+}
+
 TEST_F(SqlBackendImplTest, SetDataHintsAndDoomAndWriteOptimistically) {
   auto backend = CreateBackendAndInit();
   const std::string kKey = "my-key";
diff --git a/net/disk_cache/sql/sql_persistent_store_unittest.cc b/net/disk_cache/sql/sql_persistent_store_unittest.cc
index 15fe60f6..42cdece 100644
--- a/net/disk_cache/sql/sql_persistent_store_unittest.cc
+++ b/net/disk_cache/sql/sql_persistent_store_unittest.cc
@@ -5727,6 +5727,43 @@
   EXPECT_FALSE(open_result->has_value());
 }
 
+// Tests that when `DoomEntry` is called with a `res_id` that is present in the
+// in-memory index for the key's hash bucket but belongs to a different
+// `cache_key` in the database (so the operation returns `kNotFound`), the
+// optimistic in-memory index removal is rolled back to keep the index
+// consistent with the database.
+TEST_P(SqlPersistentStoreTest, DoomEntryRecoversIndexOnNotFound) {
+  CreateAndInitStore();
+  ASSERT_TRUE(LoadInMemoryIndex());
+
+  // Two distinct keys with the same `CacheEntryKey::hash()`.
+  const CacheEntryKey kExistingKey("colliding-key-2018");
+  const CacheEntryKey kCollidingKey("colliding-key-3000");
+  ASSERT_NE(kExistingKey, kCollidingKey);
+  ASSERT_EQ(kExistingKey.hash(), kCollidingKey.hash());
+
+  const auto res_id = CreateEntryAndGetResId(kExistingKey);
+  ASSERT_EQ(store_->GetIndexStateForHash(kExistingKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // Attempt to doom `kCollidingKey` using `kExistingKey`'s `res_id`. The
+  // database row's `cache_key` does not match, so the operation must report
+  // `kNotFound` without modifying the database.
+  EXPECT_EQ(DoomEntry(kCollidingKey, res_id),
+            SqlPersistentStore::Error::kNotFound);
+
+  // The existing entry must remain in the in-memory index.
+  EXPECT_EQ(store_->GetIndexStateForHash(kExistingKey.hash()),
+            SqlPersistentStore::IndexState::kHashFound);
+
+  // The existing entry must remain unaffected in the database as well.
+  EXPECT_EQ(GetEntryCount(), 1);
+  auto open_result = OpenEntry(kExistingKey);
+  ASSERT_TRUE(open_result.has_value());
+  ASSERT_TRUE(open_result->has_value());
+  EXPECT_EQ((*open_result)->res_id, res_id);
+}
+
 TEST_P(SqlPersistentStoreTest,
        DoomedEntryDuringEvictionRemovedFromIndexAfterReturn) {
   const int64_t kMaxBytes = 10000;
Loading diff…

Original Bug Report

reported by vm...@google.com

SQL Disk Cache: Potential In-Memory Index Desynchronization on Hash Collision Doom

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: An issue in the experimental SQL disk cache backend can cause the in-memory index to permanently desynchronize from the SQLite database. This occurs when dooming an inactive cache entry that shares a 32-bit hash collision with another cached entry. The desynchronization leads to forced cache misses across Network Isolation Key (NIK) boundaries, potentially enabling cross-site tracking side channels.

Affected files:

  • net/disk_cache/sql/sql_persistent_store_backend_shard.cc
  • net/disk_cache/sql/sql_backend_impl.cc
  • net/disk_cache/sql/sql_persistent_store_backend.cc

Estimated timestamp from git blame: 2025-11-26

Description

A potential logic vulnerability exists in Chromium’s experimental SQL disk cache backend (gated under net::features::kDiskCacheBackendExperiment) that can cause the in-memory index (index_) to permanently desynchronize from the underlying SQLite database.

This desynchronization occurs when a doom operation is initiated for an entry K1 that is not currently in the cache, but whose 32-bit PersistentHash collides with a currently cached entry K2 (such that K1.hash() == K2.hash()).

Step-by-Step Code Flow Analysis

  1. Fast-path Hash Lookup: When a doom operation for cache key K1 is initiated, SqlBackendImpl::HandleDoomEntryOperation searches the in-memory index for a matching resource ID using only the 32-bit hash key K1.hash():

    // net/disk_cache/sql/sql_backend_impl.cc
    if (auto res_id = store_->TryGetSingleResIdFromInMemoryIndex(key.hash())) {
      store_->DoomEntry(key, *res_id, /*accept_index_mismatch=*/false, std::move(store_callback));
      return;
    }
    

    Because K2 is the unique occupant of the hash bucket in the in-memory index, TryGetSingleResIdFromInMemoryIndex(K1.hash()) returns K2’s res_id (e.g., R2). The fast-path logic assumes that this res_id corresponds to K1 and invokes store_->DoomEntry(K1, R2, ...). [net/disk_cache/sql/sql_backend_impl.cc, line 735]

  2. Synchronous Index Removal: In SqlPersistentStore::BackendShard::DoomEntry, the shard synchronously removes (K1.hash(), R2) from the index before writing to the database:

    // net/disk_cache/sql/sql_persistent_store_backend_shard.cc
    if (index_->Remove(key.hash(), res_id)) {
      need_recovery_on_failure = true;
    }
    

    Since K1.hash() == K2.hash(), index_->Remove successfully finds and deletes K2’s entry from the index. [net/disk_cache/sql/sql_persistent_store_backend_shard.cc, line 106]

  3. Database Execution & Rollback Failure: The background database thread executes UPDATE resources SET doomed=1 WHERE res_id=? AND cache_key=? AND doomed=0 with res_id = R2 and cache_key = K1. [net/disk_cache/sql/sql_persistent_store_queries.h, line 125]

    Since the database row for R2 has cache_key = K2, the query matches 0 rows and returns Error::kNotFound. [net/disk_cache/sql/sql_persistent_store_backend.cc, line 797]

    Back on the main thread, the callback processes the result. However, the rollback logic explicitly excludes Error::kNotFound from index recovery:

    // net/disk_cache/sql/sql_persistent_store_backend_shard.cc
    if (need_recovery_on_failure && result.result != Error::kOk &&
        result.result != Error::kNotFound &&
        weak_ptr->index_.has_value()) {
      weak_ptr->index_->Insert(hash, res_id);
    }
    

    As a result, K2’s res_id is permanently removed from the in-memory index while remaining live (doomed=0) in the database.

Potential Exploitation and Impact

These are potential/suggested steps derived from static analysis of the codebase, as our tooling agent does not have the ability to run code:

  1. Forced Cache Miss / Partitioning Bypass: An attacker on attacker.com brute-forces a cache key K1 that collides with a victim’s cached resource K2 under a different Network Isolation Key (NIK) partition.
  2. The attacker triggers a cache doom (e.g., via a POST request to K1).
  3. This removes K2’s key from the index. Subsequent fetch attempts for K2 check the index, return kHashNotFound, and result in a forced cache miss, bypassing NIK privacy boundaries to leak caching state or evict cross-site resources.
  4. Database Pollution: Subsequent creations of K2 set run_existance_check = false and insert duplicate rows into the database without checking, leaving old rows orphaned and leading to unbounded SQLite table growth.

Suggested Fix

To remediate this issue, apply one of the following approaches:

  1. Verify the cache_key matches the in-memory index entries (or avoid the fast-path when a hash collision is possible) before performing index_->Remove.
  2. Re-insert the entry into the index in the recovery path when Error::kNotFound is returned, provided recovery is active.

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


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