Medium watch CVSS 4.2 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.2
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper privilege management in Downloads
DescriptionImproper privilege management in Downloads
ComponentDownloads
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker514006886
Fix commit68855c577a9b (chromium/src) +309/-172
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-06-11 (public git)
Disclosed2026-09-01
Patch gap82 days — fix public in source before the advisory

Background

MHTML generation
Chrome’s “Save page as MHTML” path where the renderer serializes each frame and the serialized bytes are written to a file chosen by the browser process.
`MHTMLOutputHandle`
the Mojo union passed to the renderer describing where to write output; pre-patch it could be a NewFileHandle (a duplicated base::File) or, now, a NewProducerHandle (a Mojo data-pipe producer).
Mojo data pipe
a unidirectional byte stream; here the browser keeps the consumer end and hands the renderer only the producer end, so the renderer streams bytes rather than holding a file descriptor.
`MHTMLDataPipeReader` / `mojo::DataPipeDrainer`
the new browser-side helper (run on the download task runner) that drains the consumer handle and performs the actual disk writes in the browser process.

Root Cause Analysis

Pre-patch, on POSIX and Windows the browser gave the renderer direct write access to the destination file by setting params->output_handle = mojom::MhtmlOutputHandle::NewFileHandle(browser_file_.Duplicate()), so the untrusted renderer process held a live, writable OS file handle to a browser-selected download path. That violates the privilege boundary between the sandboxed renderer and the browser: a compromised renderer could write arbitrary content to that file handle, and the file is the browser-chosen MHTML output.

The fix removes the file handle from the renderer entirely: SerializeAsMHTML (the frame-serialization request path) now creates a Mojo data pipe, sends only the producer handle to the renderer via NewProducerHandle, and stands up a base::SequenceBound<MHTMLDataPipeReader> that reads the consumer end and calls file_.WriteAtCurrentPosAndCheck in the browser process on the download task runner. The renderer’s MHTMLProducerHandleWriter streams its serialized WebThreadSafeData through a mojo::DataPipeProducer/MHTMLDataSource instead of writing to a file, so the browser retains sole authority over disk writes; the CL additionally converts MHTMLHandleWriter to a std::unique_ptr-based self-destruction model and removes the legacy mojo::SimpleWatcher to keep lifetime robust across the asynchronous streaming.

Key insight
The core mistake was handing the sandboxed renderer a duplicated writable file handle to the browser-chosen output path, granting it a privilege it should not have; the fix confines all disk writes to the browser process and gives the renderer only a data-pipe producer to stream bytes through.

Attack Path

  1. Initiate MHTML save An MHTML generation is started for a page, and the browser prepares to serialize each frame in the renderer.
  2. Renderer receives a writable file handle (pre-patch) On POSIX/Windows, the browser sets output_handle to NewFileHandle(browser_file_.Duplicate()), so the renderer process obtains a live writable OS handle to the browser-selected output file.
  3. Compromised renderer abuses the handle A renderer that is already compromised uses the duplicated file handle to write attacker-controlled bytes to the browser-chosen file, outside the intended serialize-this-frame contract — an improper-privilege-management condition (further filesystem impact is (inferred)).
  4. Fix removes the primitive Post-patch the renderer only holds a data-pipe producer; the browser drains the consumer via MHTMLDataPipeReader and performs every WriteAtCurrentPosAndCheck itself, so the renderer never gets a file handle.

Impact Assessment

The vulnerability gave the sandboxed renderer process a duplicated writable file handle to the browser-selected MHTML output path, an improper privilege granted across the renderer/browser trust boundary that a compromised renderer could abuse to control writes to that file. The advisory classifies this as “Improper privilege management in Downloads” and does not report in-the-wild exploitation; escalation to arbitrary-file overwrite, sandbox escape, or code execution is (inferred) beyond the file-handle exposure the patch demonstrably removes. The precondition is an already-compromised or malicious renderer participating in an MHTML generation.

Changed Functions

FunctionChangeNotes
MHTMLDataPipeReader
content/browser/download/mhtml_generation_manager.cc
modified
if
content/browser/download/mhtml_generation_manager.cc
modified
if
content/renderer/mhtml_handle_writer.cc
modified
for
content/renderer/mhtml_handle_writer.cc
modified
MHTMLDataSource
content/renderer/mhtml_handle_writer.cc
modified
write_position_
content/renderer/mhtml_handle_writer.cc
modified

