Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in MHTML
DescriptionRace in MHTML
ComponentMHTML
Bug ClassRace
Tracker499119490
Fix commit685289fef67d (chromium/src) +17/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-28

Changed Functions

FunctionChangeNotes
if
content/browser/download/mhtml_generation_manager.cc
modified

Files Changed

  • content/browser/download/mhtml_generation_manager.cc
From 685289fef67dbd93e761bb720d8cd1688d391748 Mon Sep 17 00:00:00 2001
From: Lukasz Anforowicz <lukasza@chromium.org>
Date: Wed, 15 Apr 2026 13:34:42 -0700
Subject: [PATCH] [mhtml] Store pending documents as `base::WeakPtr<RenderFrameHostImpl>`.

This is more correct, as the document stored in a `FrameTreeNode` can
change over the lifetime of the node, but with "Render Document" changes
`RenderFrameHostImpl` is associated with a specific document.

Fixed: 499119490
Change-Id: Ic6231c4f8d7bca362a057369d1ca95e794b9b012
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7762708
Commit-Queue: Łukasz Anforowicz <lukasza@chromium.org>
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1615376}
---

diff --git a/content/browser/download/mhtml_generation_manager.cc b/content/browser/download/mhtml_generation_manager.cc
index 61cc2eb..511dd0f4 100644
--- a/content/browser/download/mhtml_generation_manager.cc
+++ b/content/browser/download/mhtml_generation_manager.cc
@@ -12,6 +12,7 @@
 #include "base/files/file.h"
 #include "base/functional/bind.h"
 #include "base/memory/ptr_util.h"
+#include "base/memory/weak_ptr.h"
 #include "base/numerics/safe_conversions.h"
 #include "base/stl_util.h"
 #include "base/strings/string_util.h"
@@ -252,8 +253,8 @@
   // choices.
   MHTMLGenerationParams params_;
 
-  // The IDs of frames that still need to be processed.
-  base::queue<FrameTreeNodeId> pending_frame_tree_node_ids_;
+  // The documents that still need to be processed.
+  base::queue<base::WeakPtr<RenderFrameHostImpl>> pending_render_frame_hosts_;
 
   // Identifies a frame to which we've sent through
   // MhtmlFileWriter::SerializeAsMHTML but for which we didn't yet process
@@ -332,13 +333,12 @@
       // Skip inner tree placeholder nodes.
       continue;
     }
-    pending_frame_tree_node_ids_.push(node->frame_tree_node_id());
+    pending_render_frame_hosts_.push(node->current_frame_host()->GetWeakPtr());
   }
 
   // Main frame needs to be processed first.
