High watch CVSS 3.1 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in MHTML
DescriptionRace in MHTML
ComponentMHTML
Sub-componentMhtml
Bug ClassRace
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.
Tracker499119490
Fix commit685289fef67d (chromium/src) +17/-17
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-02 (per vendor advisory)
Fix landed2026-04-15 (public git)
Disclosed2026-04-28
Patch gap13 days — fix public in source before the advisory

Background

MHTML generation
the “Save page as MHTML” flow, driven in the browser process by MHTMLGenerationManager, which serializes every frame of a page in turn by messaging each renderer.
`FrameTreeNode` vs `RenderFrameHostImpl`
a FrameTreeNode (identified by FrameTreeNodeId) is a stable slot in the frame tree, while the RenderFrameHostImpl is the specific document currently hosted there; with RenderDocument, the document object can change over the node’s lifetime.
`GloballyFindByID`
resolves a FrameTreeNodeId back to the current FrameTreeNode/current_frame_host(), which may be a different document than the one originally enqueued.
`base::WeakPtr`
a non-owning pointer that automatically becomes null when its referent is destroyed, allowing a safe “is it still alive?” check.

Root Cause Analysis

The job queued frames to serialize as FrameTreeNodeId values in pending_frame_tree_node_ids_, then in SendToNextRenderFrame re-resolved each id with FrameTreeNode::GloballyFindByID and used ftn->current_frame_host(). Because serialization is asynchronous and interleaved with renderer round-trips, the document occupying a node can change (navigation / RenderDocument swap) between enqueue and processing, so the id could resolve to a different RenderFrameHostImpl than the one intended — or the node’s old current_frame_host() could already be gone. The only “went away” handling was if (!ftn) on the node, which does not detect that the node’s document was replaced, so stale document state could be acted upon.

The fix stores base::WeakPtr<RenderFrameHostImpl> (the specific document) in pending_render_frame_hosts_ instead of the node id; SendToNextRenderFrame now takes front().get() and checks if (!rfh) to detect a destroyed document, deriving the frame_tree_node_id_of_busy_frame_ from rfh->frame_tree_node() only when the document is still alive. This binds each queued work item to the exact document that was present when enqueued and makes staleness detectable rather than silently resolving to whatever document currently occupies the slot.

Key insight
The queue identified pending work by a reusable slot id (FrameTreeNodeId) that can be re-pointed to a different document, instead of by the specific document; switching to base::WeakPtr<RenderFrameHostImpl> ties each item to that document and nulls out safely when it is destroyed.

Attack Path

  1. Start an MHTML save of a multi-frame page The job enumerates frames and enqueues work items for each frame to be serialized in sequence.
  2. Change a frame's document mid-serialization While earlier frames are being serialized asynchronously, attacker-influenced content navigates a pending frame (or otherwise triggers a RenderDocument swap), replacing the RenderFrameHostImpl in that FrameTreeNode.
  3. Resolve the stale slot When the job reaches that item, the old code calls GloballyFindByID(frame_tree_node_id)->current_frame_host(), which resolves to the new document (or a node whose intended document is gone) rather than the originally enqueued one.
  4. Operate on the wrong/destroyed document Serialization proceeds against a mismatched or freed RenderFrameHost, the race condition the advisory describes; the post-fix base::WeakPtr null-check (!rfh) is what now prevents acting on a destroyed document (inferred: the precise unsafe dereference and any memory-corruption primitive are not shown in this diff).

Impact Assessment

An attacker who can cause a frame’s document to be swapped or destroyed during an in-progress MHTML save can make the browser-process generation job operate on a stale or wrong RenderFrameHostImpl, the race the advisory rates high severity (“Race in MHTML”). Preconditions are an MHTML save of a page the attacker can influence plus winning the navigation/serialization timing. The patch itself only demonstrates the stale-identity/lifetime problem and its correction via base::WeakPtr; any escalation to a concrete use-after-free or further memory-corruption primitive is (inferred) and not shown in the diff.

Changed Functions

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

Files Changed

  • content/browser/download/mhtml_generation_manager.cc

Audit Directions

  • Identity by reusable slot id across async work
    Flag queues/maps that persist a reusable identifier (FrameTreeNodeId, tab/window id) across asynchronous steps and later re-resolve it, since the slot’s contents can change; prefer a handle bound to the specific object.
  • Document identity under RenderDocument
    Audit browser-process code that stores FrameTreeNodeId and later calls current_frame_host(), as the document can be swapped; use RenderFrameHostImpl weak pointers where a specific document is meant.
  • Insufficient "went away" checks
    Review existing liveness checks that test only the container/node (if (!ftn)) and not whether the intended referent itself was replaced or destroyed.
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