Files Changed

  • content/browser/download/mhtml_generation_browsertest.cc
  • content/browser/download/mhtml_generation_manager.cc
  • content/renderer/render_frame_impl.cc
  • content/renderer/mhtml_handle_writer.cc
  • content/renderer/mhtml_handle_writer.h

Audit Directions

  • Handles duplicated into the renderer
    Look for other browser-to-renderer flows that pass base::File/OS handles (via Duplicate() or platform file mojo types) into a sandboxed process instead of streaming data through a pipe the browser controls.
  • Privilege confined by platform
    Flag features that already used a safer transport on some platforms but retained a direct-handle path on POSIX/Windows, indicating an inconsistent, weaker privilege boundary.
  • Async lifetime around streaming
    Audit self-destructing/SequenceBound streaming helpers to ensure completion callbacks and drainer/watcher teardown cannot destroy an object while it is still executing on the stack.
From 68855c577a9bd5e3d4b6ea5fad41b04f1ee684f8 Mon Sep 17 00:00:00 2001
From: Min Qin <qinmin@chromium.org>
Date: Thu, 11 Jun 2026 00:01:46 -0700
Subject: [PATCH] Secure MHTML generation by introducing a Mojo Data Pipe on all platforms.

This CL secures the MHTML generation path by introducing a Mojo Data
Pipe on all platforms (including POSIX and Windows):
    1. The browser process creates a Mojo Data Pipe, passing the Producer handle to the renderer and retaining the Consumer handle.
    2. A new helper class, MHTMLDataPipeReader, runs on the blocking download task runner to read incoming serialized MHTML data from the Consumer handle and write it safely to disk in the browser process.
    3. MHTMLHandleWriter and its subclasses are updated to use a self-destruction mechanism via std::unique_ptr (passing the unique_ptr through the asynchronous task/callback chain) to guarantee robust lifetime management during the Mojo streaming process. This completely avoids sequence lifetime concerns and potential memory leaks upon unexpected termination.
    4. Legacy mojo::SimpleWatcher member and includes are removed from the browser process, as the asynchronous reading is now entirely driven by mojo::DataPipeDrainer.

Bug: 514006886
Change-Id: Icf51a14c5422e725f2a99e69ed964f166b788a6a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7883815
Commit-Queue: Min Qin <qinmin@chromium.org>
Reviewed-by: Justin DeWitt <dewittj@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1645142}
---
diff --git a/content/browser/download/mhtml_generation_manager.cc b/content/browser/download/mhtml_generation_manager.cc
index 511dd0f4..e089638 100644
--- a/content/browser/download/mhtml_generation_manager.cc
+++ b/content/browser/download/mhtml_generation_manager.cc
@@ -18,6 +18,8 @@
 #include "base/strings/string_util.h"
 #include "base/strings/string_view_util.h"
 #include "base/strings/stringprintf.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/threading/sequence_bound.h"
 #include "base/time/time.h"
 #include "base/trace_event/trace_event.h"
 #include "base/types/optional_util.h"
@@ -37,6 +39,8 @@
 #include "content/public/common/mhtml_generation_params.h"
 #include "mojo/core/embedder/embedder.h"
 #include "mojo/public/cpp/bindings/associated_remote.h"
+#include "mojo/public/cpp/system/data_pipe.h"
+#include "mojo/public/cpp/system/data_pipe_drainer.h"
 #include "net/base/mime_util.h"
 #include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
 #include "third_party/perfetto/include/perfetto/tracing/track.h"
@@ -130,6 +134,69 @@
   return browser_file;
 }
 
