CVE-2026-91736
Overview
Background
- Slot assignment recalc
- the Blink pass that recomputes which light-DOM nodes are distributed into each
HTMLSlotElementof a shadow tree, tracked by the per-document counterslot_assignment_recalc_depth_. - `assigned_nodes_`
- the
HeapVector<Member<Node>>member ofHTMLSlotElementholding the nodes currently assigned to that slot, which is rebuilt (cleared and repopulated) whenever assignment is recalculated. - `Document::IsInSlotAssignmentRecalc()`
- a guard queried by slot/style code to decide whether a slot-assignment recalc is already in progress, used to forbid re-entrant recalculation.
- Use-after-free (UAF)
- dereferencing memory (here,
Nodepointers or the vector backing store) after it has been freed or reallocated, producing undefined behavior an attacker can steer.
Root Cause Analysis
Document::IsInSlotAssignmentRecalc() returned slot_assignment_recalc_depth_ == 1, so at depth 2 or greater it returned false, and a DCHECK_LE(slot_assignment_recalc_depth_, 1u) encoded the (false) assumption that the depth could never exceed 1. Because the guard reported “not in recalc” during a re-entrant (depth-2) recalculation, code that relies on it to forbid recursive slot assignment failed to block a synchronous, nested recalc. Meanwhile HTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes() and a second layout-tree detach loop iterated directly over the member assigned_nodes_, whose elements can trigger style/layout work that re-enters slot assignment and clears or reallocates assigned_nodes_ mid-iteration — invalidating the loop’s references and freeing the Node objects still being dereferenced.
The fix changes the guard to slot_assignment_recalc_depth_ > 0 so any in-progress recalc (including nested ones) is correctly reported, and additionally snapshots assigned_nodes_ into a local const HeapVector<Member<Node>> before each loop so iteration is stable even if a synchronous recalc mutates the member. Together these close both the missing re-entrancy guard and the direct-iteration hazard it exposed.
== 1) that silently failed for depth ≥ 2, letting re-entrant slot assignment recalc mutate assigned_nodes_ while it was being iterated; the fix generalizes the check to > 0 and iterates over defensive copies so the container cannot be freed out from under the loop.Attack Path
- Set up a slotted shadow tree
The attacker builds a shadow host with
HTMLSlotElements and assigned light-DOM nodes so thatassigned_nodes_is populated and a slot-assignment recalc will run. - Trigger the outer recalc
A DOM/style mutation causes Blink to enter slot assignment recalc, incrementing
slot_assignment_recalc_depth_and beginning to iterateassigned_nodes_inHTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes()(or the layout-tree detach loop). - Force a re-entrant recalc
Work performed on an assigned node during iteration (style recalc / layout-tree detach side effects) synchronously re-enters slot assignment at depth
2, where the pre-patchIsInSlotAssignmentRecalc()wrongly returnedfalseand so failed to forbid the nested recalculation. - Free the container mid-iteration
The nested recalc clears and rebuilds
assigned_nodes_, freeing theNodeelements and/or reallocating the vector while the outer loop still holds references into it. - Use the freed memory
The outer loop dereferences the now-dangling
Nodepointer (current->...), yielding a use-after-free that an attacker can shape into a read/write primitive and, from there, renderer compromise(inferred).
Impact Assessment
assigned_nodes_ during iteration. The only precondition is the ability to construct and mutate a shadow tree with slotted content, which is available to any web page. The advisory classifies this as a use-after-free in DOM; escalation beyond the renderer (sandbox escape or remote code execution) is not demonstrated by the patch and is marked (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/html/html_slot_element.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/html_slot_element.ccthird_party/blink/renderer/core/dom/document.h
Audit Directions
- Exact-equality depth/counter guardsSearch for state checks written as
== 1(or== N) on re-entrancy or recursion counters where a>=/> 0comparison is intended; a nested increment silently defeats the guard. - Direct iteration over mutable heap containersFlag loops that iterate a member
HeapVector/HeapHashSet(e.g.assigned_nodes_) while the loop body can synchronously re-enter code that clears or reallocates that same container; prefer a local defensive copy. - DCHECK-encoded invariants that can actually breakReview
DCHECK_LE/DCHECK-asserted bounds on depth/size counters, since release builds do not enforce them and a violated assumption (here depth ≤ 1) can turn into a memory-safety bug rather than a crash.
Patch
From 8e20d98d7df75cd3b6c721cf0eac302f5495dfd2 Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
Date: Mon, 31 Aug 2026 13:37:41 -0700
Subject: [PATCH] Fix slot assignment recalculation depth check and add defensive copies
Document::IsInSlotAssignmentRecalc() previously checked for exact
equality with 1, returning false when slot assignment recalculation
depth was 2 or greater. This could allow re-entrant recalculation to
clear HTMLSlotElement::assigned_nodes_ during iteration.
Update IsInSlotAssignmentRecalc() to check if depth is greater than 0,
and add defensive copies of assigned_nodes_ before iterating in
HTMLSlotElement to prevent use-after-free issues from synchronous
recalculations.
Fixed: 553121008
Change-Id: I172d5f346e3905a18fd9648f5ea1aa97a5330325
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8312942
Auto-Submit: Mason Freed <masonf@chromium.org>
Reviewed-by: David Baron <dbaron@chromium.org>
Commit-Queue: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1689217}
---
diff --git a/third_party/blink/renderer/core/dom/document.h b/third_party/blink/renderer/core/dom/document.h
index a16eb0c9..ebbc80e6 100644
--- a/third_party/blink/renderer/core/dom/document.h
+++ b/third_party/blink/renderer/core/dom/document.h
@@ -1992,10 +1992,7 @@
return slot_assignment_recalc_depth_;
}
bool IsInSlotAssignmentRecalc() const {
- // Since we forbid recursive slot assignement recalc, the depth should be
- // <= 1.
- DCHECK_LE(slot_assignment_recalc_depth_, 1u);
- return slot_assignment_recalc_depth_ == 1;
+ return slot_assignment_recalc_depth_ > 0;
}
bool IsVerticalScrollEnforced() const { return is_vertical_scroll_enforced_; }
diff --git a/third_party/blink/renderer/core/html/html_slot_element.cc b/third_party/blink/renderer/core/html/html_slot_element.cc
index 589d1a27..c12ef59 100644
--- a/third_party/blink/renderer/core/html/html_slot_element.cc
+++ b/third_party/blink/renderer/core/html/html_slot_element.cc
@@ -302,7 +302,8 @@
void HTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes() {
Node* previous = nullptr;
- for (auto& current : assigned_nodes_) {
+ const HeapVector<Member<Node>> assigned_nodes = assigned_nodes_;
+ for (auto& current : assigned_nodes) {
bool mark_parent_slot_changed = false;
if (!current->NeedsStyleRecalc() && !current->GetLayoutObject()) {
if (current->IsTextNode() ||
@@ -349,7 +350,8 @@
// detach the node's layout tree.
StyleEngine& style_engine = GetDocument().GetStyleEngine();
StyleEngine::DetachLayoutTreeScope detach_scope(style_engine);
- for (auto& current : assigned_nodes_) {
+ const HeapVector<Member<Node>> assigned_nodes = assigned_nodes_;
+ for (auto& current : assigned_nodes) {
if (current->GetForceReattachLayoutTree()) {
current->DetachLayoutTree();
// Restore the force-reattach state for when it's no longer display
Original Bug Report
UAF in Document::IsInSlotAssignmentRecalc via strict equality check at depth >= 2
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: Document::IsInSlotAssignmentRecalc() tests strict equality against 1 rather than checking for a depth of at least 1, causing it to return false when the recalc depth is 2 or greater. When release builds encounter nested recalculations, Node::AssignedSlot() fails open and re-enters RecalcAssignment(). This can allow nested recalculations to clear and prompt-free the backing buffer of HTMLSlotElement::assigned_nodes_ while an outer frame is iterating over it by reference, potentially resulting in a use-after-free.
Affected files:
third_party/blink/renderer/core/dom/document.hthird_party/blink/renderer/core/dom/node.ccthird_party/blink/renderer/core/dom/flat_tree_traversal.ccthird_party/blink/renderer/core/dom/slot_assignment.ccthird_party/blink/renderer/core/html/html_slot_element.cc
Estimated timestamp from git blame: 2018-12-03
Root Cause Analysis
In third_party/blink/renderer/core/dom/document.h (lines 1992-1997), Document::IsInSlotAssignmentRecalc() is defined as:
bool IsInSlotAssignmentRecalc() const {
// Since we forbid recursive slot assignement recalc, the depth should be <= 1.
DCHECK_LE(slot_assignment_recalc_depth_, 1u);
return slot_assignment_recalc_depth_ == 1;
}
The predicate checks slot_assignment_recalc_depth_ == 1 rather than >= 1 (contrast with IsFlatTreeTraversalForbidden() at lines 1985-1987, which checks > 0). The recursion depth counter (Document::slot_assignment_recalc_depth_, line 3140) is unconditionally compiled and incremented whenever SlotAssignment::RecalcAssignment() runs (third_party/blink/renderer/core/dom/slot_assignment.cc:236-237). However, recursion-blocking scopes such as SlotAssignmentRecalcForbiddenScope (third_party/blink/renderer/core/dom/slot_assignment_recalc_forbidden_scope.h:32-43) and the anti-recursion checks in SlotAssignment::RecalcAssignment() (slot_assignment.cc:239-241) are no-ops or DCHECK-only in release builds.
Mechanism of the Failure
When a nested slot assignment recalculation reaches depth 2 (for instance, via FlatTreeTraversal::TraverseParent’s fallback-slot branch at flat_tree_traversal.cc:180, which calls HTMLSlotElement::AssignedNodes() without a depth check), Document::IsInSlotAssignmentRecalc() evaluates to false because 2 == 1 is false.
This causes Node::AssignedSlot() (third_party/blink/renderer/core/dom/node.cc:3546-3553) to fail open:
if (GetDocument().IsInSlotAssignmentRecalc()) {
// FlatTreeNodeData is not reliable here. Entering slow path.
return root->AssignedSlotFor(*this);
}
root->GetSlotAssignment().RecalcAssignment(); // Reaches here at depth >= 2
If a shadow root is re-dirtied during nested recalculation (e.g. cross-tree reassignment triggered from HTMLDetailsElement::ManuallyAssignSlots / HTMLSlotElement::Assign), RecalcAssignment() does not early-return and enters depth 3. The re-entrant recalculation calls HTMLSlotElement::WillRecalcAssignedNodes() -> ClearAssignedNodes() (third_party/blink/renderer/core/html/html_slot_element.cc:294-296), which calls assigned_nodes_.clear().
In wtf/vector.h:1688-1691, Vector::clear() executes ShrinkCapacity(0), which triggers an immediate prompt-free of the HeapVectorBacking<Member<Node>> in Oilpan via cppgc::subtle::FreeUnreferencedObject (third_party/blink/renderer/platform/heap/collection_support/heap_vector_backing.h:58-60). Because HTMLSlotElement::DetachDisplayLockedAssignedNodesLayoutTreeIfNeeded() (html_slot_element.cc:352-359) and UpdateFlatTreeNodeDataForAssignedNodes() (:305-336) iterate over assigned_nodes_ by direct reference without a defensive copy, returning to the outer frame resumes iteration over freed memory.
Potential Trigger Steps
(Note: These are potential steps deduced from static analysis; our tooling agent does not have the ability to run dynamic code.)
- Environment Setup: Execute in a context where an active
AXObjectCacheImplis maintained (such as on macOS or Android whereAriaModalPrunesAXTreeis enabled, or with accessibility active). - DOM Configuration:
- Construct host element
H1with author shadow rootR1containing an assigned slot in a display-locked subtree (content-visibility: hidden). - Attach a
<details>elementD(having UA shadow rootR2) underH1. - Assign a light child
XofDto anR1slot usingr1slot.assign(X). - Set up an
aria-modal="true"dialog and focus such that accessibility removal triggers anAncestorAriaModalDialogwalk across the fallback slot inR2and theD -> H1host-child boundary.
- Construct host element
- Lifecycle Trigger: Initiate slot assignment resolution (e.g., during layout/style update):
- Depth 1:
R1.RecalcAssignment()starts. InDetachDisplayLockedAssignedNodesLayoutTreeIfNeeded(),current->DetachLayoutTree()triggers AX removal. - Depth 2: The AX walk calls
parent_slot->AssignedNodes(), startingR2.RecalcAssignment(). InsideR2,HTMLDetailsElement::ManuallyAssignSlots()reassignsX, callingDidSlotChange()onR1’s slot and markingR1dirty again. - Depth 3: AX removal in
R2traverses the host-child boundary toD.AssignedSlot(). At depth 2,IsInSlotAssignmentRecalc()returnsfalse, invokingR1.RecalcAssignment()re-entrantly. - UAF: Depth 3 prompt-frees
R1’sassigned_nodes_backing buffer. When the stack unwinds to depth 1, the range-for loop inDetachDisplayLockedAssignedNodesLayoutTreeIfNeeded()dereferences freed memory.
- Depth 1:
Impact
Use-after-free in the sandboxed Blink renderer process on a prompt-freed HeapVectorBacking<Member<Node>>. MiraclePtr does not protect Oilpan heap vectors. This presents a potential remote code execution (RCE) primitive in the renderer process.
Suggested Fix
- Correct the predicate in
third_party/blink/renderer/core/dom/document.hto check for depth >= 1:bool IsInSlotAssignmentRecalc() const { DCHECK_LE(slot_assignment_recalc_depth_, 1u); return slot_assignment_recalc_depth_ >= 1; } - As defense-in-depth, take defensive copies before iterating over
assigned_nodes_inHTMLSlotElement::DetachDisplayLockedAssignedNodesLayoutTreeIfNeeded()andHTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes(), matching the existing defensive copies inAttachLayoutTreeForSlotChildren(),DetachLayoutTree(),RebuildDistributedChildrenLayoutTrees(), andRecalcStyleForSlotChildren()(html_slot_element.cc:408, 418, 444, 555).
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.