CVE-2026-5289
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/renderer_host/navigation_entry_impl.cc |
modified | |
ifcontent/browser/renderer_host/navigation_entry_impl.cc |
modified |
Files Changed
content/browser/renderer_host/navigation_entry_impl.cc
Patch
From 43dd54a96e266921e64bdef75b15bfcda40c2cb3 Mon Sep 17 00:00:00 2001 From: Charlie Reis <[email protected]> Date: Thu, 26 Mar 2026 12:37:16 -0700 Subject: [PATCH] Remove NavigationEntryImpl::TreeNode from parent. If we already have the TreeNode to remove, it is not necessary to find it again from the root in RemoveEntryForFrame. Factor out the node removal code and use it directly. Change-Id: Id5d5f4cefa91e7d571230676698a2f11623a1f22 Bug: 495931147 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7703455 Reviewed-by: Alex Moshchuk <[email protected]> Commit-Queue: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1605715} --- diff --git a/content/browser/renderer_host/navigation_entry_impl.cc b/content/browser/renderer_host/navigation_entry_impl.cc index 2d36dab..ce2f66ca3 100644 --- a/content/browser/renderer_host/navigation_entry_impl.cc +++ b/content/browser/renderer_host/navigation_entry_impl.cc @@ -236,6 +236,33 @@ return true; } +// Removes a given subframe `node` directly from its parent, updating any +// necessary bookkeeping. Not for use with main frames. +void RemoveTreeNodeFromParent(NavigationEntryImpl::TreeNode* node) { + CHECK(node->parent); + auto* frame_entry = node->frame_entry.get(); + if (frame_entry && frame_entry->committed_origin()) { + // Normally default-isolated origins are tracked through their presence in + // session history, which is consulted whenever an origin newly requests + // isolation. If we remove a frame_entry, its origin won't be available + // to any future global walk if the same origin later wants to opt-in. So + // we add it to the non-opt-in list here to be spec compliant (unless it's + // currently opted-in, in which case this call will do nothing). + ChildProcessSecurityPolicyImpl::GetInstance() + ->AddDefaultIsolatedOriginIfNeeded( + frame_entry->site_instance()->GetIsolationContext(), + frame_entry->committed_origin().value(), + true /* global_ walk_or_frame_removal */); + } + + NavigationEntryImpl::TreeNode* parent_node = node->parent; + auto it = + std::ranges::find(parent_node->children, node, + &std::unique_ptr<NavigationEntryImpl::TreeNode>::get); + CHECK(it != parent_node->children.end()); + parent_node->children.erase(it); +} + void RegisterOriginsRecursive(NavigationEntryImpl::TreeNode* node, const url::Origin& origin) { if (node->frame_entry->committed_origin().has_value()) { @@ -1183,7 +1210,8 @@ for (const auto& child : parent_node->children) { if (child->frame_entry->frame_unique_name() == unique_name) { if (update_policy == UpdatePolicy::kReplace) { - RemoveEntryForFrame(frame_tree_node, false); + RemoveTreeNodeFromParent(child.get()); + // `child` is now deleted. break; } // If the document of the FrameNavigationEntry is changing, we must clear @@ -1280,7 +1308,7 @@ void NavigationEntryImpl::RemoveEntryForFrame(FrameTreeNode* frame_tree_node, bool only_if_different_position) { - DCHECK(!frame_tree_node->IsMainFrame()); + CHECK(!frame_tree_node->IsMainFrame()); NavigationEntryImpl::TreeNode* node = GetTreeNode(frame_tree_node); if (!node) { @@ -1292,26 +1320,7 @@ // FrameNavigationEntries and the FrameTree. if (!only_if_different_position || !InSameTreePosition(frame_tree_node, node)) { - auto* frame_entry = node->frame_entry.get(); - if (frame_entry && frame_entry->committed_origin()) { - // Normally default-isolated origins are tracked through their presence in - // session history, which is consulted whenever an origin newly requests - // isolation. If we remove a frame_entry, its origin won't be available - // to any future global walk if the same origin later wants to opt-in. So - // we add it to the non-opt-in list here to be spec compliant (unless it's - // currently opted-in, in which case this call will do nothing). - ChildProcessSecurityPolicyImpl::GetInstance() - ->AddDefaultIsolatedOriginIfNeeded( - frame_entry->site_instance()->GetIsolationContext(), - frame_entry->committed_origin().value(), - true /* global_ walk_or_frame_removal */); - } - NavigationEntryImpl::TreeNode* parent_node = node->parent; - auto it = - std::ranges::find(parent_node->children, node, - &std::unique_ptr<NavigationEntryImpl::TreeNode>::get); - CHECK(it != parent_node->children.end()); - parent_node->children.erase(it); + RemoveTreeNodeFromParent(node); } }
Original Bug Report
Potential Use-After-Free in NavigationEntryImpl via duplicate frame unique_names
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can supply duplicate unique_names for a parent and child frame, bypassing production DCHECK validations. During a frame replacement navigation, a BFS lookup erroneously matches the parent instead of the child, causing the parent’s history node to be prematurely freed. The browser then dereferences a dangling raw pointer to the freed parent node, resulting in a highly exploitable Use-After-Free.
Affected files:
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/navigation_entry_impl.cc
Estimated timestamp from git blame: 2025-11-19
Description
There is a potential Use-After-Free (UAF) vulnerability in the Browser process within content::NavigationEntryImpl. The root cause stems from insufficient browser-side validation of a frame’s unique_name. RenderFrameHostImpl relies on a DCHECK to ensure unique_name is not empty, but it never validates that the name is globally unique within the frame tree.
Because of this, a compromised renderer can create a frame tree where a parent frame and its child frame share the exact same unique_name. This breaks the assumption of NavigationEntryImpl::GetTreeNode, which relies on Breadth-First Search (BFS) and unique_name matching to locate a specific frame’s TreeNode in the session history tree.
When a navigation with UpdatePolicy::kReplace commits in the child frame, the browser attempts to remove the child’s old history entry. However, because BFS visits the parent first and both frames share the same name, the search returns the parent’s TreeNode. The browser subsequently deletes the parent node. Crucially, the calling function (AddOrUpdateFrameEntry) still holds a raw stack pointer to this parent node and subsequently uses it, triggering a Use-After-Free.
Note: The following exploitation steps are potential and based on static analysis, as our setup does not currently run active proof-of-concept exploits.
Potential Steps to Trigger
- Compromise a Renderer: An attacker gains arbitrary code execution in a renderer process.
- Create Duplicate Names: The renderer sends IPC messages to create a subframe (Frame A) with
unique_name= “duplicate_name”. It then creates a child of Frame A (Frame B) also withunique_name= “duplicate_name”. - Populate History: The attacker triggers normal navigations in both frames so that
NavigationEntryImplcreates aTreeNodefor both A and B. - Trigger Replacement: The attacker triggers a navigation in Frame B (the child) using
location.replace(), generating a commit IPC withUpdatePolicy::kReplaceand a craftednavigation_api_keystring payload.
Code Walkthrough
When the replacement navigation for Frame B commits, NavigationEntryImpl::AddOrUpdateFrameEntry is called (content/browser/renderer_host/navigation_entry_impl.cc:1106).
- Fetch Parent Node: At line 1173,
GetTreeNode(Frame B's parent)correctly returns Frame A’sTreeNode. This is stored inNavigationEntryImpl::TreeNode* parent_node, a raw pointer. - Initiate Removal: At line 1182, it loops through
parent_node->childrenand finds the entry for Frame B. Becauseupdate_policy == UpdatePolicy::kReplace, it callsRemoveEntryForFrame(frame_tree_node, false)(line 1186). - The BFS Flaw: Inside
RemoveEntryForFrame,GetTreeNode(Frame B)is called.GetTreeNodeuses BFS. Since Frame A (parent) is higher in the tree than Frame B (child), BFS evaluates Frame A first. Because the attacker explicitly set Frame A and Frame B to have the sameunique_name,TreeNode::MatchesFrameerroneously returnstruefor Frame A. - Erroneous Deletion:
GetTreeNodereturns Frame A’sTreeNodeinstead of Frame B’s.RemoveEntryForFrameproceeds to delete Frame A’sTreeNodefrom the root tree (line 1314). Frame A’s memory is freed. - Use-After-Free: Control returns to
AddOrUpdateFrameEntry. Theparent_nodestack variable is now dangling. At line 1211, a newFrameNavigationEntryis instantiated. This constructor copies the attacker-controllednavigation_api_keystring, allocating heap memory. The attacker can carefully size this string to reclaim the exact memory chunk just freed by Frame A’sTreeNode, forging the internalstd::vectormembers (begin,end,capacity). - Arbitrary Write: At line 1217, the code executes
parent_node->children.push_back(...). This operates on the attacker’s forgedstd::vectorinside the reclaimed memory chunk, providing a highly reliable arbitrary memory write primitive in the Browser process.
Impact
This provides a deterministic arbitrary memory write in the Browser process. An attacker can overwrite a function pointer (e.g., a vtable entry) to hijack control flow, leading to a full Sandbox Escape and Remote Code Execution (RCE).
Suggested Fix
- Strict Name Validation: Enforce strict browser-side validation in
RenderFrameHostImpl::CreateChildFrameto ensure thatframe_unique_nameis globally unique across the entireFrameTreebefore accepting the IPC. If a duplicate is detected, terminate the renderer process viabad_message::ReceivedBadMessage. - Safe Pointers: Convert the raw
NavigationEntryImpl::TreeNode* parent_nodepointer inAddOrUpdateFrameEntry(and similar navigation classes) to a safe reference (e.g., relying on robust identifiers rather than raw tree node pointers, or employingbase::SafeRef/ checking tree validity) so that destruction invalidates the usage. - Robust Node Lookup: Update
RemoveEntryForFrameto unconditionally verify the tree position of the returned node (removing the!only_if_different_positionshort-circuit) to ensure the deleted node actually corresponds to the correctFrameTreeNodetopology.
Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f
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. Please feel free to reach out to me if you have concerns or feedback.