+class MHTMLDataPipeReader : public mojo::DataPipeDrainer::Client {
+ public:
+  using CompletionCallback =
+      base::OnceCallback<void(content::mojom::MhtmlSaveStatus)>;
+
+  MHTMLDataPipeReader(base::File file,
+                      mojo::ScopedDataPipeConsumerHandle consumer_handle,
+                      CompletionCallback callback)
+      : file_(std::move(file)),
+        consumer_handle_(std::move(consumer_handle)),
+        callback_(std::move(callback)) {}
+
+  MHTMLDataPipeReader(const MHTMLDataPipeReader&) = delete;
+  MHTMLDataPipeReader& operator=(const MHTMLDataPipeReader&) = delete;
+
+  ~MHTMLDataPipeReader() override = default;
+
+  void Start() {
+    DCHECK(download::GetDownloadTaskRunner()->RunsTasksInCurrentSequence());
+    drainer_ = std::make_unique<mojo::DataPipeDrainer>(
+        this, std::move(consumer_handle_));
+  }
+
+  // mojo::DataPipeDrainer::Client:
+  void OnDataAvailable(base::span<const uint8_t> data) override {
+    DCHECK(download::GetDownloadTaskRunner()->RunsTasksInCurrentSequence());
+    if (completed_) {
+      return;
+    }
+    if (!file_.WriteAtCurrentPosAndCheck(data)) {
+      Complete(content::mojom::MhtmlSaveStatus::kFileWritingError);
+    }
+  }
+
+  void OnDataComplete() override {
+    DCHECK(download::GetDownloadTaskRunner()->RunsTasksInCurrentSequence());
+    Complete(content::mojom::MhtmlSaveStatus::kSuccess);
+  }
+
+ private:
+  void Complete(content::mojom::MhtmlSaveStatus status) {
+    DCHECK(download::GetDownloadTaskRunner()->RunsTasksInCurrentSequence());
+    if (completed_) {
+      return;
+    }
+    completed_ = true;
+    // We do not reset |drainer_| here because this method can be called
+    // synchronously from OnDataAvailable() (which is called by the drainer).
+    // Resetting it synchronously would destroy the drainer while it is still
+    // executing on the stack, leading to a Use-After-Free. The drainer will
+    // be safely destroyed asynchronously when this MHTMLDataPipeReader is
+    // deleted.
+    content::GetUIThreadTaskRunner({})->PostTask(
+        FROM_HERE, base::BindOnce(std::move(callback_), status));
+  }
+
+  base::File file_;
+  mojo::ScopedDataPipeConsumerHandle consumer_handle_;
+  std::unique_ptr<mojo::DataPipeDrainer> drainer_;
+  CompletionCallback callback_;
+  bool completed_ = false;
+};
+
 }  // namespace
 
 namespace content {
@@ -176,8 +243,7 @@
       mojom::MhtmlSaveStatus save_status,
       const std::string& boundary,
       base::File file,
-      const std::vector<MHTMLExtraDataPart>& extra_data_parts,
-      std::unique_ptr<mojo::SimpleWatcher> watcher);
+      const std::vector<MHTMLExtraDataPart>& extra_data_parts);
 
   // Creates a string that encompasses any remaining extra data parts to write
   // to the file.
@@ -285,17 +351,16 @@
   // MHTMLFileWriter instance for the frame being currently serialized.
   mojo::AssociatedRemote<mojom::MhtmlFileWriter> writer_;
 
-  // Watcher to detect new data written to |mhtml_data_consumer_|.
-  // This is instantiated and destroyed in the download sequence for each frame.
-  std::unique_ptr<mojo::SimpleWatcher> watcher_;
 
-  // Consumer handle for data pipe streaming.
-  mojo::ScopedDataPipeConsumerHandle mhtml_data_consumer_;
 
   // Indicates whether there is currently data being streamed from the Renderer.
   // Not used when the renderer is writing directly to file.
   bool waiting_on_data_streaming_;
 
+  // The reader responsible for reading the MHTML data from the Mojo data pipe
+  // and writing it to the file.
+  base::SequenceBound<MHTMLDataPipeReader> pipe_reader_;
+
   base::WeakPtrFactory<Job> weak_factory_{this};
 };
 
