Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in FileAPI
DescriptionRace condition in FileAPI
ComponentFileAPI
Bug ClassRace
Tracker497574154
Fix commitc25d7e463bd1 (chromium/src) +366/-116
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
for
storage/browser/blob/blob_memory_controller.cc
modified
FileTransportStrategy
storage/browser/blob/blob_transport_strategy.cc
modified
limits_
storage/browser/blob/blob_transport_strategy.cc
modified
BindOnce
storage/browser/blob/blob_transport_strategy.cc
modified
if
storage/browser/blob/blob_transport_strategy.cc
modified
while
storage/browser/blob/blob_transport_strategy.cc
modified

Files Changed

  • storage/browser/blob/blob_memory_controller.cc
  • storage/browser/blob/blob_registry_impl_unittest.cc
  • storage/browser/blob/blob_transport_strategy.cc
From c25d7e463bd16180c398a38f517724d488bf55ce Mon Sep 17 00:00:00 2001
From: Anna Tsvirchkova <atsvirchkova@google.com>
Date: Tue, 11 Aug 2026 00:53:17 -0700
Subject: [PATCH] [Gardener] Revert "blob: Harden timestamp validation in BlobTransport"

This reverts commit 68f2bf3d56f5f7883153ec00c9992fff298be425.

Reason for revert: BlobStorageContextMojoTest#WriteSingleFileBlobNoFileSize and 1 more test have become extremely flaky. See b/544855147

Failure Link: https://ci.chromium.org/ui/p/chromium/builders/ci/android-14-x64-rel/12455/overview

Original change's description:
> blob: Harden timestamp validation in BlobTransport
>
> Revert commit 966e352a7bb4759ac48058acf014c6d77ca1305c which caused
> a severe P1 performance regression by streaming bytes over a data
> pipe instead of passing the file handle.
>
> Instead, implement an alternative lightweight security fix:
> 1. FileTransportStrategy::OnReply explicitly rejects null timestamps.
> 2. FileStreamReader::VerifySnapshotTime treats null expected
>    modification times as validation failures.
>
> The diff can be seen between PS 1 and 8.
>
> Bug: 497574154, 542909707
> Change-Id: I9e0ea30b1836c88991fed0ba33a3ec48f8cf0dba
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8213659
> Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
> Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
> Cr-Commit-Position: refs/heads/main@{#1676348}

Bug: 497574154, 542909707, 544855147
No-Presubmit: true
No-Tree-Checks: true
No-Try: true
Bug: 497574154, 542909707
Change-Id: If6e7a1693ee01bf0d43c4cc6bb3944bc7f96f1e7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8235080
Bot-Commit: rubber-stamper@appspot.gserviceaccount.com <rubber-stamper@appspot.gserviceaccount.com>
Owners-Override: Anna Tsvirchkova <atsvirchkova@google.com>
Auto-Submit: Anna Tsvirchkova <atsvirchkova@google.com>
Commit-Queue: rubber-stamper@appspot.gserviceaccount.com <rubber-stamper@appspot.gserviceaccount.com>
Cr-Commit-Position: refs/heads/main@{#1677023}
---

diff --git a/storage/browser/blob/blob_memory_controller.cc b/storage/browser/blob/blob_memory_controller.cc
index 08004eb..575f84d2 100644
--- a/storage/browser/blob/blob_memory_controller.cc
+++ b/storage/browser/blob/blob_memory_controller.cc
@@ -176,11 +176,7 @@
   for (const base::FilePath& file_path : file_paths) {
     FileCreationInfo creation_info;
     // Try to open our file.
-    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);
+    File file(file_path, File::FLAG_CREATE_ALWAYS | File::FLAG_WRITE);
     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_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index 208fd1e..b2d4fca4 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(0u, stream_request_count_);
-  EXPECT_EQ(expected_file_count, file_request_count_);
+  EXPECT_EQ(1u, stream_request_count_);
+  EXPECT_EQ(0u, file_request_count_);
 
   auto snapshot = handle->CreateSnapshot();
   EXPECT_EQ(expected_file_count, snapshot->items().size());
diff --git a/storage/browser/blob/blob_transport_strategy.cc b/storage/browser/blob/blob_transport_strategy.cc
index 1728d317..1ed415ac 100644
--- a/storage/browser/blob/blob_transport_strategy.cc
+++ b/storage/browser/blob/blob_transport_strategy.cc
@@ -9,12 +9,16 @@
 #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"
 
@@ -255,116 +259,297 @@
   size_t current_source_offset_ = 0;
 };
 
-// Transport strategy that requests all data through files.
+// 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.
 class FileTransportStrategy : public BlobTransportStrategy {
  public:
   FileTransportStrategy(BlobDataBuilder* builder,
                         ResultCallback result_callback,
                         const BlobStorageLimits& limits)
       : BlobTransportStrategy(builder, std::move(result_callback)),
-        limits_(limits) {}
+        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_)));
+    }
+  }
 
   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_requests_.empty()) {
+      if (current_file_size_ >= limits_.max_file_size || file_count_ == 0) {
         current_file_size_ = 0;
-        current_file_index_++;
-        file_requests_.push_back(std::vector<Request>());
+        file_count_++;
       }
 
       // Make sure no single file gets too big, but do use up all the available
       // space in all but the last file.
-      uint64_t element_size =
+      uint64_t segment_size =
           std::min(bytes->length - source_offset,
                    limits_.max_file_size - current_file_size_);
-      BlobDataBuilder::FutureFile future_file = builder_->AppendFutureFile(
-          current_file_size_, element_size, file_requests_.size() - 1);
+      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});
 
