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 <[email protected]> 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 <[email protected]> Auto-Submit: Kent Tamura <[email protected]> Commit-Queue: Kent Tamura <[email protected]> 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