CVE-2026-4449
Overview
Background
- `PendingInvalidations`
- Blink’s deferred style-invalidation bookkeeping that records each node’s pending invalidation sets until invalidation runs.
- `PendingInvalidationMap`
- a
HeapHashMapkeyed byContainerNodewhose value, before the fix, was aNodeInvalidationSetsstored inline in the table. - `HeapHashMap`
- Blink’s garbage-collected open-addressed hash map that is not node-based, so an insert can rehash and relocate its value storage.
Root Cause Analysis
45c5a70d984d), which an audit found was a preparatory/helper change, not the fix. This page now points to the commit whose diff removes the bug.In the pre-fix source, PendingInvalidations::ScheduleInvalidationSetsForNode binds a live reference NodeInvalidationSets& pending_invalidations = EnsurePendingInvalidations(node); at pending_invalidations.cc:85-86 and then iterates invalidation_lists.siblings at :87-99. Inside that loop, :94-96 calls PossiblyScheduleNthPseudoInvalidations(node) while the reference is still held, and :97 subsequently touches pending_invalidations.Siblings().push_back(...). EnsurePendingInvalidations at :219-221 inserts into pending_invalidation_map_ via pending_invalidation_map_.insert(&node, NodeInvalidationSets()) and returns add_result.stored_value->value, i.e. a reference directly into the map’s backing. Because PendingInvalidationMap was HeapHashMap<Member<ContainerNode>, NodeInvalidationSets> (pending_invalidations.h:19-20) with the value stored inline, a re-entrant insertion for the parent (via StyleEngine::ScheduleNthPseudoInvalidations, per the report) can cross the growth threshold, rehash, and move that backing — leaving the outer reference dangling and read after free.
The fix changes the value type to std::unique_ptr<NodeInvalidationSets>, so the referenced object has a stable heap address that survives a rehash; EnsurePendingInvalidations now inserts std::make_unique<NodeInvalidationSets>() and returns *add_result.stored_value->value, and the callers dereference the pointer.
The patch TIGHTENS the object’s lifetime/address stability rather than relaxing any check.
std::unique_ptr gives it a stable address so the reference stays valid.Attack Path
- Trigger nth-affecting invalidation
Use CSS positional/nth rules and script-driven DOM changes so
ScheduleInvalidationSetsForNoderuns on a node whose parent has positional-rule flags set. - Re-enter scheduling
The still-live sibling loop calls
PossiblyScheduleNthPseudoInvalidations, which schedules nth invalidations on the parent and re-entersPendingInvalidationson the same map. (per reporter) - Force a rehash
The re-entrant
EnsurePendingInvalidationsinsert crosses the map’s expansion threshold and rehashes, relocating the inline value backing. (inferred) - Use the stale reference
Control returns to the outer frame, which accesses the now-freed
NodeInvalidationSets, producing the use-after-free.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Documentthird_party/blink/renderer/core/css/invalidation/pending_invalidations.h |
modified | |
Elementthird_party/blink/renderer/core/css/invalidation/pending_invalidations.h |
modified |
Files Changed
third_party/blink/renderer/core/css/invalidation/pending_invalidations.ccthird_party/blink/renderer/core/css/invalidation/pending_invalidations.hthird_party/blink/renderer/core/css/invalidation/style_invalidator.cc
Audit Directions
- Reference into rehashable containerFlag any reference or pointer into a
HeapHashMap/HashMapvalue that is held across a call able to insert into the same map. - Re-entrant schedulingAudit invalidation and scheduling helpers that can synchronously re-enter and mutate the same collection mid-iteration.
Patch
diff --git a/third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc b/third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc
index 19c561c..6ea0879 100644
--- a/third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc
+++ b/third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc
@@ -186,11 +186,11 @@
auto pending_invalidations_iterator =
pending_invalidation_map_.find(&element);
if (pending_invalidations_iterator == pending_invalidation_map_.end() ||
- pending_invalidations_iterator->value.Siblings().empty()) {
+ pending_invalidations_iterator->value->Siblings().empty()) {
return;
}
NodeInvalidationSets& pending_invalidations =
- pending_invalidations_iterator->value;
+ *pending_invalidations_iterator->value;
InvalidationLists invalidation_lists;
for (const auto& invalidation_set : pending_invalidations.Siblings()) {
@@ -214,11 +214,12 @@
ContainerNode& node) {
auto it = pending_invalidation_map_.find(&node);
if (it != pending_invalidation_map_.end()) {
- return it->value;
+ return *it->value;
}
PendingInvalidationMap::AddResult add_result =
- pending_invalidation_map_.insert(&node, NodeInvalidationSets());
- return add_result.stored_value->value;
+ pending_invalidation_map_.insert(
+ &node, std::make_unique<NodeInvalidationSets>());
+ return *add_result.stored_value->value;
}
} // namespace blink
diff --git a/third_party/blink/renderer/core/css/invalidation/pending_invalidations.h b/third_party/blink/renderer/core/css/invalidation/pending_invalidations.h
index 5680db9c..81ce1a7b 100644
--- a/third_party/blink/renderer/core/css/invalidation/pending_invalidations.h
+++ b/third_party/blink/renderer/core/css/invalidation/pending_invalidations.h
@@ -5,6 +5,8 @@
#ifndef THIRD_PARTY_BLINK_RENDERER_CORE_CSS_INVALIDATION_PENDING_INVALIDATIONS_H_
#define THIRD_PARTY_BLINK_RENDERER_CORE_CSS_INVALIDATION_PENDING_INVALIDATIONS_H_
+#include <memory>
+
#include "third_party/blink/renderer/core/core_export.h"
#include "third_party/blink/renderer/core/css/invalidation/node_invalidation_sets.h"
#include "third_party/blink/renderer/platform/heap/collection_support/heap_hash_map.h"
@@ -16,8 +18,12 @@
class Document;
class Element;
+// HeapHashMap is not node-based and thus does not have stable iterators,
+// but we rely on pointers to stay stable even during modification of the map.
+// Thus, use unique_ptr to make sure the NodeInvalidationSets has a stable
+// address.
using PendingInvalidationMap =
- HeapHashMap<Member<ContainerNode>, NodeInvalidationSets>;
+ HeapHashMap<Member<ContainerNode>, std::unique_ptr<NodeInvalidationSets>>;
// Performs deferred style invalidation for DOM subtrees.
//
diff --git a/third_party/blink/renderer/core/css/invalidation/style_invalidator.cc b/third_party/blink/renderer/core/css/invalidation/style_invalidator.cc
index 3c49524..3c11b75 100644
--- a/third_party/blink/renderer/core/css/invalidation/style_invalidator.cc
+++ b/third_party/blink/renderer/core/css/invalidation/style_invalidator.cc
@@ -185,7 +185,7 @@
return;
}
NodeInvalidationSets& pending_invalidations =
- pending_invalidations_iterator->value;
+ *pending_invalidations_iterator->value;
DCHECK(pending_nth_sets_.empty());
Original Bug Report
UAF in blink::PendingInvalidations
Summary
PendingInvalidations::ScheduleInvalidationSetsForNode() takes a NodeInvalidationSets& reference to a value stored inside PendingInvalidations::pending_invalidation_map_ (a HeapHashMap) and then calls PossiblyScheduleNthPseudoInvalidations(node) while that reference is still live. PossiblyScheduleNthPseudoInvalidations() can synchronously re-enter invalidation scheduling on the parent (via StyleEngine::ScheduleNthPseudoInvalidations(parent)), which can insert into the same HeapHashMap and trigger Rehash(). That rehash frees/poisons the old hash table backing, invalidating the outer reference. Therefore, when the outer frame resumes, it access the stale NodeInvalidationSets and crash with UAF.
Details
PendingInvalidations::ScheduleInvalidationSetsForNode keeps a reference into pending_invalidation_map_ while calling out to a helper that can re-enter scheduling:
NodeInvalidationSets& pending_invalidations = EnsurePendingInvalidations(node);
for (auto& invalidation_set : invalidation_lists.siblings) {
if (pending_invalidations.Siblings().Contains(invalidation_set)) {
continue;
}
if (invalidation_set->InvalidatesNth()) {
PossiblyScheduleNthPseudoInvalidations(node);
}
pending_invalidations.Siblings().push_back(invalidation_set);
}
The callout is synchronous. In PossiblyScheduleNthPseudoInvalidations, when the parent has positional-rule flags set, it immediately schedules nth invalidations on the parent:
if ((parent->ChildrenAffectedByForwardPositionalRules() && node.nextSibling()) ||
(parent->ChildrenAffectedByBackwardPositionalRules() &&
node.previousSibling())) {
node.GetDocument().GetStyleEngine().ScheduleNthPseudoInvalidations(*parent);
}
StyleEngine::ScheduleNthPseudoInvalidations() then re-enters the same PendingInvalidations instance (source):
pending_invalidations_.ScheduleInvalidationSetsForNode(invalidation_lists,
nth_parent);
The re-entrant call can insert into the same HeapHashMap via PendingInvalidations::EnsurePendingInvalidations:
PendingInvalidationMap::AddResult add_result =
pending_invalidation_map_.insert(&node, NodeInvalidationSets());
return add_result.stored_value->value;
When the insertion crosses the hash table’s expansion threshold and MustRehashInPlace() is true, HashTable::Expand can do a same-size rehash:
// ...
else if (MustRehashInPlace()) {
new_size = table_size_;
} else {
new_size = table_size_ * 2;
}
return Rehash(new_size, entry);
When returned to the outer ScheduleInvalidationSetsForNode() frame, it continues operating on the stale pending_invalidations reference. The next Vector access through that stale/invalid value (Vector::size() in wtf/vector.h:1348) and triggers the UAF.
Bisection
This issue is introduced by the commit c98887131608ef87756a77a3493aac8b6b8ec02e, hence it affects all stable versions.
Reproduction
Download the chrome from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1588577.zip
Run the following commandline:
./chrome --no-sandbox poc.html
You would get the ASAN crash with UAP in asan.txt.
Suggested Fix
Avoid holding references/pointers into pending_invalidation_map_ across any call that can re-enter PendingInvalidations and mutate/rehash the same map.
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc;l=21
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/invalidation/pending_invalidations.cc;l=213
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/style_engine.cc;l=1459
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/style_engine.cc;l=2111
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/wtf/hash_table.h;l=1619
- https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1588577.zip