@@ -313,7 +378,6 @@
 
 MHTMLGenerationManager::Job::~Job() {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  DCHECK(!watcher_);
 }
 
 void MHTMLGenerationManager::Job::initializeJob(WebContents* web_contents) {
@@ -394,10 +458,29 @@
 
   mojom::SerializeAsMHTMLParamsPtr params(CreateMojoParams());
 
-  // File::Duplicate() creates a reference to this file for use in the
-  // Renderer.
+  mojo::ScopedDataPipeProducerHandle producer;
+  mojo::ScopedDataPipeConsumerHandle consumer;
+  MojoResult result = mojo::CreateDataPipe(nullptr, producer, consumer);
+  if (result != MOJO_RESULT_OK) {
+    return mojom::MhtmlSaveStatus::kStreamingError;
+  }
+
   params->output_handle =
-      mojom::MhtmlOutputHandle::NewFileHandle(browser_file_.Duplicate());
+      mojom::MhtmlOutputHandle::NewProducerHandle(std::move(producer));
+
+  waiting_on_data_streaming_ = true;
+
+  base::File file_copy = browser_file_.Duplicate();
+  if (!file_copy.IsValid()) {
+    return mojom::MhtmlSaveStatus::kFileCreationError;
+  }
+
+  pipe_reader_.emplace(
+      download::GetDownloadTaskRunner(), std::move(file_copy),
+      std::move(consumer),
+      base::BindOnce(&Job::DoneWritingToDisk, weak_factory_.GetWeakPtr()));
+
+  pipe_reader_.AsyncCall(&MHTMLDataPipeReader::Start);
 
   // Send a Mojo request to Renderer to serialize its frame.
   DCHECK(frame_tree_node_id_of_busy_frame_.is_null());
@@ -419,10 +502,14 @@
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
 
   // If the Job has prematurely finalized and marked as finished, make this
-  // response no-op.
+  // response a no-op. This can happen if MHTMLDataPipeReader posted this task
+  // to the UI thread before it was reset in MarkAsFinished(). Since the Job
+  // is still alive waiting for the file to close, the weak pointer remains
+  // valid and this task will still run.
   if (is_finished_)
     return;
 
+  pipe_reader_.Reset();
   waiting_on_data_streaming_ = false;
   MaybeSendToNextRenderFrame(save_status);
 }
@@ -474,11 +561,15 @@
   }
   is_finished_ = true;
   writer_.reset();
+  pipe_reader_.Reset();
 
-  // Additionally, |watcher_| may also invoke DoneWritingToDisk() from
-  // the download sequence, potentially calling this twice. We cannot disable
-  // |watcher_| notifications similar to |writer_|, since it exists in
-  // the download sequence, so we handle the case in DoneWritingToDisk().
+  // Additionally, MHTMLDataPipeReader may have already posted
+  // DoneWritingToDisk() to the UI thread before we reset it here. Since the Job
+  // is still alive (waiting for the file to close on the download thread), the
+  // weak pointer remains valid, and DoneWritingToDisk() will eventually run. To
+  // prevent this from causing MarkAsFinished() to be called again (which would
+  // hit the NOTREACHED() above), we handle this case by returning early in
+  // DoneWritingToDisk() if |is_finished_| is true.
 
   TRACE_EVENT_INSTANT("page-serialization",
                       perfetto::StaticString("JobFinished"),
@@ -500,8 +591,7 @@
       FROM_HERE,
       base::BindOnce(&MHTMLGenerationManager::Job::FinalizeOnFileThread,
                      save_status, mhtml_boundary_marker_,
-                     std::move(browser_file_), std::move(extra_data_parts_),
-                     std::move(watcher_)),
+                     std::move(browser_file_), std::move(extra_data_parts_)),
       base::BindOnce(&Job::OnFinished, weak_factory_.GetWeakPtr()));
 }
 
@@ -586,11 +676,8 @@
     mojom::MhtmlSaveStatus save_status,
     const std::string& boundary,
     base::File file,
