CVE-2026-118356
Overview
Background
- `LinkedHashSet` / `HeapLinkedHashSet<WeakMember<T>>`
- A WTF set that preserves insertion order. It is backed by a doubly-linked list (
list_) plus avalue_to_index_HashMapused for O(1) lookup. The Oilpan garbage-collected variant can holdWeakMember<T>entries, which are cleared when the referent dies. - `IteratorWrapper` / `map_iterator_`
LinkedHashSet::IteratorWrapperwraps alist_iterator (iterator_) and, crucially, also holds avalue_to_index_map iterator (map_iterator_). During a conservative GC, that live map iterator is what keeps weak entries “strong” (prevents them from being cleared out from under the iteration).- `HashTable::CheckModifications()` / `modifications_`
- A DCHECK-only mechanism: the underlying
HashTablebumps amodifications_counter on structural change and iterators compare a snapshot (container_modifications_) against it to detect illegal mutation-during-iteration. - Rehash
- When enough entries are inserted,
value_to_index_’s backing store is reallocated (rehashed), invalidating any raw iterator into the old table.
Root Cause Analysis
LinkedHashSet iterators were documented and designed to tolerate modification of the set during iteration — true for the ordinary (strong) case, where only the list_ iterator matters and moving/adding items does not invalidate it. But for HeapLinkedHashSet<WeakMember<T>> / GCedHeapLinkedHashSet<WeakMember<T>>, IteratorWrapper additionally caches map_iterator_ into value_to_index_ precisely so a conservative GC can strongify the weak entries being visited. Inserting into the set while such an iterator is live can rehash value_to_index_, freeing its old backing store and leaving map_iterator_ dangling. A subsequent conservative GC then dereferences that stale map iterator — a classic use-after-free of the hash map’s backing store (and a weak-strongification failure that can let live entries be collected).
The fix (third_party/blink/renderer/platform/wtf/linked_hash_set.h and hash_table.h) adds a CheckModifications() to IteratorWrapper that, only when IsWeakV<Value>, forwards to map_iterator_.impl_.CheckModifications(), and calls it from operator*, operator->, operator++, and operator--. This turns illegal modify-during-iteration of a weak LinkedHashSet into a DCHECK failure instead of silent corruption, and the comments are updated to state the exception applies only to non-weak LinkedHashSet. Because a DCHECK only catches the bug in debug builds, the real fix is the four call-site changes that snapshot the set before iterating: FullscreenController::NotifyFramesOfFullscreenEntry copies *pending_frames_ into a HeapVector (and clears early); Page::SetVisibilityState copies page_visibility_observer_set_ into a HeapVector and re-checks Contains(observer) before dispatch; DOMTaskSignal::SignalPriorityChange copies both priority_change_algorithms_ and source_signal_manager->GetDependentSignals() into HeapVectors; and LazyLoadMediaObserver::LoadAllImagesAndBlockLoadEvent defers the mutating LoadDeferredImageBlockingLoad() calls to a second loop so it does not mutate the observed set mid-iteration. The diff’s class (UAF) matches the advisory.
HeapLinkedHashSet iterator secretly holds a second iterator into the lookup HashMap; mutating the set while iterating can rehash that map and leave the hidden iterator pointing at freed backing store, which the next conservative GC dereferences.Attack Path
- Reach a vulnerable iteration
Attacker drives one of the patched sinks from web content: fire fullscreen requests (
NotifyFramesOfFullscreenEntry), toggle page visibility (SetVisibilityState), change aTaskSignalpriority (SignalPriorityChange), or trigger lazy-load (LoadAllImagesAndBlockLoadEvent). (inferred reachability for the specific sink) - Register weak dependents
Arrange for the relevant weak
LinkedHashSet(e.g. dependent signals / observers) to hold entries while iteration is in progress. - Mutate during dispatch
During the per-entry callback (which runs script or Blink handlers), insert enough new entries into the same weak set to force a rehash of
value_to_index_, invalidating the livemap_iterator_. - Trigger conservative GC
Cause a conservative garbage collection while the stale
map_iterator_is still held by the active iterator (freed-memory access; exact GC timing inferred). - Primitive Use-after-free of the hash map’s old backing store during strongification, yielding memory corruption / premature collection of live weak referents in the renderer heap.
Impact Assessment
TaskSignal priority, lazy image loading). The primitive is a heap use-after-free of a WTF HashMap backing store exercised during conservative GC, plus possible incorrect clearing of still-live weak members — both strong memory-corruption primitives suitable for exploitation, though achieving a controlled RCE (inferred) requires heap grooming and precise GC timing not shown by the diff. Preconditions: ability to add set entries from within a dispatch callback and to induce a conservative GC while an iterator is live.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/frame/fullscreen_controller.cc |
modified | |
ifthird_party/blink/renderer/core/frame/fullscreen_controller.cc |
modified | |
forthird_party/blink/renderer/core/html/media/lazy_load_media_observer.cc |
modified | |
forthird_party/blink/renderer/core/page/page.cc |
modified | |
ifthird_party/blink/renderer/core/page/page.cc |
modified | |
forthird_party/blink/renderer/core/scheduler/dom_task_signal.cc |
modified | |
TEST_Fthird_party/blink/renderer/platform/heap/test/weakness_marking_test.cc |
modified | |
ifthird_party/blink/renderer/platform/heap/test/weakness_marking_test.cc |
modified | |
forthird_party/blink/renderer/platform/heap/test/weakness_marking_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/heap/test/weakness_marking_test.ccthird_party/blink/renderer/platform/wtf/linked_hash_set.hthird_party/blink/renderer/core/frame/fullscreen_controller.ccthird_party/blink/renderer/core/scheduler/dom_task_signal.ccthird_party/blink/renderer/platform/wtf/hash_table.hthird_party/blink/renderer/core/html/media/lazy_load_media_observer.ccthird_party/blink/renderer/core/page/page.cc
Audit Directions
- Other weak heap sets iterated with mutationGrep for
HeapLinkedHashSet<WeakMember</GCedHeapLinkedHashSet<WeakMember<and verify every iteration that can run script or re-enter is over a snapshot (HeapVector) rather than the live set. - Dispatch-during-iteration patternsReview observer/listener/signal dispatch loops across Blink (visibility observers, abort/task signals, fullscreen pending frames) for the “iterate live container while callback mutates it” anti-pattern.
- Weak collection strongificationAudit other Oilpan collections whose iterators cache a secondary iterator for GC strongification (not just
LinkedHashSet) for the same rehash-invalidation hazard.
Patch
From db073cce765ddee969aaf0ef65d27bf841c47091 Mon Sep 17 00:00:00 2001
From: Kent Tamura <tkent@chromium.org>
Date: Thu, 22 Oct 2026 00:04:19 -0700
Subject: [PATCH] Forbid modifying weak LinkedHashSet during iteration
LinkedHashSet::IteratorWrapper holds a HashMap iterator (map_iterator_)
to strongify WeakMember entries in value_to_index_ during conservative
GC. Modifying a HeapLinkedHashSet<WeakMember<T>> or
GCedHeapLinkedHashSet<WeakMember<T>> while an iterator is active can
rehash value_to_index_ and leave map_iterator_ pointing to the old
backing store.
Call CheckModifications() from LinkedHashSet::IteratorWrapper when
IsWeakV<Value> is true, update documentation comments, and snapshot
weak LinkedHashSets before iterating in the following functions:
* DOMTaskSignal::SignalPriorityChange()
* Page::SetVisibilityState()
* LazyLoadMediaObserver::LoadAllImagesAndBlockLoadEvent()
* FullscreenController::NotifyFramesOfFullscreenEntry()
Fixed: 575851252
Change-Id: I3eefbcf737ee04ebfdd9685565235877b9668042
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8618181
Reviewed-by: Keishi Hattori <keishi@chromium.org>
Auto-Submit: Kent Tamura <tkent@chromium.org>
Commit-Queue: Kent Tamura <tkent@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1724484}
---
diff --git a/third_party/blink/renderer/core/frame/fullscreen_controller.cc b/third_party/blink/renderer/core/frame/fullscreen_controller.cc
index d40fce39..c4572fb 100644
--- a/third_party/blink/renderer/core/frame/fullscreen_controller.cc
+++ b/third_party/blink/renderer/core/frame/fullscreen_controller.cc
@@ -366,7 +366,9 @@
void FullscreenController::NotifyFramesOfFullscreenEntry(bool granted) {
// Notify all pending local frames in order whether or not we successfully
// entered fullscreen.
- for (LocalFrame* frame : *pending_frames_) {
+ HeapVector<Member<LocalFrame>> pending_frames(*pending_frames_);
+ pending_frames_->clear();
+ for (LocalFrame* frame : pending_frames) {
if (frame) {
if (Document* document = frame->GetDocument()) {
Fullscreen::DidResolveEnterFullscreenRequest(*document, granted);
@@ -384,7 +386,6 @@
Fullscreen::DidResolveEnterFullscreenRequest(*document, granted);
}
}
- pending_frames_->clear();
}
void FullscreenController::EnterFullscreenCallback(bool granted) {
diff --git a/third_party/blink/renderer/core/html/media/lazy_load_media_observer.cc b/third_party/blink/renderer/core/html/media/lazy_load_media_observer.cc
index c2752e7..62567b9 100644
--- a/third_party/blink/renderer/core/html/media/lazy_load_media_observer.cc
+++ b/third_party/blink/renderer/core/html/media/lazy_load_media_observer.cc
@@ -81,14 +81,13 @@
if (!IsDescendantOrSameDocument(element->GetDocument(), for_document)) {
continue;
}
- if (auto* image_element = DynamicTo<HTMLImageElement>(element)) {
- const_cast<HTMLImageElement*>(image_element)
- ->LoadDeferredImageBlockingLoad();
- resources_have_started_loading = true;
- }
to_be_unobserved.push_back(element);
}
for (Element* element : to_be_unobserved) {
+ if (auto* image_element = DynamicTo<HTMLImageElement>(element)) {
+ image_element->LoadDeferredImageBlockingLoad();
+ resources_have_started_loading = true;
+ }
lazy_load_intersection_observer_->unobserve(element);
}
return resources_have_started_loading;
diff --git a/third_party/blink/renderer/core/page/page.cc b/third_party/blink/renderer/core/page/page.cc
index 2255eec..b85152c 100644
--- a/third_party/blink/renderer/core/page/page.cc
+++ b/third_party/blink/renderer/core/page/page.cc
@@ -821,8 +821,12 @@
if (is_initial_state)
return;
- for (auto& observer : page_visibility_observer_set_) {
- observer->PageVisibilityChanged();
+ HeapVector<Member<PageVisibilityObserver>> observers(
+ page_visibility_observer_set_);
+ for (auto& observer : observers) {
+ if (page_visibility_observer_set_.Contains(observer)) {
+ observer->PageVisibilityChanged();
+ }
}
if (main_frame_) {
diff --git a/third_party/blink/renderer/core/scheduler/dom_task_signal.cc b/third_party/blink/renderer/core/scheduler/dom_task_signal.cc
index 89dad7a..abc3f678 100644
--- a/third_party/blink/renderer/core/scheduler/dom_task_signal.cc
+++ b/third_party/blink/renderer/core/scheduler/dom_task_signal.cc
@@ -116,7 +116,8 @@
const V8TaskPriority::Enum previous_priority = priority_;
priority_ = priority;
- for (AlgorithmHandle* handle : priority_change_algorithms_) {
+ HeapVector<Member<AlgorithmHandle>> algorithms(priority_change_algorithms_);
+ for (AlgorithmHandle* handle : algorithms) {
handle->GetAlgorithm()->Run();
}
@@ -127,12 +128,13 @@
if (auto* source_signal_manager = DynamicTo<SourceSignalCompositionManager>(
*priority_composition_manager_.Get())) {
- // Dependents can be added while dispatching events, but none are removed
- // since having an active iterator will strongify weak references, making
- // the following iteration safe. Signaling priority change on newly added
- // dependent signals has no effect since the new priority is already set.
- for (auto& abort_signal : source_signal_manager->GetDependentSignals()) {
- To<DOMTaskSignal>(abort_signal.Get())
+ // Dependents can be added while dispatching events. Signaling priority
+ // change on newly added dependent signals has no effect since the new
+ // priority is already set, so iterating a snapshot is sufficient.
+ HeapVector<Member<AbortSignal>> dependent_signals(
+ source_signal_manager->GetDependentSignals());
+ for (AbortSignal* abort_signal : dependent_signals) {
+ To<DOMTaskSignal>(abort_signal)
->SignalPriorityChange(priority, exception_state);
}
}
diff --git a/third_party/blink/renderer/platform/wtf/hash_table.h b/third_party/blink/renderer/platform/wtf/hash_table.h
index cf4e8a5..90dbf81 100644
--- a/third_party/blink/renderer/platform/wtf/hash_table.h
+++ b/third_party/blink/renderer/platform/wtf/hash_table.h
@@ -308,21 +308,21 @@
#endif
}
+ public:
+ constexpr HashTableConstIterator() = default;
+
void CheckModifications() const {
#if DCHECK_IS_ON()
// HashTable and collections that build on it do not support
// modifications while there is an iterator in use. The exception is
- // LinkedHashSet, which has its own iterators that tolerate modification
- // of the underlying set.
+ // non-weak LinkedHashSet, which has its own iterators that tolerate
+ // modification of the underlying set.
DCHECK_EQ(container_modifications_, container_->Modifications());
DCHECK(!container_->AccessForbidden());
#endif
}
- public:
- constexpr HashTableConstIterator() = default;
-
GetType Get() const {
CheckModifications();
return position_;
@@ -777,8 +777,8 @@
void RegisterModification() { modifications_++; }
// HashTable and collections that build on it do not support modifications
// while there is an iterator in use. The exception is
- // LinkedHashSet, which has its own iterators that tolerate modification
- // of the underlying set.
+ // non-weak LinkedHashSet, which has its own iterators that tolerate
+ // modification of the underlying set.
void CheckModifications(int64_t mods) const {
DCHECK_EQ(mods, modifications_);
}
diff --git a/third_party/blink/renderer/platform/wtf/linked_hash_set.h b/third_party/blink/renderer/platform/wtf/linked_hash_set.h
index b0f8c9d..7da88c9 100644
--- a/third_party/blink/renderer/platform/wtf/linked_hash_set.h
+++ b/third_party/blink/renderer/platform/wtf/linked_hash_set.h
@@ -49,6 +49,10 @@
// long as you don't remove the current item. Moving items does not invalidate
// iterator, so that it may cause unexpected behavior (i.e. loop unexpectedly
// ends when moving the current item to last).
+// However, this does NOT apply to HeapLinkedHashSet<WeakMember<T>> and
+// GCedHeapLinkedHashSet<WeakMember<T>>: modifying a weak LinkedHashSet while an
+// iterator is active is not allowed because rehashing the underlying map
+// invalidates the internal iterator used to strongify weak entries during GC.
//
// Note: empty/deleted values as defined in HashTraits are not allowed.
template <typename ValueArg,
@@ -99,15 +103,23 @@
IteratorWrapper(const IteratorWrapper&) = default;
IteratorWrapper& operator=(const IteratorWrapper&) = default;
- const Value& operator*() const { return *iterator_; }
- const Value* operator->() const { return &*iterator_; }
+ const Value& operator*() const {
+ CheckModifications();
+ return *iterator_;
+ }
+ const Value* operator->() const {
+ CheckModifications();
+ return &*iterator_;
+ }
IteratorWrapper& operator++() {
+ CheckModifications();
++iterator_;
return *this;
}
IteratorWrapper& operator--() {
+ CheckModifications();
--iterator_;
return *this;
}
@@ -135,6 +147,12 @@
IteratorWrapper(const T& it, const Map& map)
: iterator_(it), map_iterator_(map.begin()) {}
+ void CheckModifications() const {
+ if constexpr (IsWeakV<Value>) {
+ map_iterator_.impl_.CheckModifications();
+ }
+ }
+
// LinkedHashSet::list_ iterator.
T iterator_;
diff --git a/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc b/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
index c05b3f2a..bfbf8c13 100644
--- a/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
+++ b/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include "base/test/gtest_util.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/renderer/platform/heap/collection_support/heap_hash_map.h"
#include "third_party/blink/renderer/platform/heap/collection_support/heap_hash_set.h"
@@ -229,6 +230,45 @@
CHECK_EQ(weak_set->size(), kNumberOfWeakEntries);
}
+TEST_F(WeaknessMarkingTest, StrongLinkedHashSetIteratorAcrossRehashAndGC) {
+ using StrongSet = GCedHeapLinkedHashSet<Member<IntegerObject>>;
+ Persistent<StrongSet> set = MakeGarbageCollected<StrongSet>();
+ Persistent<IntegerObject> first = MakeGarbageCollected<IntegerObject>(1);
+ set->insert(first.Get());
+ set->insert(MakeGarbageCollected<IntegerObject>(2));
+
+ size_t visited = 0;
+ for (auto it = set->begin(); it != set->end();) {
+ EXPECT_NE(it->Get(), nullptr);
+ if (visited == 0) {
+ // Trigger rehash of the underlying LinkedHashSet::value_to_index_ map.
+ for (int j = 10; j < 40; ++j) {
+ set->insert(MakeGarbageCollected<IntegerObject>(j));
+ }
+ }
+ ++it;
+ TestSupportingGC::ConservativelyCollectGarbage();
+ ++visited;
+ }
+ EXPECT_EQ(visited, 32u);
+}
+
+TEST_F(WeaknessMarkingTest, WeakLinkedHashSetModificationDuringIteration) {
+ using WeakSet = GCedHeapLinkedHashSet<WeakMember<IntegerObject>>;
+ Persistent<WeakSet> set = MakeGarbageCollected<WeakSet>();
+ Persistent<IntegerObject> first = MakeGarbageCollected<IntegerObject>(1);
+ Persistent<IntegerObject> second = MakeGarbageCollected<IntegerObject>(2);
+ set->insert(first.Get());
+ set->insert(second.Get());
+
+ auto it = set->begin();
+ EXPECT_EQ(it->Get(), first.Get());
+ set->insert(MakeGarbageCollected<IntegerObject>(3));
+ EXPECT_DCHECK_DEATH(++it);
+ EXPECT_DCHECK_DEATH(*it);
+ EXPECT_DCHECK_DEATH(it->Get());
+}
+
} // namespace weakness_marking_test
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc b/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
index c05b3f2a..bfbf8c13 100644
--- a/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
+++ b/third_party/blink/renderer/platform/heap/test/weakness_marking_test.cc
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include "base/test/gtest_util.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/renderer/platform/heap/collection_support/heap_hash_map.h"
#include "third_party/blink/renderer/platform/heap/collection_support/heap_hash_set.h"
@@ -229,6 +230,45 @@
CHECK_EQ(weak_set->size(), kNumberOfWeakEntries);
}
+TEST_F(WeaknessMarkingTest, StrongLinkedHashSetIteratorAcrossRehashAndGC) {
+ using StrongSet = GCedHeapLinkedHashSet<Member<IntegerObject>>;
+ Persistent<StrongSet> set = MakeGarbageCollected<StrongSet>();
+ Persistent<IntegerObject> first = MakeGarbageCollected<IntegerObject>(1);
+ set->insert(first.Get());
+ set->insert(MakeGarbageCollected<IntegerObject>(2));
+
+ size_t visited = 0;
+ for (auto it = set->begin(); it != set->end();) {
+ EXPECT_NE(it->Get(), nullptr);
+ if (visited == 0) {
+ // Trigger rehash of the underlying LinkedHashSet::value_to_index_ map.
+ for (int j = 10; j < 40; ++j) {
+ set->insert(MakeGarbageCollected<IntegerObject>(j));
+ }
+ }
+ ++it;
+ TestSupportingGC::ConservativelyCollectGarbage();
+ ++visited;
+ }
+ EXPECT_EQ(visited, 32u);
+}
+
+TEST_F(WeaknessMarkingTest, WeakLinkedHashSetModificationDuringIteration) {
+ using WeakSet = GCedHeapLinkedHashSet<WeakMember<IntegerObject>>;
+ Persistent<WeakSet> set = MakeGarbageCollected<WeakSet>();
+ Persistent<IntegerObject> first = MakeGarbageCollected<IntegerObject>(1);
+ Persistent<IntegerObject> second = MakeGarbageCollected<IntegerObject>(2);
+ set->insert(first.Get());
+ set->insert(second.Get());
+
+ auto it = set->begin();
+ EXPECT_EQ(it->Get(), first.Get());
+ set->insert(MakeGarbageCollected<IntegerObject>(3));
+ EXPECT_DCHECK_DEATH(++it);
+ EXPECT_DCHECK_DEATH(*it);
+ EXPECT_DCHECK_DEATH(it->Get());
+}
+
} // namespace weakness_marking_test
} // namespace blink