-      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;
+      source_offset += segment_size;
+      current_file_size_ += segment_size;
     }
+    elements_.push_back(std::move(element));
   }
 
   void BeginTransport(
       std::vector<BlobMemoryController::FileCreationInfo> file_infos) override {
-    if (file_requests_.empty()) {
+    if (elements_.empty()) {
       std::move(result_callback_).Run(BlobStatus::DONE);
       return;
     }
-    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;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index 208fd1e..b2d4fca4 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(0u, stream_request_count_);
-  EXPECT_EQ(expected_file_count, file_request_count_);
+  EXPECT_EQ(1u, stream_request_count_);
+  EXPECT_EQ(0u, 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 1626140c..e2698c7 100644
--- a/storage/browser/blob/blob_transport_strategy_unittest.cc
+++ b/storage/browser/blob/blob_transport_strategy_unittest.cc
@@ -366,6 +366,64 @@
   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);
 
@@ -374,9 +432,10 @@
   blink::mojom::DataElementBytes bytes(data.size(), std::nullopt,
                                        mojo::NullRemote());
 
-  // Must outlive `strategy`.
+  // Must outlive `strategy`. Supplies fewer bytes than the element declares so
+  // that the page file cannot be fully written.
   mojo::Remote<blink::mojom::BytesProvider> bytes_provider(
-      CreateBytesProvider(data, std::nullopt));
+      CreateBytesProvider(data.substr(0, data.size() - 1), mock_time_));
 
   BlobStatus status = BlobStatus::PENDING_TRANSPORT;
   auto strategy = BlobTransportStrategy::Create(
@@ -443,32 +502,41 @@
   size_t expected_file_count =
       1 + data.size() / kTestBlobStorageMaxFileSizeBytes;
   FileInfoVector files(expected_file_count);
+  std::vector<base::FilePath> paths(expected_file_count);
   for (size_t i = 0; i < expected_file_count; ++i) {
     base::ScopedAllowBlockingForTesting allow_blocking;
-    base::FilePath path;
-    ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &path));
+    ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &paths[i]));
     files[i].file =
-        base::File(path, base::File::FLAG_OPEN | base::File::FLAG_WRITE);
+        base::File(paths[i], base::File::FLAG_OPEN | base::File::FLAG_WRITE);
     files[i].file_deletion_runner =
         base::SingleThreadTaskRunner::GetCurrentDefault();
     files[i].file_reference = ShareableFileReference::GetOrCreate(
-        path, ShareableFileReference::DELETE_ON_FINAL_RELEASE,
+        paths[i], ShareableFileReference::DELETE_ON_FINAL_RELEASE,
         bytes_provider_runner_.get());
-    size_t offset = i * kTestBlobStorageMaxFileSizeBytes;
-    size_t length = std::min<uint64_t>(kTestBlobStorageMaxFileSizeBytes,
-                                       data.size() - offset);
-    expected.AppendFile(path, 0, length, mock_time_);
   }
 
   strategy->BeginTransport(std::move(files));
   loop.Run();
 
+  for (size_t i = 0; i < expected_file_count; ++i) {
+    base::ScopedAllowBlockingForTesting allow_blocking;
+    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);
+  }
+
   EXPECT_EQ(BlobStatus::DONE, status);
   EXPECT_EQ(expected, builder);
   EXPECT_TRUE(bad_messages_.empty());
   EXPECT_EQ(0u, reply_request_count_);
-  EXPECT_EQ(0u, stream_request_count_);
-  EXPECT_EQ(expected_file_count, file_request_count_);
+  EXPECT_EQ(1u, stream_request_count_);
+  EXPECT_EQ(0u, file_request_count_);
 }
 
 TEST_F(BlobTransportStrategyTest, Files_ValidBytesMultipleElements) {
@@ -516,31 +584,40 @@
   size_t expected_file_count =
       1 + 4 * data.size() / kTestBlobStorageMaxFileSizeBytes;
   FileInfoVector files(expected_file_count);
+  std::vector<base::FilePath> paths(expected_file_count);
   for (size_t i = 0; i < expected_file_count; ++i) {
     base::ScopedAllowBlockingForTesting allow_blocking;
-    base::FilePath path;
-    ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &path));
+    ASSERT_TRUE(base::CreateTemporaryFileInDir(data_dir_.GetPath(), &paths[i]));
     files[i].file =