-  DCHECK(!pending_frame_tree_node_ids_.empty());
-  DCHECK(FrameTreeNode::GloballyFindByID(pending_frame_tree_node_ids_.front())
-             ->parent() == nullptr);
+  CHECK(!pending_render_frame_hosts_.empty());
+  CHECK(pending_render_frame_hosts_.front().get()->is_main_frame());
 
   // Save off any extra data.
   auto* extra_parts = static_cast<MHTMLExtraPartsImpl*>(
@@ -369,16 +369,14 @@
 }
 
 mojom::MhtmlSaveStatus MHTMLGenerationManager::Job::SendToNextRenderFrame() {
-  DCHECK(browser_file_.IsValid());
-  DCHECK(!pending_frame_tree_node_ids_.empty());
+  CHECK(browser_file_.IsValid());
+  CHECK(!pending_render_frame_hosts_.empty());
 
-  FrameTreeNodeId frame_tree_node_id = pending_frame_tree_node_ids_.front();
-  pending_frame_tree_node_ids_.pop();
-
-  FrameTreeNode* ftn = FrameTreeNode::GloballyFindByID(frame_tree_node_id);
-  if (!ftn)  // The contents went away.
+  RenderFrameHostImpl* rfh = pending_render_frame_hosts_.front().get();
+  pending_render_frame_hosts_.pop();
+  if (!rfh) {  // The contents went away.
     return mojom::MhtmlSaveStatus::kFrameNoLongerExists;
-  RenderFrameHost* rfh = ftn->current_frame_host();
+  }
 
   if (writer_) {
     // If we reached here, means the work for previous frame is done, so it is
@@ -403,7 +401,8 @@
 
   // Send a Mojo request to Renderer to serialize its frame.
   DCHECK(frame_tree_node_id_of_busy_frame_.is_null());
-  frame_tree_node_id_of_busy_frame_ = frame_tree_node_id;
+  frame_tree_node_id_of_busy_frame_ =
+      rfh->frame_tree_node()->frame_tree_node_id();
 
   auto response_callback = base::BindOnce(&Job::SerializeAsMHTMLResponse,
                                           weak_factory_.GetWeakPtr());
@@ -542,7 +541,7 @@
   // If current operation is successful and there are more frames to process,
   // let save status depend on the result of sending the next request.
   if (save_status == mojom::MhtmlSaveStatus::kSuccess &&
-      !pending_frame_tree_node_ids_.empty() && CurrentFrameDone()) {
+      !pending_render_frame_hosts_.empty() && CurrentFrameDone()) {
     save_status = SendToNextRenderFrame();
   }
 
@@ -555,8 +554,9 @@
 
   // Otherwise report completion if there are no more frames to process
   // and Job is done processing the current frame.
-  if (pending_frame_tree_node_ids_.empty() && CurrentFrameDone())
+  if (pending_render_frame_hosts_.empty() && CurrentFrameDone()) {
     Finalize(mojom::MhtmlSaveStatus::kSuccess);
+  }
 }
 
 bool MHTMLGenerationManager::Job::CurrentFrameDone() const {
Loading diff…

Original Bug Report

reported by vm...@google.com

TOCTOU in MHTMLGenerationManager allows DOM exfiltration via BFCache race

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists between the chrome.pageCapture.saveAsMHTML API and MHTMLGenerationManager. An extension can race an asynchronous file creation task by triggering a fast BFCache restoration navigation, tricking the browser into serializing the DOM of a restricted page instead of the authorized one.

Affected files:

  • content/browser/download/mhtml_generation_manager.cc
  • chrome/browser/extensions/api/page_capture/page_capture_api.cc

Estimated timestamp from git blame: 2024-09-04

Summary

A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the MHTMLGenerationManager when combined with the chrome.pageCapture.saveAsMHTML extension API.

The saveAsMHTML API performs a permission check and verifies the target document’s ID before initiating MHTML generation. However, MHTMLGenerationManager::Job::initializeJob captures only the persistent FrameTreeNodeIds of the page before posting an asynchronous task to a background thread to create the target file (CreateMHTMLFile).

While the background task is executing, an attacker can trigger a fast navigation, such as restoring a restricted page (e.g., chrome://settings) from the Back/Forward Cache (BFCache). When the file creation task finishes and execution resumes on the UI thread, MHTMLGenerationManager::Job::SendToNextRenderFrame fetches the current_frame_host() for each captured FrameTreeNodeId. Because the BFCache navigation has already committed, the job will instruct the restricted page’s RenderFrameHost to serialize its DOM, bypassing the extension API’s origin checks and exfiltrating sensitive data.

Technical Details

  1. PageCaptureSaveAsMHTMLFunction::Run() checks permissions and saves the DocumentId (document_id_) of the authorized page.
  2. After creating a temporary file, TemporaryFileCreatedOnUI verifies that the current main frame’s DocumentId still matches document_id_. If so, it calls WebContents::GenerateMHTML().
  3. MHTMLGenerationManager::Job::initializeJob() takes a snapshot of the frame tree by storing FrameTreeNodeIds into pending_frame_tree_node_ids_.
  4. initializeJob() posts an asynchronous task to GetDownloadTaskRunner() to call CreateMHTMLFile.
  5. During this task hop, a fast navigation (e.g., a BFCache restore) can commit on the UI thread, swapping the document in the main frame’s FrameTreeNode.
  6. The background task completes, and MHTMLGenerationManager::Job::OnFileAvailable resumes on the UI thread.
  7. SendToNextRenderFrame() is called. It pops a FrameTreeNodeId and calls ftn->current_frame_host(). This now returns the newly restored RenderFrameHost (the restricted page).
  8. The browser calls writer_->SerializeAsMHTML() on the restricted RenderFrameHost. The renderer process does not perform URL or permission checks and serializes the restricted DOM.

Potential Steps to Reproduce

Note: These steps are suggested based on code analysis; a working PoC has not yet been executed.

  1. A malicious extension (with pageCapture permission) opens a tab to a restricted URL like chrome://settings.
  2. The extension navigates the tab away to an attacker-controlled origin (https://attacker.com), placing chrome://settings into the BFCache.
  3. The extension calls chrome.pageCapture.saveAsMHTML({tabId: ...}).
  4. Immediately after, the extension triggers a fast navigation back to the restricted page: chrome.tabs.goBack(...).
  5. The BFCache restoration commits quickly, racing the background file creation task (CreateMHTMLFile).
  6. The MHTML generation process completes using the newly restored document.
  7. The extension reads the resulting MHTML file to access the DOM of chrome://settings.

Suggested Fix

Instead of storing persistent FrameTreeNodeIds, MHTMLGenerationManager::Job should store GlobalRenderFrameHostIds or the DocumentId for each frame during initializeJob. When iterating in SendToNextRenderFrame(), the job should verify that the RenderFrameHost still matches the saved ID before proceeding with serialization. Alternatively, the job should abort if a navigation occurs while it is pending.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker
Links in the report