CVE-2026-106300
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/cache_storage/cache_storage_cache_entry_handler.cc |
modified | |
ifstorage/browser/blob/blob_impl.cc |
modified | |
ifstorage/browser/blob/blob_reader.cc |
modified | |
ShortReadCacheEntrycontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
ifcontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
ShortReadBackendcontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
side_data_read_len_content/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
TEST_Pcontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
TEST_Fcontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified | |
ShortReadDataHandlestorage/browser/blob/blob_impl_unittest.cc |
modified | |
actual_side_data_size_storage/browser/blob/blob_impl_unittest.cc |
modified |
Files Changed
content/browser/cache_storage/cache_storage_cache_unittest.ccstorage/browser/blob/blob_impl.ccstorage/browser/blob/blob_reader.cccontent/browser/cache_storage/cache_storage_cache_entry_handler.ccstorage/browser/blob/blob_impl_unittest.cc
Patch
From 982692b19811575ebebb25d0c46ee848201eaeb3 Mon Sep 17 00:00:00 2001
From: Tsuyoshi Horo <horo@chromium.org>
Date: Mon, 07 Sep 2026 20:54:48 -0700
Subject: [PATCH] CacheStorage: Return error on ReadSideData size mismatch
EntryReaderImpl::ReadSideData() allocates a mojo_base::BigBuffer sized
to the side-data stream's GetDataSize() and issues a disk_cache read
into it. The disk_cache read may complete with fewer bytes than
requested (for example, when a concurrent truncating WriteSideData on
the same entry lands between the size sample and the queued read).
To prevent callers from observing uninitialized or truncated data,
treat any read result that does not match the expected side data size
as a failure. This simplified error-on-mismatch approach ensures
memory safety without complex buffer clamping or resizing.
Specifically:
- EntryReaderImpl::ReadSideData() returns net::ERR_FAILED on mismatch.
- BlobImpl::ReadSideData() returns std::nullopt on mismatch.
- BlobReader::DidReadSideData() reports net::ERR_FAILED on mismatch.
Adds CacheStorageCacheTestP.ReadSideDataShortRead and
ReadSideDataZeroRead, which verify that mismatching reads are
treated as errors. Also adds BlobImplTest.ReadSideDataShortRead to
verify the Blob layer fails correctly.
Fixed: 500106110
Change-Id: I9d868784f52ec45b570d1a2720ab0cbd7cedac91
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8365944
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Commit-Queue: Tsuyoshi Horo <horo@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1693525}
---
diff --git a/content/browser/cache_storage/cache_storage_cache_entry_handler.cc b/content/browser/cache_storage/cache_storage_cache_entry_handler.cc
index f990f66..21b61de 100644
--- a/content/browser/cache_storage/cache_storage_cache_entry_handler.cc
+++ b/content/browser/cache_storage/cache_storage_cache_entry_handler.cc
@@ -142,15 +142,24 @@
// Use a WrappedIOBuffer so that the DiskCacheBlobEntry writes directly
// to the BigBuffer without a copy.
int length = blob_entry_->GetSize(side_data_disk_cache_index_);
+ if (length <= 0) {
+ std::move(callback).Run(length, mojo_base::BigBuffer());
+ return;
+ }
mojo_base::BigBuffer output_buf(static_cast<size_t>(length));
auto wrapped_buf = base::MakeRefCounted<net::WrappedIOBuffer>(output_buf);
auto split_callback = base::SplitOnceCallback(base::BindOnce(
- [](mojo_base::BigBuffer output_buf, ReadSideDataCallback callback,
- int result) {
+ [](int expected_length, mojo_base::BigBuffer output_buf,
+ ReadSideDataCallback callback, int result) {
+ if (result != expected_length) {
+ std::move(callback).Run(result < 0 ? result : net::ERR_FAILED,
+ mojo_base::BigBuffer());
+ return;
+ }
std::move(callback).Run(result, std::move(output_buf));
},
- std::move(output_buf), std::move(callback)));
+ length, std::move(output_buf), std::move(callback)));
uint64_t offset = 0;
int result =
diff --git a/storage/browser/blob/blob_impl.cc b/storage/browser/blob/blob_impl.cc
index 6ae354a9..f167d63 100644
--- a/storage/browser/blob/blob_impl.cc
+++ b/storage/browser/blob/blob_impl.cc
@@ -176,15 +176,15 @@
return;
}
item->data_handle()->ReadSideData(base::BindOnce(
- [](ReadSideDataCallback callback, int result,
+ [](int32_t expected_size, ReadSideDataCallback callback, int result,
mojo_base::BigBuffer buffer) {
- if (result < 0) {
+ if (result < 0 || result != expected_size) {
std::move(callback).Run(std::nullopt);
return;
}
std::move(callback).Run(std::move(buffer));
},
- std::move(callback)));
+ body_size, std::move(callback)));
},
*handle_, std::move(callback)));
}
diff --git a/storage/browser/blob/blob_reader.cc b/storage/browser/blob/blob_reader.cc
index 4962eb38..d7b5da48 100644
--- a/storage/browser/blob/blob_reader.cc
+++ b/storage/browser/blob/blob_reader.cc
@@ -153,15 +153,14 @@
int result,
mojo_base::BigBuffer data) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
- if (result >= 0) {
- DCHECK_EQ(expected_size, result);
+ if (result == expected_size) {
DCHECK_EQ(static_cast<size_t>(expected_size), data.size());
RecordBytesReadFromDataHandle(/* item_index= */ 0, result);
side_data_ = std::move(data);
std::move(done).Run(Status::DONE);
return;
}
- std::move(done).Run(ReportError(result));
+ std::move(done).Run(ReportError(result < 0 ? result : net::ERR_FAILED));
}
BlobReader::Status BlobReader::SetReadRange(uint64_t offset, uint64_t length) {
diff --git a/content/browser/cache_storage/cache_storage_cache_unittest.cc b/content/browser/cache_storage/cache_storage_cache_unittest.cc
index 910f9e3..b08afd1 100644
--- a/content/browser/cache_storage/cache_storage_cache_unittest.cc
+++ b/content/browser/cache_storage/cache_storage_cache_unittest.cc
@@ -376,6 +376,174 @@
FailureStage stage_;
};
+// A disk_cache::Entry wrapper that returns fewer bytes than requested when
+// reading side data, to simulate the side data being truncated by another
+// caller after GetDataSize() was sampled.
+class ShortReadCacheEntry : public disk_cache::Entry {
+ public:
+ ShortReadCacheEntry(disk_cache::Entry* entry, int side_data_read_len)
+ : entry_(entry), side_data_read_len_(side_data_read_len) {}
+
+ void Doom() override { entry_->Doom(); }
+ void Close() override {
+ entry_.ExtractAsDangling()->Close();
+ delete this;
+ }
+ std::string GetKey() const override { return entry_->GetKey(); }
+ base::Time GetLastUsed() const override { return entry_->GetLastUsed(); }
+ int64_t GetDataSize(int index) const override {
+ return entry_->GetDataSize(index);
+ }
+ int ReadData(int index,
+ int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ if (index == CacheStorageCache::INDEX_SIDE_DATA) {
+ buf_len = std::min(buf_len, side_data_read_len_);
+ }
+ return entry_->ReadData(index, offset, buf, buf_len, std::move(callback));
+ }
+ int WriteData(int index,
+ int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback,
+ bool truncate) override {
+ return entry_->WriteData(index, offset, buf, buf_len, std::move(callback),
+ truncate);
+ }
+ int ReadSparseData(int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ return entry_->ReadSparseData(offset, buf, buf_len, std::move(callback));
+ }
+ int WriteSparseData(int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ return entry_->WriteSparseData(offset, buf, buf_len, std::move(callback));
+ }
+ disk_cache::RangeResult GetAvailableRange(
+ int64_t offset,
+ int len,
+ disk_cache::RangeResultCallback callback) override {
+ return entry_->GetAvailableRange(offset, len, std::move(callback));
+ }
+ bool CouldBeSparse() const override { return entry_->CouldBeSparse(); }
+ void CancelSparseIO() override { entry_->CancelSparseIO(); }
+ net::Error ReadyForSparseIO(CompletionOnceCallback callback) override {
+ return entry_->ReadyForSparseIO(std::move(callback));
+ }
+ void SetLastUsedTimeForTest(base::Time time) override {
+ entry_->SetLastUsedTimeForTest(time);
+ }
+
+ private:
+ ~ShortReadCacheEntry() override = default;
+
+ raw_ptr<disk_cache::Entry> entry_;
+ const int side_data_read_len_;
+};
+
+// A disk_cache::Backend wrapper that wraps entries returned from OpenEntry()
+// in a ShortReadCacheEntry.
+class ShortReadBackend : public disk_cache::Backend {
+ public:
+ ShortReadBackend(std::unique_ptr<disk_cache::Backend> backend,
+ int side_data_read_len)
+ : Backend(backend->GetCacheType()),
+ backend_(std::move(backend)),
+ side_data_read_len_(side_data_read_len) {}
+
+ // disk_cache::Backend overrides
+ base::expected<int32_t, net::Error> GetEntryCount(
+ GetEntryCountCallback callback) const override {
+ return backend_->GetEntryCount(std::move(callback));
+ }
+
+ EntryResult OpenEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ auto split_callback = base::SplitOnceCallback(
+ base::BindOnce(&ShortReadBackend::WrapEntry, side_data_read_len_,
+ std::move(callback)));
+ EntryResult result = backend_->OpenEntry(key, request_priority,
+ std::move(split_callback.first));
+ if (result.net_error() == net::ERR_IO_PENDING) {
+ return result;
+ }
+ std::move(split_callback.second).Run(std::move(result));
+ return EntryResult::MakeError(net::ERR_IO_PENDING);
+ }
+
+ EntryResult CreateEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ return backend_->CreateEntry(key, request_priority, std::move(callback));
+ }
+ EntryResult OpenOrCreateEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ return backend_->OpenOrCreateEntry(key, request_priority,
+ std::move(callback));
+ }
+ net::Error DoomEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntry(key, request_priority, std::move(callback));
+ }
+ net::Error DoomAllEntries(CompletionOnceCallback callback) override {
+ return backend_->DoomAllEntries(std::move(callback));
+ }
+ net::Error DoomEntriesBetween(base::Time initial_time,
+ base::Time end_time,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntriesBetween(initial_time, end_time,
+ std::move(callback));
+ }
+ net::Error DoomEntriesSince(base::Time initial_time,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntriesSince(initial_time, std::move(callback));
+ }
+ int64_t CalculateSizeOfAllEntries(
+ Int64CompletionOnceCallback callback) override {
+ return backend_->CalculateSizeOfAllEntries(std::move(callback));
+ }
+ std::unique_ptr<Iterator> CreateIterator() override {
+ return backend_->CreateIterator();
+ }
+ void GetStats(base::StringPairs* stats) override {
+ return backend_->GetStats(stats);
+ }
+ void OnExternalCacheHit(const std::string& key) override {
+ return backend_->OnExternalCacheHit(key);
+ }
+ int64_t MaxFileSize() const override { return backend_->MaxFileSize(); }
+ void SetMaxBytes(base::ByteSize max_bytes) override {
+ backend_->SetMaxBytes(max_bytes);
+ }
+ base::ByteSize GetMaxBytesForTesting() const override {
+ return backend_->GetMaxBytesForTesting();
+ }
+
+ private:
+ static void WrapEntry(int side_data_read_len,
+ EntryResultCallback callback,
+ EntryResult result) {
+ if (result.net_error() != net::OK) {
+ std::move(callback).Run(std::move(result));
+ return;
+ }
+ std::move(callback).Run(EntryResult::MakeOpened(
+ new ShortReadCacheEntry(result.ReleaseEntry(), side_data_read_len)));
+ }
+
+ std::unique_ptr<disk_cache::Backend> backend_;
+ const int side_data_read_len_;
+};
+
std::string CopySideData(blink::mojom::Blob* actual_blob) {
std::string output;
base::RunLoop loop;
@@ -508,6 +676,12 @@
backend_ = std::move(failable_backend);
}
+ void UseShortReadBackend(int side_data_read_len) {
+ EXPECT_TRUE(backend_);
+ backend_ = std::make_unique<ShortReadBackend>(std::move(backend_),
+ side_data_read_len);
+ }
+
void Init() { InitBackend(); }
base::CheckedNumeric<uint64_t> GetRequiredSafeSpaceForRequest(
@@ -2048,6 +2222,55 @@
EXPECT_EQ(CacheStorageError::kErrorNotFound, callback_error_);
}
+TEST_P(CacheStorageCacheTestP, ReadSideDataShortRead) {
+ base::Time response_time(base::Time::Now());
+ blink::mojom::FetchAPIResponsePtr response = CreateBlobBodyResponse();
+ response->response_time = response_time;
+ EXPECT_TRUE(Put(body_request_, std::move(response)));
+
+ const std::string side_data(2048, 'X');
+ scoped_refptr<net::IOBuffer> buffer =
+ base::MakeRefCounted<net::StringIOBuffer>(side_data);
+ EXPECT_TRUE(WriteSideData(body_request_->url, response_time, buffer,
+ side_data.length()));
+
+ // Simulate the side data stream being shorter at read time than the value
+ // returned by GetDataSize() when the read was issued.
+ cache_->UseShortReadBackend(/*side_data_read_len=*/1);
+
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_->blob);
+ mojo::Remote<blink::mojom::Blob> blob(
+ std::move(callback_response_->blob->blob));
+ // Since it is a short read, it should be treated as an error and return
+ // empty.
+ EXPECT_EQ("", CopySideData(blob.get()));
+}
+
+TEST_P(CacheStorageCacheTestP, ReadSideDataZeroRead) {
+ base::Time response_time(base::Time::Now());
+ blink::mojom::FetchAPIResponsePtr response = CreateBlobBodyResponse();
+ response->response_time = response_time;
+ EXPECT_TRUE(Put(body_request_, std::move(response)));
+
+ const std::string side_data(2048, 'X');
+ scoped_refptr<net::IOBuffer> buffer =
+ base::MakeRefCounted<net::StringIOBuffer>(side_data);
+ EXPECT_TRUE(WriteSideData(body_request_->url, response_time, buffer,
+ side_data.length()));
+
+ // Simulate the side data stream returning 0 bytes at read time despite
+ // GetDataSize() reporting a positive value when sampled.
+ cache_->UseShortReadBackend(/*side_data_read_len=*/0);
+
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_->blob);
+ mojo::Remote<blink::mojom::Blob> blob(
+ std::move(callback_response_->blob->blob));
+ // Since 0 bytes were read, an empty string should be returned.
+ EXPECT_EQ("", CopySideData(blob.get()));
+}
+
TEST_F(CacheStorageCacheTest, CaselessServiceWorkerFetchRequestHeaders) {
// CacheStorageCache depends on blink::mojom::FetchAPIRequest having caseless
// headers so that it can quickly lookup vary headers.
diff --git a/storage/browser/blob/blob_impl_unittest.cc b/storage/browser/blob/blob_impl_unittest.cc
index e72f83a..42bca6b0 100644
--- a/storage/browser/blob/blob_impl_unittest.cc
+++ b/storage/browser/blob/blob_impl_unittest.cc
@@ -14,6 +14,7 @@
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/string_view_util.h"
+#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
@@ -22,6 +23,7 @@
#include "net/base/net_errors.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
#include "storage/browser/blob/blob_storage_context.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -68,6 +70,41 @@
uint64_t data_length_ = 0;
};
+class ShortReadDataHandle : public BlobDataItem::DataHandle {
+ public:
+ ShortReadDataHandle(uint64_t size,
+ uint64_t side_data_size,
+ uint64_t actual_side_data_size)
+ : size_(size),
+ side_data_size_(side_data_size),
+ actual_side_data_size_(actual_side_data_size) {}
+
+ uint64_t GetSize() const override { return size_; }
+ void Read(mojo::ScopedDataPipeProducerHandle /*producer*/,
+ uint64_t /*src_offset*/,
+ uint64_t /*bytes_to_read*/,
+ base::OnceCallback<void(int)> callback) override {
+ std::move(callback).Run(net::OK);
+ }
+ uint64_t GetSideDataSize() const override { return side_data_size_; }
+ void ReadSideData(
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/cache_storage/cache_storage_cache_unittest.cc b/content/browser/cache_storage/cache_storage_cache_unittest.cc
index 910f9e3..b08afd1 100644
--- a/content/browser/cache_storage/cache_storage_cache_unittest.cc
+++ b/content/browser/cache_storage/cache_storage_cache_unittest.cc
@@ -376,6 +376,174 @@
FailureStage stage_;
};
+// A disk_cache::Entry wrapper that returns fewer bytes than requested when
+// reading side data, to simulate the side data being truncated by another
+// caller after GetDataSize() was sampled.
+class ShortReadCacheEntry : public disk_cache::Entry {
+ public:
+ ShortReadCacheEntry(disk_cache::Entry* entry, int side_data_read_len)
+ : entry_(entry), side_data_read_len_(side_data_read_len) {}
+
+ void Doom() override { entry_->Doom(); }
+ void Close() override {
+ entry_.ExtractAsDangling()->Close();
+ delete this;
+ }
+ std::string GetKey() const override { return entry_->GetKey(); }
+ base::Time GetLastUsed() const override { return entry_->GetLastUsed(); }
+ int64_t GetDataSize(int index) const override {
+ return entry_->GetDataSize(index);
+ }
+ int ReadData(int index,
+ int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ if (index == CacheStorageCache::INDEX_SIDE_DATA) {
+ buf_len = std::min(buf_len, side_data_read_len_);
+ }
+ return entry_->ReadData(index, offset, buf, buf_len, std::move(callback));
+ }
+ int WriteData(int index,
+ int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback,
+ bool truncate) override {
+ return entry_->WriteData(index, offset, buf, buf_len, std::move(callback),
+ truncate);
+ }
+ int ReadSparseData(int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ return entry_->ReadSparseData(offset, buf, buf_len, std::move(callback));
+ }
+ int WriteSparseData(int64_t offset,
+ IOBuffer* buf,
+ int buf_len,
+ CompletionOnceCallback callback) override {
+ return entry_->WriteSparseData(offset, buf, buf_len, std::move(callback));
+ }
+ disk_cache::RangeResult GetAvailableRange(
+ int64_t offset,
+ int len,
+ disk_cache::RangeResultCallback callback) override {
+ return entry_->GetAvailableRange(offset, len, std::move(callback));
+ }
+ bool CouldBeSparse() const override { return entry_->CouldBeSparse(); }
+ void CancelSparseIO() override { entry_->CancelSparseIO(); }
+ net::Error ReadyForSparseIO(CompletionOnceCallback callback) override {
+ return entry_->ReadyForSparseIO(std::move(callback));
+ }
+ void SetLastUsedTimeForTest(base::Time time) override {
+ entry_->SetLastUsedTimeForTest(time);
+ }
+
+ private:
+ ~ShortReadCacheEntry() override = default;
+
+ raw_ptr<disk_cache::Entry> entry_;
+ const int side_data_read_len_;
+};
+
+// A disk_cache::Backend wrapper that wraps entries returned from OpenEntry()
+// in a ShortReadCacheEntry.
+class ShortReadBackend : public disk_cache::Backend {
+ public:
+ ShortReadBackend(std::unique_ptr<disk_cache::Backend> backend,
+ int side_data_read_len)
+ : Backend(backend->GetCacheType()),
+ backend_(std::move(backend)),
+ side_data_read_len_(side_data_read_len) {}
+
+ // disk_cache::Backend overrides
+ base::expected<int32_t, net::Error> GetEntryCount(
+ GetEntryCountCallback callback) const override {
+ return backend_->GetEntryCount(std::move(callback));
+ }
+
+ EntryResult OpenEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ auto split_callback = base::SplitOnceCallback(
+ base::BindOnce(&ShortReadBackend::WrapEntry, side_data_read_len_,
+ std::move(callback)));
+ EntryResult result = backend_->OpenEntry(key, request_priority,
+ std::move(split_callback.first));
+ if (result.net_error() == net::ERR_IO_PENDING) {
+ return result;
+ }
+ std::move(split_callback.second).Run(std::move(result));
+ return EntryResult::MakeError(net::ERR_IO_PENDING);
+ }
+
+ EntryResult CreateEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ return backend_->CreateEntry(key, request_priority, std::move(callback));
+ }
+ EntryResult OpenOrCreateEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ EntryResultCallback callback) override {
+ return backend_->OpenOrCreateEntry(key, request_priority,
+ std::move(callback));
+ }
+ net::Error DoomEntry(const std::string& key,
+ net::RequestPriority request_priority,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntry(key, request_priority, std::move(callback));
+ }
+ net::Error DoomAllEntries(CompletionOnceCallback callback) override {
+ return backend_->DoomAllEntries(std::move(callback));
+ }
+ net::Error DoomEntriesBetween(base::Time initial_time,
+ base::Time end_time,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntriesBetween(initial_time, end_time,
+ std::move(callback));
+ }
+ net::Error DoomEntriesSince(base::Time initial_time,
+ CompletionOnceCallback callback) override {
+ return backend_->DoomEntriesSince(initial_time, std::move(callback));
+ }
+ int64_t CalculateSizeOfAllEntries(
+ Int64CompletionOnceCallback callback) override {
+ return backend_->CalculateSizeOfAllEntries(std::move(callback));
+ }
+ std::unique_ptr<Iterator> CreateIterator() override {
+ return backend_->CreateIterator();
+ }
+ void GetStats(base::StringPairs* stats) override {
+ return backend_->GetStats(stats);
+ }
+ void OnExternalCacheHit(const std::string& key) override {
+ return backend_->OnExternalCacheHit(key);
+ }
+ int64_t MaxFileSize() const override { return backend_->MaxFileSize(); }
+ void SetMaxBytes(base::ByteSize max_bytes) override {
+ backend_->SetMaxBytes(max_bytes);
+ }
+ base::ByteSize GetMaxBytesForTesting() const override {
+ return backend_->GetMaxBytesForTesting();
+ }
+
+ private:
+ static void WrapEntry(int side_data_read_len,
+ EntryResultCallback callback,
+ EntryResult result) {
+ if (result.net_error() != net::OK) {
+ std::move(callback).Run(std::move(result));
+ return;
+ }
+ std::move(callback).Run(EntryResult::MakeOpened(
+ new ShortReadCacheEntry(result.ReleaseEntry(), side_data_read_len)));
+ }
+
+ std::unique_ptr<disk_cache::Backend> backend_;
+ const int side_data_read_len_;
+};
+
std::string CopySideData(blink::mojom::Blob* actual_blob) {
std::string output;
base::RunLoop loop;
@@ -508,6 +676,12 @@
backend_ = std::move(failable_backend);
}
+ void UseShortReadBackend(int side_data_read_len) {
+ EXPECT_TRUE(backend_);
+ backend_ = std::make_unique<ShortReadBackend>(std::move(backend_),
+ side_data_read_len);
+ }
+
void Init() { InitBackend(); }
base::CheckedNumeric<uint64_t> GetRequiredSafeSpaceForRequest(
@@ -2048,6 +2222,55 @@
EXPECT_EQ(CacheStorageError::kErrorNotFound, callback_error_);
}
+TEST_P(CacheStorageCacheTestP, ReadSideDataShortRead) {
+ base::Time response_time(base::Time::Now());
+ blink::mojom::FetchAPIResponsePtr response = CreateBlobBodyResponse();
+ response->response_time = response_time;
+ EXPECT_TRUE(Put(body_request_, std::move(response)));
+
+ const std::string side_data(2048, 'X');
+ scoped_refptr<net::IOBuffer> buffer =
+ base::MakeRefCounted<net::StringIOBuffer>(side_data);
+ EXPECT_TRUE(WriteSideData(body_request_->url, response_time, buffer,
+ side_data.length()));
+
+ // Simulate the side data stream being shorter at read time than the value
+ // returned by GetDataSize() when the read was issued.
+ cache_->UseShortReadBackend(/*side_data_read_len=*/1);
+
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_->blob);
+ mojo::Remote<blink::mojom::Blob> blob(
+ std::move(callback_response_->blob->blob));
+ // Since it is a short read, it should be treated as an error and return
+ // empty.
+ EXPECT_EQ("", CopySideData(blob.get()));
+}
+
+TEST_P(CacheStorageCacheTestP, ReadSideDataZeroRead) {
+ base::Time response_time(base::Time::Now());
+ blink::mojom::FetchAPIResponsePtr response = CreateBlobBodyResponse();
+ response->response_time = response_time;
+ EXPECT_TRUE(Put(body_request_, std::move(response)));
+
+ const std::string side_data(2048, 'X');
+ scoped_refptr<net::IOBuffer> buffer =
+ base::MakeRefCounted<net::StringIOBuffer>(side_data);
+ EXPECT_TRUE(WriteSideData(body_request_->url, response_time, buffer,
+ side_data.length()));
+
+ // Simulate the side data stream returning 0 bytes at read time despite
+ // GetDataSize() reporting a positive value when sampled.
+ cache_->UseShortReadBackend(/*side_data_read_len=*/0);
+
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_->blob);
+ mojo::Remote<blink::mojom::Blob> blob(
+ std::move(callback_response_->blob->blob));
+ // Since 0 bytes were read, an empty string should be returned.
+ EXPECT_EQ("", CopySideData(blob.get()));
+}
+
TEST_F(CacheStorageCacheTest, CaselessServiceWorkerFetchRequestHeaders) {
// CacheStorageCache depends on blink::mojom::FetchAPIRequest having caseless
// headers so that it can quickly lookup vary headers.
diff --git a/storage/browser/blob/blob_impl_unittest.cc b/storage/browser/blob/blob_impl_unittest.cc
index e72f83a..42bca6b0 100644
--- a/storage/browser/blob/blob_impl_unittest.cc
+++ b/storage/browser/blob/blob_impl_unittest.cc
@@ -14,6 +14,7 @@
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/string_view_util.h"
+#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
@@ -22,6 +23,7 @@
#include "net/base/net_errors.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
#include "storage/browser/blob/blob_storage_context.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -68,6 +70,41 @@
uint64_t data_length_ = 0;
};
+class ShortReadDataHandle : public BlobDataItem::DataHandle {
+ public:
+ ShortReadDataHandle(uint64_t size,
+ uint64_t side_data_size,
+ uint64_t actual_side_data_size)
+ : size_(size),
+ side_data_size_(side_data_size),
+ actual_side_data_size_(actual_side_data_size) {}
+
+ uint64_t GetSize() const override { return size_; }
+ void Read(mojo::ScopedDataPipeProducerHandle /*producer*/,
+ uint64_t /*src_offset*/,
+ uint64_t /*bytes_to_read*/,
+ base::OnceCallback<void(int)> callback) override {
+ std::move(callback).Run(net::OK);
+ }
+ uint64_t GetSideDataSize() const override { return side_data_size_; }
+ void ReadSideData(
+ base::OnceCallback<void(int, mojo_base::BigBuffer)> callback) override {
+ mojo_base::BigBuffer buffer(static_cast<size_t>(side_data_size_));
+ std::ranges::fill(buffer, 'X');
+ std::move(callback).Run(static_cast<int>(actual_side_data_size_),
+ std::move(buffer));
+ }
+ void PrintTo(::std::ostream* os) const override {
+ *os << "<ShortReadDataHandle>";
+ }
+
... (truncated)
Original Bug Report
Browser heap info leak in CacheStorage via TOCTOU in ReadSideData
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 without the security team.
Overview: A TOCTOU vulnerability in CacheStorage may allow a compromised renderer to leak up to 64KB of uninitialized browser process heap memory. This occurs because ReadSideData allocates an uninitialized buffer based on a stale size while a truncating WriteSideData operation is pending in the disk cache queue. When the read completes, the buffer is not resized to the actual bytes read, transmitting the uninitialized memory to the renderer.
Affected files:
content/browser/cache_storage/cache_storage_cache_entry_handler.ccstorage/browser/blob/blob_impl.ccnet/disk_cache/simple/simple_entry_impl.cc
Estimated timestamp from git blame: 2024-01-10
Summary
A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the CacheStorage component. A compromised renderer process could potentially exploit a race condition between WriteSideData and ReadSideData to leak up to 64KB of uninitialized browser process heap memory. This memory may contain sensitive pointers or cross-origin data, which could facilitate ASLR bypasses and sandbox escapes.
Vulnerability Details
The vulnerability roots from how EntryReaderImpl::ReadSideData allocates its response buffer and how operations are queued in the Simple Cache backend (SimpleEntryImpl):
- Stale Size Fetch: When
EntryReaderImpl::ReadSideDatais called, it determines the buffer size synchronously viablob_entry_->GetSize(side_data_disk_cache_index_). InSimpleEntryImpl, this directly returnsdata_size_[stream], which will be stale if there is a pendingWriteDataoperation that hasn’t executed yet. - Uninitialized Allocation:
EntryReaderImpluses this stale size to allocate amojo_base::BigBuffer. For sizes up to 64KB (BigBuffer::kMaxInlineBytes),BigBufferavoids shared memory and instead allocates uninitialized browser heap memory viabase::HeapArray<uint8_t>::Uninit(). - Clamped Read: The read operation is then queued. If a truncating write was queued beforehand, the write executes first, truncating the stream’s data size. When the read subsequently executes,
ReadDataInternalcorrectly clamps the read length to the new, smaller data size. It reads only a few bytes and returns the actual number of bytes read. - Unchecked Return Buffer: The completion callback in
EntryReaderImpl::ReadSideData(and subsequentlyBlobImpl::ReadSideData) receives the actual bytes read (e.g., 1 byte) but ignores it. It blindly passes the original 64KBBigBufferover Mojo to the renderer. SinceBigBufferserializes its entire capacity, the remaining 65,535 bytes of uninitialized browser heap memory are leaked.
Potential Attacker Steps
Note: These are theoretical steps based on code analysis; our tooling agent does not currently have the capability to run a live Proof of Concept.
- A compromised renderer creates a Cache Storage entry with 64KB of side data.
- The renderer initiates a read of the entry’s response body, reading the Mojo data pipe very slowly to keep the disk cache entry’s
pending_operations_queue busy. - While the body read is pending, the renderer sends a
WriteSideDataIPC request to truncate the side data to 1 byte. Because the cache scheduler’sMatchoperation has finished, this write is dispatched to the disk cache backend and queued. - Immediately after, the renderer requests to read the side data. The browser allocates a 64KB uninitialized
BigBuffer(based on the stale size) and queues the read. - Once the queue processes, the write truncates the data, and the read fetches 1 byte into the 64KB buffer.
- The browser sends the 64KB buffer to the renderer, leaking 65,535 bytes of uninitialized heap memory.
Suggested Fix
In EntryReaderImpl::ReadSideData’s completion callback (content/browser/cache_storage/cache_storage_cache_entry_handler.cc:149), the code should verify the result (number of bytes read) against the size of the allocated output_buf.
If result < output_buf.size(), the code must not return the original output_buf. Instead, it should allocate a new BigBuffer of exactly result size, copy the read bytes into it, and return the new buffer. Alternatively, ensure the buffer is zero-initialized before reading, though resizing it is preferable to avoid transmitting unnecessary null bytes over IPC.
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.