CVE-2026-5289
Overview
Background
- `NavigationEntryImpl`
- a browser-process object representing one session-history entry; it holds a tree of
TreeNodes mirroring the page’s frame tree. - `NavigationEntryImpl::TreeNode`
- a node owning a
FrameNavigationEntry(frame_entry), aparentpointer, and a vector ofchildrenheld asstd::unique_ptr<TreeNode>. - `RemoveEntryForFrame` / `GetTreeNode`
RemoveEntryForFramedeletes the subframe entry for a givenFrameTreeNode, locating the node by re-walking from the root viaGetTreeNode.- `AddDefaultIsolatedOriginIfNeeded`
- a
ChildProcessSecurityPolicyImplcall that records a removed frame’s origin for origin-isolation bookkeeping before the node is destroyed.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Commit message frames this as avoiding a redundant root re-lookup, not a UAF. Original code breaks right after erase, so the exact iterator/reference-invalidation UAF asserted is not conclusively shown by the diff.
The replace path (UpdatePolicy::kReplace) iterates for (const auto& child : parent_node->children) and, upon finding the matching child, called RemoveEntryForFrame(frame_tree_node, false) (the enclosing function’s name is not shown in the @@ -1183,... hunk). RemoveEntryForFrame re-derives the node from the root with GetTreeNode(frame_tree_node) and then does parent_node->children.erase(it) on the same std::vector the loop is iterating, which destroys a TreeNode and invalidates the range-based-for iterator and the child reference bound to that element. Because the removal was routed through a fresh lookup rather than operating on the child already in hand, the code erased an element out from under the active iteration, leaving a dangling reference/iterator to freed TreeNode memory — the use-after-free.
The fix factors the node-removal logic (origin bookkeeping plus the children.erase) into a new file-local helper RemoveTreeNodeFromParent(node) and calls it directly with child.get(), then breaks with the comment // child is now deleted., so the already-known node is removed exactly once and no stale lookup or continued iteration touches freed memory. RemoveEntryForFrame is likewise simplified to call the same helper, and its DCHECK(!frame_tree_node->IsMainFrame()) is promoted to a CHECK to harden the main-frame precondition.
RemoveEntryForFrame → GetTreeNode → children.erase) while already iterating parent_node->children, invalidating the live child reference; the fix removes the node already in hand via RemoveTreeNodeFromParent(child.get()) and breaks immediately.Attack Path
- Create a subframe history structure
From web content, build a page with subframes so a
NavigationEntryImplholds aTreeNodetree with children under aparent_node. - Trigger a replacing update
Navigate a subframe such that the browser takes the
UpdatePolicy::kReplacebranch and matches achildbyframe_unique_name. - Erase during iteration
Pre-patch,
RemoveEntryForFramere-locates the node and callsparent_node->children.erase(it), freeing aTreeNodewhile the range-based for loop’schildreference/iterator still points into that vector. - Use-after-free
The invalidated
child/iterator is used after the erase, dereferencing freedTreeNodememory in the browser process. The exact downstream dereference and any exploitation primitive are(inferred); the diff shows only the removal-during-iteration pattern and its fix.
Impact Assessment
NavigationEntryImpl to free a TreeNode while it is still referenced by the active iteration, causing a use-after-free in the privileged browser process. The vendor advisory rates this high severity and describes it as a use-after-free in Navigation; it does not report in-the-wild exploitation. A browser-process UAF is attractive for sandbox-escape-level impact, but controlled reuse or code execution here is (inferred) and not demonstrated by the patch.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
Audit Directions
- Erase during container iterationFlag code that calls a helper which may
erasefrom a container currently being walked by a range-based for loop; the loop’s reference/iterator becomes dangling the moment an element is removed. - Re-lookup instead of using the node in handReview paths that re-derive an object from a root/tree (
GetTreeNode) when the caller already holds it; the extra walk can target or free a different (or the same) element unexpectedly. - unique_ptr vectors as ownership treesAudit
std::vector<std::unique_ptr<Node>>frame/history trees for removal routines that assume stable references across mutation, especially where origin-isolation or security bookkeeping runs amid the removal.
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.