Medium watch ~ plausible CVSS 8.8 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in FileAPI
DescriptionRace condition in FileAPI
ComponentFileAPI
Sub-componentGardener
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIPlausible
The mapped commit may not contain the whole fix.
Reviewer notes: 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.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker497574154
Fix commit0c52ba991c52 (chromium/src) +103/-336
Related commits68f2bf3d56f5 first attempt (reverted) · chromium/src
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-08
Patch gap13 days — fix public in source before the advisory

Background

Disk-backed blob transport
when a blob is too large for memory, storage writes its bytes into page files and a FileTransportStrategy coordinates 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 ShareableFileReference and its modification time into the blob being built.
`VerifySnapshotTime`
the FileStreamReader check that compares a file’s current mtime against the recorded expected_modification_time when the blob is read.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
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.
Re-analyzed on 2026-10-03 against the actual fix commit 0c52ba991c52. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (c25d7e463bd1).
The security invariant is that a finalized disk-backed blob’s page file is effectively immutable: a trustworthy modification time is recorded at transport time and re-verified at read time, so a later overwrite is detected. Per the reporter and commit message, the originally vulnerable design passed a writable page-file handle to the renderer and trusted the renderer’s reported modification time; a null time both got recorded and made verification pass unconditionally — the removed line 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).
Key insight
In the original handle-passing design (per the reporter and commit message), the browser trusted a renderer-reported file modification time and accepted a null value, which silently disabled the immutability/TOCTOU check. The primary restores handle passing, and the fix rejects null (and missing) timestamps at OnReply so no blob file is ever recorded with an unverifiable time.

Attack Path

  1. 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.
  2. Retain the writable handle In the original design (per the reporter; before 966e352a), the browser passed a writable file handle to the renderer via RequestAsFile. The pre-fix source shown here does not do this (blob_memory_controller.cc:179 opens without untrusted-process flags); the primary re-adds AddFlagsForPassingToUntrustedProcess (post-fix diff hunk @@ -176). The renderer keeps a copy (inferred: handle-retention mechanics are not shown in the material).
  3. Report a null timestamp The renderer replies to RequestAsFile with a null base::Time. Per the reporter (unverified; no OnReply without the null check appears in the material), the original OnReply checked only !time_file_modified and recorded the null time via Populate, yielding a null expected_modification_time.
  4. Bypass verification At read time VerifySnapshotTime returns true for 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.)
  5. 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

A compromised renderer could defeat the immutability guarantee of a large (disk-backed) blob it creates, enabling time-of-check/time-of-use tampering with blob file contents after browser-side finalization. The advisory classifies this as a medium-severity race condition in FileAPI with no in-the-wild exploitation; the precondition is an already-compromised renderer able to supply the finalization reply. Broader escalation (e.g. XSS or Site Isolation violation) is the reporter’s claim and is not demonstrable from the material (inferred).

Changed Functions

FunctionChangeNotes
for
storage/browser/blob/blob_memory_controller.cc
modified
FileTransportStrategy
storage/browser/blob/blob_transport_strategy.cc
modified
BindOnce
storage/browser/blob/blob_transport_strategy.cc
modified
limits_
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
for
storage/browser/blob/blob_transport_strategy.cc
modified
StreamToFilesWriter
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
  • storage/browser/blob/blob_transport_strategy_unittest.cc
  • storage/browser/test/mock_bytes_provider.cc

Audit Directions

  • Renderer-reported metadata as a trust anchor
    Treat 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 verifiers
    Audit predicates like VerifySnapshotTime for x.is_null() || ... patterns that turn a missing value into an unconditional pass; also check the callers themselves, since the reverted 68f2bf3d made VerifySnapshotTime reject 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 fixes
    When 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 OnReply null-rejection.
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)
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 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
Loading diff…

Original Bug Report

reported by [email protected]

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