CVE-2026-7351
Overview
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 byFrameTreeNodeId) is a stable slot in the frame tree, while theRenderFrameHostImplis the specific document currently hosted there; with RenderDocument, the document object can change over the node’s lifetime. - `GloballyFindByID`
- resolves a
FrameTreeNodeIdback to the currentFrameTreeNode/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.
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
- 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.
- 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
RenderFrameHostImplin thatFrameTreeNode. - 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. - Operate on the wrong/destroyed document
Serialization proceeds against a mismatched or freed
RenderFrameHost, the race condition the advisory describes; the post-fixbase::WeakPtrnull-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
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
| Function | Change | Notes |
|---|---|---|
ifcontent/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 workFlag 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 RenderDocumentAudit browser-process code that stores
FrameTreeNodeIdand later callscurrent_frame_host(), as the document can be swapped; useRenderFrameHostImplweak pointers where a specific document is meant. - Insufficient "went away" checksReview existing liveness checks that test only the container/node (
if (!ftn)) and not whether the intended referent itself was replaced or destroyed.
Patch
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 {
Original Bug Report
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.ccchrome/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
PageCaptureSaveAsMHTMLFunction::Run()checks permissions and saves theDocumentId(document_id_) of the authorized page.- After creating a temporary file,
TemporaryFileCreatedOnUIverifies that the current main frame’sDocumentIdstill matchesdocument_id_. If so, it callsWebContents::GenerateMHTML(). MHTMLGenerationManager::Job::initializeJob()takes a snapshot of the frame tree by storingFrameTreeNodeIds intopending_frame_tree_node_ids_.initializeJob()posts an asynchronous task toGetDownloadTaskRunner()to callCreateMHTMLFile.- 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. - The background task completes, and
MHTMLGenerationManager::Job::OnFileAvailableresumes on the UI thread. SendToNextRenderFrame()is called. It pops aFrameTreeNodeIdand callsftn->current_frame_host(). This now returns the newly restoredRenderFrameHost(the restricted page).- The browser calls
writer_->SerializeAsMHTML()on the restrictedRenderFrameHost. 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.
- A malicious extension (with
pageCapturepermission) opens a tab to a restricted URL likechrome://settings. - The extension navigates the tab away to an attacker-controlled origin (
https://attacker.com), placingchrome://settingsinto the BFCache. - The extension calls
chrome.pageCapture.saveAsMHTML({tabId: ...}). - Immediately after, the extension triggers a fast navigation back to the restricted page:
chrome.tabs.goBack(...). - The BFCache restoration commits quickly, racing the background file creation task (
CreateMHTMLFile). - The MHTML generation process completes using the newly restored document.
- 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.