CVE-2026-79070
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fnet/disk_cache/sql/sql_backend_impl_unittest.cc |
modified | |
ifnet/disk_cache/sql/sql_persistent_store_backend_shard.cc |
modified | |
TEST_Pnet/disk_cache/sql/sql_persistent_store_unittest.cc |
modified |
Files Changed
net/disk_cache/sql/sql_backend_impl_unittest.ccnet/disk_cache/sql/sql_persistent_store_backend_shard.ccnet/disk_cache/sql/sql_persistent_store_unittest.cc
Patch
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_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_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_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;
Regression Test / PoC
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;
Original Bug Report
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.ccnet/disk_cache/sql/sql_backend_impl.ccnet/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
-
Fast-path Hash Lookup: When a doom operation for cache key
K1is initiated,SqlBackendImpl::HandleDoomEntryOperationsearches the in-memory index for a matching resource ID using only the 32-bit hash keyK1.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
K2is the unique occupant of the hash bucket in the in-memory index,TryGetSingleResIdFromInMemoryIndex(K1.hash())returnsK2’sres_id(e.g.,R2). The fast-path logic assumes that thisres_idcorresponds toK1and invokesstore_->DoomEntry(K1, R2, ...). [net/disk_cache/sql/sql_backend_impl.cc, line 735] -
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_->Removesuccessfully finds and deletesK2’s entry from the index. [net/disk_cache/sql/sql_persistent_store_backend_shard.cc, line 106] -
Database Execution & Rollback Failure: The background database thread executes
UPDATE resources SET doomed=1 WHERE res_id=? AND cache_key=? AND doomed=0withres_id = R2andcache_key = K1. [net/disk_cache/sql/sql_persistent_store_queries.h, line 125]Since the database row for
R2hascache_key = K2, the query matches 0 rows and returnsError::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::kNotFoundfrom 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’sres_idis 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:
- Forced Cache Miss / Partitioning Bypass:
An attacker on
attacker.combrute-forces a cache keyK1that collides with a victim’s cached resourceK2under a different Network Isolation Key (NIK) partition. - The attacker triggers a cache doom (e.g., via a POST request to
K1). - This removes
K2’s key from the index. Subsequent fetch attempts forK2check the index, returnkHashNotFound, and result in a forced cache miss, bypassing NIK privacy boundaries to leak caching state or evict cross-site resources. - Database Pollution:
Subsequent creations of
K2setrun_existance_check = falseand 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:
- Verify the
cache_keymatches the in-memory index entries (or avoid the fast-path when a hash collision is possible) before performingindex_->Remove. - Re-insert the entry into the index in the recovery path when
Error::kNotFoundis 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.