-        base::File(path, base::File::FLAG_OPEN | base::File::FLAG_WRITE);
-    files[i].path = path;
+        base::File(paths[i], base::File::FLAG_OPEN | base::File::FLAG_WRITE);
+    files[i].path = paths[i];
     files[i].file_deletion_runner =
         base::SingleThreadTaskRunner::GetCurrentDefault();
     files[i].file_reference = ShareableFileReference::GetOrCreate(
-        path, ShareableFileReference::DELETE_ON_FINAL_RELEASE,
+        paths[i], 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;
   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);
-      expected.AppendFile(files[file_index].path, file_offset, block_size,
-                          mock_time_);
-      expected_request_count++;
+      expected.AppendFile(paths[file_index], file_offset, block_size,
+                          mtimes[file_index]);
       remaining_size -= block_size;
       file_offset += block_size;
       if (file_offset >= kTestBlobStorageMaxFileSizeBytes) {
@@ -550,15 +627,12 @@
     }
   }
 
-  strategy->BeginTransport(std::move(files));
-  loop.Run();
-
   EXPECT_EQ(BlobStatus::DONE, status);
   EXPECT_EQ(expected, builder);
   EXPECT_TRUE(bad_messages_.empty());
   EXPECT_EQ(0u, reply_request_count_);
-  EXPECT_EQ(0u, stream_request_count_);
-  EXPECT_EQ(expected_request_count, file_request_count_);
+  EXPECT_EQ(4u, stream_request_count_);
+  EXPECT_EQ(0u, file_request_count_);
 }
 
 }  // namespace
diff --git a/storage/browser/test/mock_bytes_provider.h b/storage/browser/test/mock_bytes_provider.h
index 41c7e216..ae27962 100644
--- a/storage/browser/test/mock_bytes_provider.h
+++ b/storage/browser/test/mock_bytes_provider.h
@@ -17,12 +17,12 @@
 // bytes are consumed.
 class MockBytesProvider : public blink::mojom::BytesProvider {
  public:
-  explicit MockBytesProvider(std::vector<uint8_t> data,
-                             size_t* reply_request_count = nullptr,
-                             size_t* stream_request_count = nullptr,
-                             size_t* file_request_count = nullptr,
-                             std::optional<base::Time> file_modification_time =
-                                 base::Time::FromSecondsSinceUnixEpoch(1));
+  explicit MockBytesProvider(
+      std::vector<uint8_t> data,
+      size_t* reply_request_count = nullptr,
+      size_t* stream_request_count = nullptr,
+      size_t* file_request_count = nullptr,
+      std::optional<base::Time> file_modification_time = base::Time());
   ~MockBytesProvider() override;
 
   // BytesProvider implementation:
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • storage/browser/blob/blob_memory_controller.cc
  • storage/browser/file_system/local_file_stream_reader.cc
  • storage/browser/blob/blob_data_item.h
  • storage/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

  1. Writable Handle Transfer: When a blob is too large to fit in memory, BlobMemoryController::CreateEmptyFiles creates a temporary file with base::File::FLAG_WRITE and base::File::AddFlagsForPassingToUntrustedProcess. This writable handle is sent to the renderer via the BytesProvider::RequestAsFile Mojo call.
  2. 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>. In FileTransportStrategy::OnReply, the browser checks if (!time_file_modified). If the renderer sends a non-null std::optional containing a null base::Time (value 0), this check passes, and the null time is stored in the BlobDataItem.
  3. Validation Short-Circuit: Later, when the blob is read by a consumer (e.g., navigating to a blob: URL or reading via IndexedDB), LocalFileStreamReader uses FileStreamReader::VerifySnapshotTime to 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 returns true, 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 or postMessage), 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.

  1. Compromise a renderer process (e.g., via a standard V8 bug).
  2. Use the standard Web API to create a large Blob (e.g., > 5MB) to force disk-backed storage.
  3. Intercept the BytesProvider::RequestAsFile Mojo call in the compromised renderer.
  4. Write benign data to the provided base::File handle, but retain a copy of the writable file descriptor (e.g., using dup()).
  5. Reply to the browser’s callback with a std::optional<base::Time> containing base::Time().
  6. The browser finalizes the Blob and considers it immutable.
  7. Use the retained file descriptor to overwrite the file on disk with a malicious payload.
  8. Cause a victim frame or process to read the Blob (e.g., via window.open(blob_url)).

Suggested Fix

  1. Reject Null Timestamps: Update FileTransportStrategy::OnReply to explicitly reject null timestamps. if (!time_file_modified || time_file_modified->is_null()).
  2. Harden Verification: FileStreamReader::VerifySnapshotTime should treat a null expected_modification_time as a verification failure when validating immutable blobs.
  3. 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_create with F_ADD_SEALS on 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.

View on issue tracker