-    const std::vector<MHTMLExtraDataPart>& extra_data_parts,
-    std::unique_ptr<mojo::SimpleWatcher> watcher) {
+    const std::vector<MHTMLExtraDataPart>& extra_data_parts) {
   DCHECK(download::GetDownloadTaskRunner()->RunsTasksInCurrentSequence());
-
-  watcher.reset();
   DCHECK(!boundary.empty());
 
   if (save_status == mojom::MhtmlSaveStatus::kSuccess) {
diff --git a/content/renderer/mhtml_handle_writer.cc b/content/renderer/mhtml_handle_writer.cc
index e0baea3..9c387216 100644
--- a/content/renderer/mhtml_handle_writer.cc
+++ b/content/renderer/mhtml_handle_writer.cc
@@ -12,6 +12,7 @@
 #include "base/trace_event/trace_event.h"
 #include "content/common/download/mhtml_file_writer.mojom.h"
 #include "content/public/renderer/render_thread.h"
+#include "mojo/public/cpp/system/data_pipe_producer.h"
 #include "third_party/blink/public/platform/web_thread_safe_data.h"
 #include "third_party/perfetto/include/perfetto/tracing/track.h"
 
@@ -23,18 +24,20 @@
     : main_thread_task_runner_(std::move(main_thread_task_runner)),
       callback_(std::move(callback)) {}
 
-MHTMLHandleWriter::~MHTMLHandleWriter() {}
+MHTMLHandleWriter::~MHTMLHandleWriter() = default;
 
 void MHTMLHandleWriter::WriteContents(
+    std::unique_ptr<MHTMLHandleWriter> self,
     std::vector<blink::WebThreadSafeData> mhtml_contents) {
   TRACE_EVENT_BEGIN(
       "page-serialization", "Writing MHTML contents to handle",
       perfetto::NamedTrack::FromPointer("content::MHTMLHandleWriter", this));
   is_writing_ = true;
-  WriteContentsImpl(std::move(mhtml_contents));
+  WriteContentsImpl(std::move(self), std::move(mhtml_contents));
 }
 
-void MHTMLHandleWriter::Finish(mojom::MhtmlSaveStatus save_status) {
+void MHTMLHandleWriter::Finish(std::unique_ptr<MHTMLHandleWriter> self,
+                               mojom::MhtmlSaveStatus save_status) {
   DCHECK(!RenderThread::IsMainThread())
       << "Should not run in the main renderer thread";
   if (is_writing_) {
@@ -67,116 +70,145 @@
 MHTMLFileHandleWriter::~MHTMLFileHandleWriter() {}
 
 void MHTMLFileHandleWriter::WriteContentsImpl(
+    std::unique_ptr<MHTMLHandleWriter> self,
     std::vector<blink::WebThreadSafeData> mhtml_contents) {
   for (const auto& data : mhtml_contents) {
     if (data.IsEmpty()) {
       continue;
     }
     if (!file_.WriteAtCurrentPosAndCheck(base::as_byte_span(data))) {
-      Finish(mojom::MhtmlSaveStatus::kFileWritingError);
+      Finish(std::move(self), mojom::MhtmlSaveStatus::kFileWritingError);
       return;
     }
   }
-  Finish(mojom::MhtmlSaveStatus::kSuccess);
+  Finish(std::move(self), mojom::MhtmlSaveStatus::kSuccess);
 }
 
 void MHTMLFileHandleWriter::Close() {
   file_.Close();
 }
 
+namespace {
+
+class MHTMLDataSource : public mojo::DataPipeProducer::DataSource {
+ public:
+  explicit MHTMLDataSource(std::vector<blink::WebThreadSafeData> mhtml_contents)
+      : mhtml_contents_(std::move(mhtml_contents)) {
+    for (const auto& data : mhtml_contents_) {
+      total_size_ += data.size();
+    }
+  }
+
+  MHTMLDataSource(const MHTMLDataSource&) = delete;
+  MHTMLDataSource& operator=(const MHTMLDataSource&) = delete;
+
+  ~MHTMLDataSource() override = default;
+
+  // mojo::DataPipeProducer::DataSource:
+  uint64_t GetLength() const override { return total_size_; }
+
+  ReadResult Read(uint64_t offset, base::span<char> buffer) override {
+    ReadResult result;
+    if (offset != current_offset_) {
+      result.result = MOJO_RESULT_INVALID_ARGUMENT;
+      return result;
+    }
+
+    size_t bytes_to_read = buffer.size();
+    size_t bytes_read = 0;
+
+    while (bytes_to_read > 0 &&
+           current_buffer_index_ < mhtml_contents_.size()) {
+      const auto& data = mhtml_contents_[current_buffer_index_];
+      size_t data_size = data.size();
+
+      DCHECK_GE(data_size, offset_in_current_buffer_);
+      size_t available_in_buffer = data_size - offset_in_current_buffer_;
+
+      if (available_in_buffer == 0) {
+        current_buffer_index_++;
+        offset_in_current_buffer_ = 0;
+        continue;
+      }
+
+      size_t copy_size = std::min(bytes_to_read, available_in_buffer);
+
+      base::as_writable_bytes(buffer.subspan(bytes_read, copy_size))
+          .copy_from(base::as_byte_span(data).subspan(offset_in_current_buffer_,
+                                                      copy_size));
+
+      bytes_read += copy_size;
+      bytes_to_read -= copy_size;
+      offset_in_current_buffer_ += copy_size;
+      current_offset_ += copy_size;
+
+      if (offset_in_current_buffer_ == data_size) {
+        current_buffer_index_++;
+        offset_in_current_buffer_ = 0;
+      }
+    }
+
+    result.bytes_read = bytes_read;
+    result.result = MOJO_RESULT_OK;
+    return result;
+  }
+
+ private:
+  const std::vector<blink::WebThreadSafeData> mhtml_contents_;
+  uint64_t total_size_ = 0;
+  size_t current_buffer_index_ = 0;
+  size_t offset_in_current_buffer_ = 0;
+  uint64_t current_offset_ = 0;
+};
+
+}  // namespace
+
 MHTMLProducerHandleWriter::MHTMLProducerHandleWriter(
     scoped_refptr<base::TaskRunner> main_thread_task_runner,
     MHTMLWriteCompleteCallback callback,
     mojo::ScopedDataPipeProducerHandle producer)
     : MHTMLHandleWriter(std::move(main_thread_task_runner),
                         std::move(callback)),
-      producer_(std::move(producer)),
-      current_block_(0),
-      write_position_(0) {}
+      producer_(std::make_unique<mojo::DataPipeProducer>(std::move(producer))) {
+}
 
 void MHTMLProducerHandleWriter::WriteContentsImpl(
+    std::unique_ptr<MHTMLHandleWriter> self,
     std::vector<blink::WebThreadSafeData> mhtml_contents) {
-  DCHECK(mhtml_contents_.empty());
-  mhtml_contents_ = std::move(mhtml_contents);
-
   scoped_refptr<base::SequencedTaskRunner> task_runner =
       base::ThreadPool::CreateSequencedTaskRunner(
           {base::MayBlock(), base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN});
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/download/mhtml_generation_browsertest.cc b/content/browser/download/mhtml_generation_browsertest.cc
index 66c6675..cfa602b 100644
--- a/content/browser/download/mhtml_generation_browsertest.cc
+++ b/content/browser/download/mhtml_generation_browsertest.cc
@@ -280,57 +280,60 @@
   void SerializeAsMHTML(mojom::SerializeAsMHTMLParamsPtr params,
                         SerializeAsMHTMLCallback callback) override {
     // Upon using the overridden mock interface implementation, this will be
-    // handled by the product code as illustrated below.  (1), (2), (3) depict
-    // points in time when product code runs on UI thread and download sequence.
-    // For the repro, the message pipe disconnection needs to happen between (1)
-    // and (3).
+    // handled by the product code as illustrated below. (1), (2), (3), (4)
+    // depict points in time when product code runs on UI thread and download
+    // sequence. For the repro, the message pipe disconnection needs to happen
+    // after the browser has completed writing to disk (2) and before the job is
+    // destroyed in OnFinished (4).
     //
-    //   Test instance     UI thread         download sequence
-    //     ---------       ---------           -----------
-    //        |                |                     |
-    //    WE ARE HERE          |                     |
-    //        |                |                     |
-    //        |                |                     |
-    //        +--------------->+                     |
-    //        |                |                     |
-    //        |                |                     |
-    //        |                |                     |
-    //        |                |                     |
-    //        |                |                     |
-    //        |                |                     |
-    // (1)    |      MHTMLGenerationManager::Job     |
-    //        |      ::SerializeAsMHTMLResponse      |
-    //        |                +-------------------->+
-    //        |                |                     |
-    //        |                |                     |
-    //        |                |                     |
-    // (2)    |                |          MHTMLGenerationManager::Job
-    //        |                |          ::CloseFileOnFileThread
-    //        |                |                     |
-    //        |                |                     |
-    //        |           test needs to              |
-    //        |       disconnect message pipe        |
-    //        |      HERE - between (1) and (3)      |
-    //        |                |                     |
-    //        |                |                     |
-    //        |                +<--------------------+
-    //        |                |                     |
-    // (3)    |      MHTMLGenerationManager          |
-    //        |      Job::OnFinished                 |
-    //        |                |                     |
+    //   Test instance      UI thread          download sequence
+    //     ---------        ---------            -----------
+    //        |                 |                      |
+    //    WE ARE HERE           |                      |
+    //        |                 |                      |
+    //        +---------------->+                      |
+    //        |                 |                      |
+    //        |                 |                      |
+    // (1)    |       MHTMLGenerationManager::Job      |
+    //        |       ::SerializeAsMHTMLResponse       |
+    //        |                 |                      |
+    //        |                 |                      |
+    //        |                 |               [Streams MHTML data]
+    //        |                 |                      +
+    // (2)    |                 +<---------------------+
+    //        |       MHTMLGenerationManager::Job      |
+    //        |       ::DoneWritingToDisk              |
+    //        |                 +-------------------->+
+    //        |                 |                      |
+    // (3)    |                 |           MHTMLGenerationManager::Job
+    //        |                 |           ::FinalizeOnFileThread
+    //        |                 |                      |
+    //        |                 |                      |
+    //        |            test needs to               |
+    //        |       disconnect message pipe          |
+    //        |       HERE - between (2) and (4)       |
+    //        |                 |                      |
+    //        |                 |                      |
+    //        |                 +<---------------------+
+    //        |                 |                      |
+    // (4)    |       MHTMLGenerationManager           |
+    //        |       Job::OnFinished                  |
+    //        |                 |                      |
     //
-    // We hope that the error handler is invoked between (1) and (3) by doing
+    // We ensure the disconnect handler is invoked between (2) and (4) by doing
     // the following:
     // - From here, run the callback response to the UI thread. This queues
     //   the response message onto the bound message pipe.
-    // - After running the callback response, immediately unbind the message
-    //   pipe in order to queue a message onto the bound message pipe to notify
-    //   the Browser the connection was closed and invoke the error handler.
-    // - Upon resuming operation, the FIFO ordering property of associated
-    //   interfaces guarantees the execution of (1) before the error handler.
-    //   (1) posts (2) to the download sequence and terminates. The client end
-    //   then accepts the error notification and invokes the connection error
-    //   handler, guaranteeing its execution before (3).
+    // - After running the callback response, we post a sequence of tasks to
+    //   the download thread (DelayDisconnectionStep1 -> Step2 -> Step3 ->
+    //   DisconnectMojoReceiver) to delay resetting the Mojo connection until
+    //   after the data pipe streaming has completed and DoneWritingToDisk (2)
+    //   has run on the UI thread.
+    // - Upon resuming operation, the FIFO ordering property of Mojo interfaces
+    //   guarantees that (1) executes before the error handler. In parallel,
+    //   the data pipe is drained, posting (2) which then posts (3). The client
+    //   end then accepts the error notification and invokes the connection
+    //   error handler, guaranteeing its execution before (4).
 
     bool compute_contents_hash = params->output_handle->is_producer_handle();
 
@@ -359,31 +362,39 @@
     // we must ensure the write complete notification arrives before the
     // connection error notification, otherwise the Browser will report
     // an MhtmlSaveStatus != kSuccess. We can guarantee this by potentially
-    // running tasks after each watcher invocation to send notifications that
-    // it has been completed. We need at least two tasks to guarantee this,
-    // as there can be at most two watcher invocations to write a block of
-    // data smaller than the data pipe buffer to file.
+    // running tasks sequentially on the download thread to delay the Mojo
+    // connection reset. We need three task hops on the download sequence to
+    // ensure that the reader has completed reading the EOF, and the completion
+    // task has been posted to the UI thread (DoneWritingToDisk) before the
+    // receiver is reset.
     download::GetDownloadTaskRunner()->PostTask(
         FROM_HERE,
-        base::BindOnce(&RespondAndDisconnectMockWriter::TaskX,
+        base::BindOnce(&RespondAndDisconnectMockWriter::DelayDisconnectionStep1,
                        scoped_refptr<RespondAndDisconnectMockWriter>(this)));
   }
 
-  void TaskX() {
+  void DelayDisconnectionStep1() {
     download::GetDownloadTaskRunner()->PostTask(
         FROM_HERE,
-        base::BindOnce(&RespondAndDisconnectMockWriter::TaskY,
+        base::BindOnce(&RespondAndDisconnectMockWriter::DelayDisconnectionStep2,
                        scoped_refptr<RespondAndDisconnectMockWriter>(this)));
   }
 
-  void TaskY() {
+  void DelayDisconnectionStep2() {
+    download::GetDownloadTaskRunner()->PostTask(
+        FROM_HERE,
+        base::BindOnce(&RespondAndDisconnectMockWriter::DelayDisconnectionStep3,
+                       scoped_refptr<RespondAndDisconnectMockWriter>(this)));
+  }
+
+  void DelayDisconnectionStep3() {
     GetUIThreadTaskRunner({})->PostTask(
         FROM_HERE,
-        base::BindOnce(&RespondAndDisconnectMockWriter::TaskZ,
+        base::BindOnce(&RespondAndDisconnectMockWriter::DisconnectMojoReceiver,
                        scoped_refptr<RespondAndDisconnectMockWriter>(this)));
   }
 
-  void TaskZ() { receiver_.reset(); }
+  void DisconnectMojoReceiver() { receiver_.reset(); }
 
  private:
   friend base::RefCountedThreadSafe<RespondAndDisconnectMockWriter>;
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-origin MHTML overwrite and URL spoofing on POSIX due to shared O_APPEND flag

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: MHTML generation on POSIX platforms shares an Open File Description (OFD) between the browser and multiple renderers. A compromised renderer can potentially clear the shared O_APPEND flag via fcntl(F_SETFL), allowing it to overwrite data from other origins and spoof the final archive. This is particularly impactful on Android Offline Pages, where internal archives skip integrity checks, leading to persistent Omnibox URL spoofing.

Affected files:

  • content/browser/download/mhtml_generation_manager.cc
  • base/files/file_posix.cc
  • sandbox/linux/seccomp-bpf-helpers/syscall_parameters_restrictions.cc
  • chrome/browser/offline_pages/offline_page_mhtml_archiver.cc
  • chrome/browser/offline_pages/offline_page_request_handler.cc

Estimated timestamp from git blame: 2026-04-01

Summary

On POSIX platforms (Linux, Android, macOS), the O_APPEND flag on an open file description (OFD) is shared between all file descriptors referring to it. In Chrome’s MHTML generation, the browser process opens an output file with O_APPEND and then provides each renderer with a duplicated file descriptor via dup(). Because these FDs share the same OFD, a compromised renderer can clear the O_APPEND flag for the entire OFD using fcntl(F_SETFL, ...). This allows the renderer to use lseek and write to overwrite content written by other origins during the shared archival process.

Root Cause Analysis

In content/browser/download/mhtml_generation_manager.cc, the browser opens the MHTML output file using base::File::FLAG_APPEND. On POSIX, this results in the O_APPEND status flag being set on the Open File Description.

When the browser calls browser_file_.Duplicate() to send the handle to a renderer, it calls dup(), creating a new FD pointing to the same OFD. On Linux and Android, the renderer’s seccomp-bpf sandbox permits the modification of O_APPEND via fcntl:

// sandbox/linux/seccomp-bpf-helpers/syscall_parameters_restrictions.cc
const uint64_t kAllowedMask = O_ACCMODE | O_APPEND | O_NONBLOCK | O_SYNC | ...;
// ...
.Case(F_SETFL,
      If((long_arg & ~kAllowedMask) == 0, Allow()).Else(CrashSIGSYS()))

A compromised renderer can potentially call fcntl(fd, F_SETFL, 0) to clear O_APPEND. It can then use lseek(fd, 0, SEEK_SET) to move the shared file offset to the beginning and overwrite the archive with malicious content. The Android renderer sandbox also permits ftruncate(), which can be used to erase subsequent legitimate data.

Potential Impact: Android Offline Pages URL Spoofing

This vulnerability is most critical for the Android Offline Pages feature. When a page is archived, the browser records the main frame’s URL and the file’s SHA256 digest in a local database. Because archives in internal directories are considered trusted, OfflinePageRequestHandler skips the integrity validation (digest check) when serving them later.

By overwriting the archive during the generation phase, a compromised renderer for a cross-origin subframe can inject malicious HTML that will be associated with the victim main frame’s URL. When the user later opens the offline page, Chrome will display the victim’s URL in the Omnibox while rendering the attacker’s content.

Suggested Reproductions Steps (Potential)

  1. Compromise a renderer process for a cross-origin iframe (e.g., via a V8 exploit).
  2. Trigger a page save (e.g., ‘Download’ in Android Chrome) for a site embedding that iframe.
  3. In the compromised renderer, wait for the MHTML file handle.
  4. Execute: fcntl(fd, F_SETFL, 0); lseek(fd, 0, SEEK_SET); write(fd, malicious_data, len); ftruncate(fd, len);.
  5. Wait for the browser to finalize the archive.
  6. Access the saved offline page to observe the URL spoof.

Suggested Fix

To prevent this escalation, the browser should avoid sharing the same Open File Description with multiple renderers if O_APPEND is the only intended protection. Possible solutions include:

  1. Platform-specific hardening: On Windows, the kernel enforces FILE_APPEND_DATA permissions. On POSIX, consider using a pipe to receive data from renderers and having the browser perform the writes.
  2. Verify file integrity: Ensure that even internal archives undergo mandatory digest validation before being served to the user in OfflinePageRequestHandler.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker