CVE-2026-87433
Overview
Background
- Disk-backed blob transport
- when a blob is too large for memory,
storagewrites its bytes into page files and aFileTransportStrategycoordinates filling those files before the blob is finalized. - `expected_modification_time`
- the last-modified time recorded for a blob’s backing file, later re-checked so a consumer can detect post-finalization tampering (a TOCTOU guard).
- `FutureFile::Populate`
- records a page file’s
ShareableFileReferenceand its modification time into the blob being built. - `VerifySnapshotTime`
- the
FileStreamReadercheck that compares a file’s current mtime against the recordedexpected_modification_timewhen the blob is read.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: The citations check out: blob_transport_strategy.cc:500-533, blob_memory_controller.cc:179, local_file_stream_reader.cc:239, VerifySnapshotTime pre-fix line 21, and the removed test. The pre-fix source is not vulnerable because streaming (966e352a) already fixed it, so the primary only re-lands a mitigation. The original vulnerable OnReply is not shown, and the revert reason for 68f2bf3d is inferred.
0c52ba991c52. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (c25d7e463bd1).return expected_modification_time.is_null() || ... in the reverted attempt’s file_stream_reader.cc diff confirms a null expected_modification_time caused VerifySnapshotTime to return true (defeating the check). An interim fix (commit 966e352a, not shown) removed the hole by streaming bytes over a Mojo data pipe and computing mtimes browser-side — this is the pre-fix source shown here, where FileTransportStrategy uses StreamToFilesWriter and FinalizeFilesOnFileSequence/OnFilesFinalized to derive mtimes from the browser’s own file.GetInfo(&info) (blob_transport_strategy.cc:500-533), so the renderer never supplies a time. The pre-fix code is therefore not itself vulnerable: pre-fix CreateEmptyFiles opens page files with only File::FLAG_CREATE_ALWAYS | File::FLAG_WRITE (blob_memory_controller.cc:179), and the removed test Files_PageFileNotSharedWithProvider asserts EXPECT_EQ(0u, file_request_count_), i.e. the page file is never handed to the provider. That streaming approach caused a severe performance regression, so the primary commit 0c52ba991c52 reverts it and re-enables direct handle passing (the diff adds flags = base::File::AddFlagsForPassingToUntrustedProcess(flags) in the @@ -176,7 +176,11 @@ hunk; these lines are post-fix) while closing the integrity hole at the input: the new FileTransportStrategy::OnReply rejects a missing or null timestamp — if (!time_file_modified || time_file_modified->is_null()) returning BlobStatus::ERR_FILE_WRITE_FAILED (diff, added) — before calling future_file.Populate(std::move(file_reference), *time_file_modified) (diff, added). A null modification time can therefore never be recorded for a blob page file. The complete fix spans three CLs: 966e352a (streaming, reverted), 68f2bf3d (first re-land attempt adding the OnReply null-rejection and changing VerifySnapshotTime to return false for a null time, reverted for Android test flakiness; the same diff also changed the callers in local_file_stream_reader.cc:239 and both memory_file_stream_reader.cc sites to !expected_modification_time.is_null() && !VerifySnapshotTime(...), so at those callers a null time still skipped verification and the read side gained no effective hardening), and the primary 0c52ba991c52 (re-land carrying the live OnReply null-rejection plus a mock-timestamp test fix).OnReply so no blob file is ever recorded with an unverifiable time.Attack Path
- Force disk-backed storage A compromised renderer creates a blob large enough to be written to page files, so the file transport path is taken.
- Retain the writable handle
In the original design (per the reporter; before
966e352a), the browser passed a writable file handle to the renderer viaRequestAsFile. The pre-fix source shown here does not do this (blob_memory_controller.cc:179opens without untrusted-process flags); the primary re-addsAddFlagsForPassingToUntrustedProcess(post-fix diff hunk@@ -176). The renderer keeps a copy (inferred: handle-retention mechanics are not shown in the material). - Report a null timestamp
The renderer replies to
RequestAsFilewith a nullbase::Time. Per the reporter (unverified; noOnReplywithout the null check appears in the material), the originalOnReplychecked only!time_file_modifiedand recorded the null time viaPopulate, yielding a nullexpected_modification_time. - Bypass verification
At read time
VerifySnapshotTimereturnstruefor a null recorded time, so a post-finalization overwrite of the file is not detected. (inferred: XSS/Site-Isolation escalation is the reporter’s claim, not shown in the code here.) - Fix blocks step 3
With the patch, the null reply is rejected as
ERR_FILE_WRITE_FAILED, so the null time is never stored.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forstorage/browser/blob/blob_memory_controller.cc |
modified | |
FileTransportStrategystorage/browser/blob/blob_transport_strategy.cc |
modified | |
BindOncestorage/browser/blob/blob_transport_strategy.cc |
modified | |
limits_storage/browser/blob/blob_transport_strategy.cc |
modified | |
ifstorage/browser/blob/blob_transport_strategy.cc |
modified | |
whilestorage/browser/blob/blob_transport_strategy.cc |
modified | |
forstorage/browser/blob/blob_transport_strategy.cc |
modified | |
StreamToFilesWriterstorage/browser/blob/blob_transport_strategy.cc |
modified |
Files Changed
storage/browser/blob/blob_memory_controller.ccstorage/browser/blob/blob_registry_impl_unittest.ccstorage/browser/blob/blob_transport_strategy.ccstorage/browser/blob/blob_transport_strategy_unittest.ccstorage/browser/test/mock_bytes_provider.cc
Audit Directions
- Renderer-reported metadata as a trust anchorTreat any file time, size, or hash reported by a renderer and used as a security check (immutability, TOCTOU) as untrusted; reject null/sentinel values at the input boundary rather than downstream.
- Null/sentinel short-circuits in verifiersAudit predicates like
VerifySnapshotTimeforx.is_null() || ...patterns that turn a missing value into an unconditional pass; also check the callers themselves, since the reverted68f2bf3dmadeVerifySnapshotTimereject null but wrapped its callers in!expected_modification_time.is_null() && ..., which kept skipping verification for null times. The primary re-lands neither change. - Performance reverts of security fixesWhen a security CL is reverted for performance, verify the replacement preserves the invariant end-to-end; here handle passing was re-enabled, so the whole defense now rests on the
OnReplynull-rejection.
Patch
diff --git a/storage/browser/blob/blob_memory_controller.cc b/storage/browser/blob/blob_memory_controller.cc
index f94ff440..3e94280f 100644
--- a/storage/browser/blob/blob_memory_controller.cc
+++ b/storage/browser/blob/blob_memory_controller.cc
@@ -176,7 +176,11 @@
for (const base::FilePath& file_path : file_paths) {
FileCreationInfo creation_info;
// Try to open our file.
- File file(file_path, File::FLAG_CREATE_ALWAYS | File::FLAG_WRITE);
+ uint32_t flags = File::FLAG_CREATE_ALWAYS | File::FLAG_WRITE;
+
+ // This File may be passed to an untrusted process.
+ flags = base::File::AddFlagsForPassingToUntrustedProcess(flags);
+ File file(file_path, flags);
creation_info.path = std::move(file_path);
creation_info.file_deletion_runner = file_task_runner;
creation_info.error = file.error_details();
diff --git a/storage/browser/blob/blob_transport_strategy.cc b/storage/browser/blob/blob_transport_strategy.cc
index 1ed415ac..1728d317 100644
--- a/storage/browser/blob/blob_transport_strategy.cc
+++ b/storage/browser/blob/blob_transport_strategy.cc
@@ -9,16 +9,12 @@
#include "base/check.h"
#include "base/check_op.h"
#include "base/containers/circular_deque.h"
-#include "base/files/file.h"
#include "base/functional/bind.h"
#include "base/logging.h"
#include "base/memory/raw_ptr.h"
-#include "base/memory/weak_ptr.h"
#include "base/notreached.h"
#include "base/task/sequenced_task_runner.h"
-#include "base/task/thread_pool.h"
#include "mojo/public/cpp/system/data_pipe.h"
-#include "mojo/public/cpp/system/simple_watcher.h"
#include "storage/browser/blob/blob_data_builder.h"
#include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -259,297 +255,116 @@
size_t current_source_offset_ = 0;
};
-// Transport strategy that stores all data in page files. Bytes are streamed
-// over data pipes and written to the files locally so that the page files
-// remain private to this process.
+// Transport strategy that requests all data through files.
class FileTransportStrategy : public BlobTransportStrategy {
public:
FileTransportStrategy(BlobDataBuilder* builder,
ResultCallback result_callback,
const BlobStorageLimits& limits)
: BlobTransportStrategy(builder, std::move(result_callback)),
- limits_(limits),
- reply_runner_(base::SequencedTaskRunner::GetCurrentDefault()),
- file_runner_(base::ThreadPool::CreateSequencedTaskRunner(
- {base::MayBlock(), base::TaskPriority::USER_VISIBLE})) {}
-
- ~FileTransportStrategy() override {
- if (!files_.empty()) {
- file_runner_->PostTask(
- FROM_HERE,
- base::BindOnce([](std::vector<base::File>) {}, std::move(files_)));
- }
- }
+ limits_(limits) {}
void AddBytesElement(
blink::mojom::DataElementBytes* bytes,
const mojo::Remote<blink::mojom::BytesProvider>& data) override {
- if (bytes->length == 0) {
- return;
- }
- Element element;
- element.provider = data.get();
- element.length = bytes->length;
uint64_t source_offset = 0;
while (source_offset < bytes->length) {
- if (current_file_size_ >= limits_.max_file_size || file_count_ == 0) {
+ if (current_file_size_ >= limits_.max_file_size ||
+ file_requests_.empty()) {
current_file_size_ = 0;
- file_count_++;
+ current_file_index_++;
+ file_requests_.push_back(std::vector<Request>());
}
// Make sure no single file gets too big, but do use up all the available
// space in all but the last file.
- uint64_t segment_size =
+ uint64_t element_size =
std::min(bytes->length - source_offset,
limits_.max_file_size - current_file_size_);
- element.future_files.push_back(builder_->AppendFutureFile(
- current_file_size_, segment_size, file_count_ - 1));
- element.segments.push_back(
- Segment{file_count_ - 1, current_file_size_, segment_size});
+ BlobDataBuilder::FutureFile future_file = builder_->AppendFutureFile(
+ current_file_size_, element_size, file_requests_.size() - 1);
- source_offset += segment_size;
- current_file_size_ += segment_size;
+ num_unresolved_requests_++;
+ file_requests_.back().push_back(Request{
+ data.get(), source_offset, element_size, std::move(future_file)});
+
+ source_offset += element_size;
+ current_file_size_ += element_size;
}
- elements_.push_back(std::move(element));
}
void BeginTransport(
std::vector<BlobMemoryController::FileCreationInfo> file_infos) override {
- if (elements_.empty()) {
+ if (file_requests_.empty()) {
std::move(result_callback_).Run(BlobStatus::DONE);
return;
}
- DCHECK_EQ(file_infos.size(), file_count_);
- file_references_.reserve(file_infos.size());
- files_.reserve(file_infos.size());
- for (auto& info : file_infos) {
- file_references_.push_back(std::move(info.file_reference));
- files_.push_back(std::move(info.file));
+ DCHECK_EQ(file_infos.size(), file_requests_.size());
+ for (size_t file_index = 0; file_index < file_requests_.size();
+ ++file_index) {
+ auto& requests = file_requests_[file_index];
+ uint64_t file_offset = 0;
+ for (size_t i = 0; i < requests.size(); ++i) {
+ auto& request = requests[i];
+ base::File file = i == requests.size() - 1
+ ? std::move(file_infos[file_index].file)
+ : file_infos[file_index].file.Duplicate();
+ // base::Unretained is safe because |this| is guaranteed (by the
+ // contract that code using BlobTransportStrategy should adhere to) to
+ // outlive the BytesProvider.
+ request.provider->RequestAsFile(
+ request.source_offset, request.source_size, std::move(file),
+ file_offset,
+ base::BindOnce(&FileTransportStrategy::OnReply,
+ base::Unretained(this),
+ std::move(request.future_file),
+ file_infos[file_index].file_reference));
+ file_offset += request.source_size;
+ }
}
- NextElementOrDone();
}
private:
- // A contiguous run of bytes from one element to be written to one file.
- struct Segment {
- size_t file_index;
- uint64_t file_offset;
- uint64_t size;
- };
-
- struct Element {
- raw_ptr<blink::mojom::BytesProvider> provider;
- uint64_t length = 0;
- // Segments are ordered by source offset and cover the whole element.
- std::vector<Segment> segments;
- // One entry per segment.
- std::vector<BlobDataBuilder::FutureFile> future_files;
- };
-
- using StreamWrittenCallback =
- base::OnceCallback<void(std::vector<base::File>, uint64_t bytes_written)>;
-
- // Runs on |file_runner_|. Reads from a data pipe and writes the bytes to the
- // page files according to |segments|. Self-deletes when done.
- class StreamToFilesWriter {
- public:
- static void CreateAndStart(
- std::vector<base::File> files,
- mojo::ScopedDataPipeConsumerHandle pipe,
- std::vector<Segment> segments,
- uint64_t expected_size,
- scoped_refptr<base::SequencedTaskRunner> reply_runner,
- StreamWrittenCallback callback) {
- new StreamToFilesWriter(std::move(files), std::move(pipe),
- std::move(segments), expected_size,
- std::move(reply_runner), std::move(callback));
- }
-
- private:
- StreamToFilesWriter(std::vector<base::File> files,
- mojo::ScopedDataPipeConsumerHandle pipe,
- std::vector<Segment> segments,
- uint64_t expected_size,
- scoped_refptr<base::SequencedTaskRunner> reply_runner,
- StreamWrittenCallback callback)
- : files_(std::move(files)),
- pipe_(std::move(pipe)),
- watcher_(FROM_HERE,
- mojo::SimpleWatcher::ArmingPolicy::MANUAL,
- base::SequencedTaskRunner::GetCurrentDefault()),
- segments_(std::move(segments)),
- expected_size_(expected_size),
- reply_runner_(std::move(reply_runner)),
- callback_(std::move(callback)) {
- watcher_.Watch(pipe_.get(), MOJO_HANDLE_SIGNAL_READABLE,
- MOJO_WATCH_CONDITION_SATISFIED,
- base::BindRepeating(&StreamToFilesWriter::OnReadable,
- base::Unretained(this)));
- watcher_.ArmOrNotify();
- }
-
- void OnReadable(MojoResult result, const mojo::HandleSignalsState& state) {
- while (bytes_written_ < expected_size_) {
- base::span<const uint8_t> buffer;
- MojoResult read_result =
- pipe_->BeginReadData(MOJO_READ_DATA_FLAG_NONE, buffer);
- if (read_result == MOJO_RESULT_SHOULD_WAIT) {
- watcher_.ArmOrNotify();
- return;
- }
- if (read_result != MOJO_RESULT_OK) {
- // The producer closed the pipe before we received all the data.
- break;
- }
-
- size_t consumed = 0;
- while (consumed < buffer.size() && segment_index_ < segments_.size()) {
- const Segment& segment = segments_[segment_index_];
- uint64_t remaining_in_segment = segment.size - segment_offset_;
- size_t chunk = static_cast<size_t>(std::min<uint64_t>(
- buffer.size() - consumed, remaining_in_segment));
- std::optional<size_t> written = files_[segment.file_index].Write(
- segment.file_offset + segment_offset_,
- buffer.subspan(consumed, chunk));
- if (!written.has_value() || *written != chunk) {
- pipe_->EndReadData(consumed);
- Done();
- return;
- }
- consumed += chunk;
- bytes_written_ += chunk;
- segment_offset_ += chunk;
- if (segment_offset_ == segment.size) {
- segment_index_++;
- segment_offset_ = 0;
- }
- }
- pipe_->EndReadData(consumed);
- }
- Done();
- }
-
- void Done() {
- reply_runner_->PostTask(
- FROM_HERE, base::BindOnce(std::move(callback_), std::move(files_),
- bytes_written_));
- delete this;
- }
-
- std::vector<base::File> files_;
- mojo::ScopedDataPipeConsumerHandle pipe_;
- mojo::SimpleWatcher watcher_;
- const std::vector<Segment> segments_;
- const uint64_t expected_size_;
- scoped_refptr<base::SequencedTaskRunner> reply_runner_;
- StreamWrittenCallback callback_;
-
- size_t segment_index_ = 0;
- uint64_t segment_offset_ = 0;
- uint64_t bytes_written_ = 0;
- };
-
- void NextElementOrDone() {
- if (current_element_ >= elements_.size()) {
- file_runner_->PostTask(
- FROM_HERE,
- base::BindOnce(
- &FileTransportStrategy::FinalizeFilesOnFileSequence,
- std::move(files_), reply_runner_,
- base::BindOnce(&FileTransportStrategy::OnFilesFinalized,
- weak_factory_.GetWeakPtr())));
- return;
- }
-
- Element& element = elements_[current_element_];
- mojo::ScopedDataPipeProducerHandle producer_handle;
- mojo::ScopedDataPipeConsumerHandle consumer_handle;
- MojoCreateDataPipeOptions options;
- options.struct_size = sizeof(MojoCreateDataPipeOptions);
- options.flags = MOJO_CREATE_DATA_PIPE_FLAG_NONE;
- options.element_num_bytes = 1;
- options.capacity_num_bytes = static_cast<uint32_t>(
- std::min<uint64_t>(element.length, limits_.max_shared_memory_size));
- if (CreateDataPipe(&options, producer_handle, consumer_handle) !=
- MOJO_RESULT_OK) {
- DVLOG(1) << "Unable to create data pipe for blob transfer.";
- std::move(result_callback_).Run(BlobStatus::ERR_OUT_OF_MEMORY);
- return;
- }
-
- element.provider->RequestAsStream(std::move(producer_handle));
- file_runner_->PostTask(
- FROM_HERE,
- base::BindOnce(&StreamToFilesWriter::CreateAndStart, std::move(files_),
- std::move(consumer_handle), element.segments,
- element.length, reply_runner_,
- base::BindOnce(&FileTransportStrategy::OnElementWritten,
- weak_factory_.GetWeakPtr())));
- }
-
- void OnElementWritten(std::vector<base::File> files, uint64_t bytes_written) {
- files_ = std::move(files);
- if (bytes_written < elements_[current_element_].length) {
+ void OnReply(BlobDataBuilder::FutureFile future_file,
+ scoped_refptr<ShareableFileReference> file_reference,
+ std::optional<base::Time> time_file_modified) {
+ if (!time_file_modified || time_file_modified->is_null()) {
+ // Writing to the file failed in the renderer.
std::move(result_callback_).Run(BlobStatus::ERR_FILE_WRITE_FAILED);
return;
}
- current_element_++;
- NextElementOrDone();
- }
- static void FinalizeFilesOnFileSequence(
- std::vector<base::File> files,
- scoped_refptr<base::SequencedTaskRunner> reply_runner,
- base::OnceCallback<void(std::vector<base::Time>)> callback) {
- std::vector<base::Time> mtimes;
- mtimes.reserve(files.size());
- for (auto& file : files) {
- base::File::Info info;
- if (!file.IsValid() || !file.Flush() || !file.GetInfo(&info)) {
- mtimes.clear();
- break;
- }
- mtimes.push_back(info.last_modified);
- }
- reply_runner->PostTask(
- FROM_HERE, base::BindOnce(std::move(callback), std::move(mtimes)));
- }
+ bool populate_result =
+ future_file.Populate(std::move(file_reference), *time_file_modified);
+ DCHECK(populate_result);
- void OnFilesFinalized(std::vector<base::Time> mtimes) {
- if (mtimes.size() != file_references_.size()) {
- std::move(result_callback_).Run(BlobStatus::ERR_FILE_WRITE_FAILED);
- return;
+ if (--num_unresolved_requests_ == 0) {
+ std::move(result_callback_).Run(BlobStatus::DONE);
}
- for (Element& element : elements_) {
- DCHECK_EQ(element.segments.size(), element.future_files.size());
- for (size_t i = 0; i < element.segments.size(); ++i) {
- size_t file_index = element.segments[i].file_index;
- bool populate_result = element.future_files[i].Populate(
- file_references_[file_index], mtimes[file_index]);
- DCHECK(populate_result);
- }
- }
- std::move(result_callback_).Run(BlobStatus::DONE);
}
const BlobStorageLimits limits_;
- scoped_refptr<base::SequencedTaskRunner> reply_runner_;
- scoped_refptr<base::SequencedTaskRunner> file_runner_;
// State used to assign bytes elements to individual files.
- size_t file_count_ = 0;
+ // The index of the first file that still has available space.
+ size_t current_file_index_ = 0;
// How big the current file already is.
uint64_t current_file_size_ = 0;
- std::vector<Element> elements_;
- size_t current_element_ = 0;
+ struct Request {
+ // The BytesProvider to request this particular bit of data from.
+ raw_ptr<blink::mojom::BytesProvider> provider;
+ // Offset into the BytesProvider of the data to request.
+ uint64_t source_offset;
+ // Size of the bytes to request.
+ uint64_t source_size;
+ // Future file the data should be populated into.
+ BlobDataBuilder::FutureFile future_file;
+ };
+ // For each file, a list of requests involving that file.
+ std::vector<std::vector<Request>> file_requests_;
- // Populated in BeginTransport. |files_| is moved to |file_runner_| while a
- // write is in progress and moved back when it completes.
- std::vector<base::File> files_;
- std::vector<scoped_refptr<ShareableFileReference>> file_references_;
-
- base::WeakPtrFactory<FileTransportStrategy> weak_factory_{this};
+ size_t num_unresolved_requests_ = 0;
};
} // namespace
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
... (file diff truncated)
Regression Test / PoC
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index b2d4fca4..208fd1e 100644
--- a/storage/browser/blob/blob_registry_impl_unittest.cc
+++ b/storage/browser/blob/blob_registry_impl_unittest.cc
@@ -903,8 +903,8 @@
size_t expected_file_count =
1 + kData.size() / kTestBlobStorageMaxFileSizeBytes;
EXPECT_EQ(0u, reply_request_count_);
- EXPECT_EQ(1u, stream_request_count_);
- EXPECT_EQ(0u, file_request_count_);
+ EXPECT_EQ(0u, stream_request_count_);
+ EXPECT_EQ(expected_file_count, file_request_count_);
auto snapshot = handle->CreateSnapshot();
EXPECT_EQ(expected_file_count, snapshot->items().size());
diff --git a/storage/browser/blob/blob_transport_strategy_unittest.cc b/storage/browser/blob/blob_transport_strategy_unittest.cc
index e2698c7..868891a 100644
--- a/storage/browser/blob/blob_transport_strategy_unittest.cc
+++ b/storage/browser/blob/blob_transport_strategy_unittest.cc
@@ -366,64 +366,6 @@
EXPECT_TRUE(bad_messages_.empty());
}
-TEST_F(BlobTransportStrategyTest, Files_PageFileNotSharedWithProvider) {
- BlobDataBuilder builder(kId);
-
- std::string data = base::RandBytesAsString(kTestBlobStorageMaxFileSizeBytes);
- blink::mojom::DataElementBytes bytes(data.size(), std::nullopt,
- mojo::NullRemote());
-
- // Must outlive `strategy`.
- mojo::Remote<blink::mojom::BytesProvider> bytes_provider(
- CreateBytesProvider(data, mock_time_));
-
- base::RunLoop loop;
- BlobStatus status = BlobStatus::PENDING_TRANSPORT;
- auto strategy = BlobTransportStrategy::Create(
- MemoryStrategy::FILE, &builder,
- base::BindOnce(
- [](BlobStatus* result_out, base::OnceClosure closure,
- BlobStatus result) {
- *result_out = result;
- std::move(closure).Run();
- },
- &status, loop.QuitClosure()),
- limits_);
-
- strategy->AddBytesElement(&bytes, bytes_provider);
-
- base::FilePath path;
- FileInfoVector files(1);
- {
- base::ScopedAllowBlockingForTesting allow_blocking;
- ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &path));
- files[0].file =
- base::File(path, base::File::FLAG_OPEN | base::File::FLAG_WRITE);
- files[0].file_deletion_runner =
- base::SingleThreadTaskRunner::GetCurrentDefault();
- files[0].file_reference = ShareableFileReference::GetOrCreate(
- path, ShareableFileReference::DELETE_ON_FINAL_RELEASE,
- bytes_provider_runner_.get());
- }
-
- strategy->BeginTransport(std::move(files));
- loop.Run();
-
- EXPECT_EQ(BlobStatus::DONE, status);
- EXPECT_TRUE(bad_messages_.empty());
-
- // Page files back blob data that must remain immutable once transport
- // completes, so the provider must never be given a handle to them.
- EXPECT_EQ(0u, file_request_count_);
-
- std::string file_contents;
- {
- base::ScopedAllowBlockingForTesting allow_blocking;
- ASSERT_TRUE(base::ReadFileToString(path, &file_contents));
- }
- EXPECT_EQ(data, file_contents);
-}
-
TEST_F(BlobTransportStrategyTest, Files_WriteFailed) {
BlobDataBuilder builder(kId);
@@ -432,10 +374,9 @@
blink::mojom::DataElementBytes bytes(data.size(), std::nullopt,
mojo::NullRemote());
- // Must outlive `strategy`. Supplies fewer bytes than the element declares so
- // that the page file cannot be fully written.
+ // Must outlive `strategy`.
mojo::Remote<blink::mojom::BytesProvider> bytes_provider(
- CreateBytesProvider(data.substr(0, data.size() - 1), mock_time_));
+ CreateBytesProvider(data, std::nullopt));
BlobStatus status = BlobStatus::PENDING_TRANSPORT;
auto strategy = BlobTransportStrategy::Create(
@@ -505,13 +446,15 @@
std::vector<base::FilePath> paths(expected_file_count);
for (size_t i = 0; i < expected_file_count; ++i) {
base::ScopedAllowBlockingForTesting allow_blocking;
- ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &paths[i]));
+ base::FilePath path;
+ ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &path));
+ paths[i] = path;
files[i].file =
- base::File(paths[i], base::File::FLAG_OPEN | base::File::FLAG_WRITE);
+ base::File(path, base::File::FLAG_OPEN | base::File::FLAG_WRITE);
files[i].file_deletion_runner =
base::SingleThreadTaskRunner::GetCurrentDefault();
files[i].file_reference = ShareableFileReference::GetOrCreate(
- paths[i], ShareableFileReference::DELETE_ON_FINAL_RELEASE,
+ path, ShareableFileReference::DELETE_ON_FINAL_RELEASE,
bytes_provider_runner_.get());
}
@@ -523,9 +466,6 @@
size_t offset = i * kTestBlobStorageMaxFileSizeBytes;
size_t length = std::min<uint64_t>(kTestBlobStorageMaxFileSizeBytes,
data.size() - offset);
- std::string file_contents;
- ASSERT_TRUE(base::ReadFileToString(paths[i], &file_contents));
- EXPECT_EQ(data.substr(offset, length), file_contents);
base::File::Info info;
ASSERT_TRUE(base::GetFileInfo(paths[i], &info));
expected.AppendFile(paths[i], 0, length, info.last_modified);
@@ -535,8 +475,8 @@
EXPECT_EQ(expected, builder);
EXPECT_TRUE(bad_messages_.empty());
EXPECT_EQ(0u, reply_request_count_);
- EXPECT_EQ(1u, stream_request_count_);
- EXPECT_EQ(0u, file_request_count_);
+ EXPECT_EQ(0u, stream_request_count_);
+ EXPECT_EQ(expected_file_count, file_request_count_);
}
TEST_F(BlobTransportStrategyTest, Files_ValidBytesMultipleElements) {
@@ -587,37 +527,39 @@
std::vector<base::FilePath> paths(expected_file_count);
for (size_t i = 0; i < expected_file_count; ++i) {
base::ScopedAllowBlockingForTesting allow_blocking;
- ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &paths[i]));
+ base::FilePath path;
+ ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &path));
+ paths[i] = path;
files[i].file =
- base::File(paths[i], base::File::FLAG_OPEN | base::File::FLAG_WRITE);
- files[i].path = paths[i];
+ base::File(path, base::File::FLAG_OPEN | base::File::FLAG_WRITE);
+ files[i].path = path;
files[i].file_deletion_runner =
base::SingleThreadTaskRunner::GetCurrentDefault();
files[i].file_reference = ShareableFileReference::GetOrCreate(
- paths[i], ShareableFileReference::DELETE_ON_FINAL_RELEASE,
+ path, ShareableFileReference::DELETE_ON_FINAL_RELEASE,
bytes_provider_runner_.get());
}
strategy->BeginTransport(std::move(files));
loop.Run();
- std::vector<base::Time> mtimes(expected_file_count);
- for (size_t i = 0; i < expected_file_count; ++i) {
- base::ScopedAllowBlockingForTesting allow_blocking;
- base::File::Info info;
- ASSERT_TRUE(base::GetFileInfo(paths[i], &info));
- mtimes[i] = info.last_modified;
- }
-
size_t file_offset = 0;
size_t file_index = 0;
+ size_t expected_request_count = 0;
+ auto snapshot = builder.CreateSnapshot();
+ const auto& items = snapshot->items();
+ size_t item_index = 0;
for (size_t i = 0; i < 4; ++i) {
size_t remaining_size = data.size();
while (remaining_size > 0) {
size_t block_size = std::min<uint64_t>(
kTestBlobStorageMaxFileSizeBytes - file_offset, remaining_size);
+ ASSERT_LT(item_index, items.size());
+ EXPECT_FALSE(items[item_index]->expected_modification_time().is_null());
expected.AppendFile(paths[file_index], file_offset, block_size,
- mtimes[file_index]);
+ items[item_index]->expected_modification_time());
+ item_index++;
+ expected_request_count++;
remaining_size -= block_size;
file_offset += block_size;
if (file_offset >= kTestBlobStorageMaxFileSizeBytes) {
@@ -631,8 +573,8 @@
EXPECT_EQ(expected, builder);
EXPECT_TRUE(bad_messages_.empty());
EXPECT_EQ(0u, reply_request_count_);
- EXPECT_EQ(4u, stream_request_count_);
- EXPECT_EQ(0u, file_request_count_);
+ EXPECT_EQ(0u, stream_request_count_);
+ EXPECT_EQ(expected_request_count, file_request_count_);
}
} // namespace
diff --git a/storage/browser/test/mock_bytes_provider.cc b/storage/browser/test/mock_bytes_provider.cc
index e83223b..b7c642dc 100644
--- a/storage/browser/test/mock_bytes_provider.cc
+++ b/storage/browser/test/mock_bytes_provider.cc
@@ -56,7 +56,13 @@
static_cast<size_t>(source_size)))
.value_or(0)));
EXPECT_TRUE(file.Flush());
- std::move(callback).Run(file_modification_time_);
+ if (!file_modification_time_) {
+ std::move(callback).Run(std::nullopt);
+ return;
+ }
+ base::File::Info info;
+ EXPECT_TRUE(file.GetInfo(&info));
+ std::move(callback).Run(info.last_modified);
}
} // namespace storage
Original Bug Report
Blob Immutability Bypass via Writable File Handle and Null Timestamp
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can bypass blob immutability by retaining a writable file handle provided by the browser during large blob creation. By sending a null modification timestamp during finalization, the renderer disables subsequent browser-side integrity checks, allowing post-validation data tampering.
Affected files:
storage/browser/blob/blob_transport_strategy.ccstorage/browser/blob/blob_memory_controller.ccstorage/browser/file_system/local_file_stream_reader.ccstorage/browser/blob/blob_data_item.hstorage/browser/blob/write_blob_to_file.cc
Estimated timestamp from git blame: 2021-05-15
Summary
The Chrome Blob storage system relies on immutability as a core security invariant to prevent cross-origin data tampering and Time-of-Check to Time-of-Use (TOCTOU) vulnerabilities. However, when handling large blobs that require disk-backed storage, the browser passes a writable OS file handle to the renderer and relies on a flawed timestamp mechanism to detect subsequent modifications. A compromised renderer can exploit this to tamper with blob data after the browser has finalized and validated it.
Vulnerability Details
- Writable Handle Transfer: When a blob is too large to fit in memory,
BlobMemoryController::CreateEmptyFilescreates a temporary file withbase::File::FLAG_WRITEandbase::File::AddFlagsForPassingToUntrustedProcess. This writable handle is sent to the renderer via theBytesProvider::RequestAsFileMojo call. - Null Timestamp Bypass: After writing the data, the renderer replies to the browser with the file’s last modification time via a
std::optional<base::Time>. InFileTransportStrategy::OnReply, the browser checksif (!time_file_modified). If the renderer sends a non-nullstd::optionalcontaining a nullbase::Time(value 0), this check passes, and the null time is stored in theBlobDataItem. - Validation Short-Circuit: Later, when the blob is read by a consumer (e.g., navigating to a
blob:URL or reading via IndexedDB),LocalFileStreamReaderusesFileStreamReader::VerifySnapshotTimeto ensure the file hasn’t been modified. This function contains the following logic:return expected_modification_time.is_null() || ...;Because the stored time is null, the check unconditionally returnstrue, completely bypassing the file integrity verification.
Impact
A compromised renderer can break the immutability of any large blob it creates. This allows an attacker to:
- Pass initial browser-side security checks (e.g., MIME type sniffing, Safe Browsing) with benign data.
- Silently overwrite the file with malicious data (e.g., an HTML payload) using the retained writable handle.
- Trigger a read from a higher-privileged context or a different origin (via
blob:URL navigation orpostMessage), leading to Cross-Site Scripting (XSS) or delivery of tampered files, effectively violating Site Isolation.
Potential Reproduction Steps
Note: These are suggested/potential steps derived from code analysis, as our tooling agent does not currently have the ability to execute code or run a working Proof of Concept.
- Compromise a renderer process (e.g., via a standard V8 bug).
- Use the standard Web API to create a large Blob (e.g.,
> 5MB) to force disk-backed storage. - Intercept the
BytesProvider::RequestAsFileMojo call in the compromised renderer. - Write benign data to the provided
base::Filehandle, but retain a copy of the writable file descriptor (e.g., usingdup()). - Reply to the browser’s callback with a
std::optional<base::Time>containingbase::Time(). - The browser finalizes the Blob and considers it immutable.
- Use the retained file descriptor to overwrite the file on disk with a malicious payload.
- Cause a victim frame or process to read the Blob (e.g., via
window.open(blob_url)).
Suggested Fix
- Reject Null Timestamps: Update
FileTransportStrategy::OnReplyto explicitly reject null timestamps.if (!time_file_modified || time_file_modified->is_null()). - Harden Verification:
FileStreamReader::VerifySnapshotTimeshould treat a nullexpected_modification_timeas a verification failure when validating immutable blobs. - Revoke Write Access: If possible, the browser should avoid trusting the renderer’s modification time entirely. The browser could close its handle, wait for the renderer to finish, and then re-open the file in read-only mode, or use OS-level file sealing (like
memfd_createwithF_ADD_SEALSon Linux) to strictly enforce immutability at the OS level.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.