CVE-2026-106291
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/platform/heap/test/heap_test.cc |
modified | |
ThingWithDestructorthird_party/blink/renderer/platform/heap/test/heap_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/wtf/vector_backed_linked_list.hthird_party/blink/renderer/platform/heap/test/heap_test.cc
Patch
From e0b31bdbe9144f084915122cb63eed91a6581203 Mon Sep 17 00:00:00 2001
From: Anton Bikineev <bikineev@google.com>
Date: Wed, 09 Sep 2026 03:46:22 -0700
Subject: [PATCH] [wtf] Allocate VectorBackedLinkedList anchor lazily
VectorBackedLinkedList previously allocated its out-of-line vector
backing during default construction to store the list's anchor node.
Consequently, when HeapLinkedHashSet was used as an inline value type
in HeapHashMap, empty buckets held default-constructed sets with
backing buffers that were not visited during tracing.
This change defers anchor node allocation until the first call to
insert(), ensuring default-constructed instances own no out-of-line
storage, and updates traversal and cleanup methods to handle the
unallocated state. A unit test covering this scenario is added.
Regression test: https://crbug.com/497112470
TAG=agy
Bug: 499571442
Change-Id: I2482e6ab2724235f2c5475eaefee03b6c11b5b88
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8372183
Commit-Queue: Anton Bikineev <bikineev@chromium.org>
Reviewed-by: Michael Lippautz <mlippautz@chromium.org>
Auto-Submit: Anton Bikineev <bikineev@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694478}
---
diff --git a/third_party/blink/renderer/platform/wtf/vector_backed_linked_list.h b/third_party/blink/renderer/platform/wtf/vector_backed_linked_list.h
index 8303f0bb..ac98671 100644
--- a/third_party/blink/renderer/platform/wtf/vector_backed_linked_list.h
+++ b/third_party/blink/renderer/platform/wtf/vector_backed_linked_list.h
@@ -199,7 +199,9 @@
using const_reverse_iterator =
VectorBackedLinkedListConstReverseIterator<VectorBackedLinkedList>;
- VectorBackedLinkedList();
+ // The anchor node is allocated lazily on the first call to insert() so that
+ // a default-constructed list does not own an out-of-line buffer.
+ VectorBackedLinkedList() = default;
~VectorBackedLinkedList() = default;
void swap(VectorBackedLinkedList&);
@@ -264,6 +266,9 @@
// Removes all elements in a linked list.
void clear() {
+ if (nodes_.empty()) {
+ return;
+ }
// Keep anchor so that we can insert elements after this operation.
nodes_.ShrinkCapacity(1);
nodes_[kAnchorIndex].prev_index_ = kAnchorIndex;
@@ -291,8 +296,12 @@
bool IsFreeListEmpty() const { return free_head_index_ == kAnchorIndex; }
- wtf_size_t UsedFirstIndex() const { return nodes_[kAnchorIndex].next_index_; }
- wtf_size_t UsedLastIndex() const { return nodes_[kAnchorIndex].prev_index_; }
+ wtf_size_t UsedFirstIndex() const {
+ return nodes_.empty() ? kAnchorIndex : nodes_[kAnchorIndex].next_index_;
+ }
+ wtf_size_t UsedLastIndex() const {
+ return nodes_.empty() ? kAnchorIndex : nodes_[kAnchorIndex].prev_index_;
+ }
const_iterator MakeConstIterator(wtf_size_t index) const {
return const_iterator(index, this);
@@ -644,13 +653,6 @@
};
template <typename T, typename Allocator>
-VectorBackedLinkedList<T, Allocator>::VectorBackedLinkedList() {
- // First inserts anchor, which serves as the beginning and the end of
- // the used list.
- nodes_.push_back(Node(kAnchorIndex, kAnchorIndex));
-}
-
-template <typename T, typename Allocator>
inline void VectorBackedLinkedList<T, Allocator>::swap(
VectorBackedLinkedList& other) {
nodes_.swap(other.nodes_);
@@ -687,6 +689,11 @@
typename VectorBackedLinkedList<T, Allocator>::iterator
VectorBackedLinkedList<T, Allocator>::insert(const_iterator position,
IncomingValueType&& value) {
+ if (nodes_.empty()) {
+ // Lazily insert the anchor, which serves as the beginning and the end of
+ // the used list.
+ nodes_.push_back(Node(kAnchorIndex, kAnchorIndex));
+ }
wtf_size_t position_index = position.GetIndex();
wtf_size_t prev_index = nodes_[position_index].prev_index_;
diff --git a/third_party/blink/renderer/platform/heap/test/heap_test.cc b/third_party/blink/renderer/platform/heap/test/heap_test.cc
index c205e1e..76a9b01 100644
--- a/third_party/blink/renderer/platform/heap/test/heap_test.cc
+++ b/third_party/blink/renderer/platform/heap/test/heap_test.cc
@@ -1631,6 +1631,31 @@
ClearInWeakProcessingHelper<HeapLinkedHashSet<WeakMember<IntWrapper>>>();
}
+TEST_F(HeapTest, HeapHashMapWithHeapLinkedHashSetValue) {
+ // HeapLinkedHashSet may be used as the inline value type of a HeapHashMap.
+ // Empty buckets in the map's backing are not visited during tracing, so a
+ // default-constructed value must not own any out-of-line allocations.
+ ClearOutOldGarbage();
+
+ using InnerSet = HeapLinkedHashSet<Member<IntWrapper>>;
+ using Map = GCedHeapHashMap<Member<IntWrapper>, InnerSet>;
+ Persistent<Map> map = MakeGarbageCollected<Map>();
+ Persistent<IntWrapper> key1 = MakeGarbageCollected<IntWrapper>(1);
+ Persistent<IntWrapper> key2 = MakeGarbageCollected<IntWrapper>(2);
+
+ map->ReserveCapacityForSize(8);
+ PreciselyCollectGarbage();
+
+ map->insert(key1.Get(), InnerSet()).stored_value->value.insert(key1.Get());
+ PreciselyCollectGarbage();
+ map->insert(key2.Get(), InnerSet()).stored_value->value.insert(key2.Get());
+ PreciselyCollectGarbage();
+
+ EXPECT_EQ(2u, map->size());
+ EXPECT_TRUE(map->find(key1.Get())->value.Contains(key1.Get()));
+ EXPECT_TRUE(map->find(key2.Get())->value.Contains(key2.Get()));
+}
+
namespace {
class ThingWithDestructor {
DISALLOW_NEW();
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/heap/test/heap_test.cc b/third_party/blink/renderer/platform/heap/test/heap_test.cc
index c205e1e..76a9b01 100644
--- a/third_party/blink/renderer/platform/heap/test/heap_test.cc
+++ b/third_party/blink/renderer/platform/heap/test/heap_test.cc
@@ -1631,6 +1631,31 @@
ClearInWeakProcessingHelper<HeapLinkedHashSet<WeakMember<IntWrapper>>>();
}
+TEST_F(HeapTest, HeapHashMapWithHeapLinkedHashSetValue) {
+ // HeapLinkedHashSet may be used as the inline value type of a HeapHashMap.
+ // Empty buckets in the map's backing are not visited during tracing, so a
+ // default-constructed value must not own any out-of-line allocations.
+ ClearOutOldGarbage();
+
+ using InnerSet = HeapLinkedHashSet<Member<IntWrapper>>;
+ using Map = GCedHeapHashMap<Member<IntWrapper>, InnerSet>;
+ Persistent<Map> map = MakeGarbageCollected<Map>();
+ Persistent<IntWrapper> key1 = MakeGarbageCollected<IntWrapper>(1);
+ Persistent<IntWrapper> key2 = MakeGarbageCollected<IntWrapper>(2);
+
+ map->ReserveCapacityForSize(8);
+ PreciselyCollectGarbage();
+
+ map->insert(key1.Get(), InnerSet()).stored_value->value.insert(key1.Get());
+ PreciselyCollectGarbage();
+ map->insert(key2.Get(), InnerSet()).stored_value->value.insert(key2.Get());
+ PreciselyCollectGarbage();
+
+ EXPECT_EQ(2u, map->size());
+ EXPECT_TRUE(map->find(key1.Get())->value.Contains(key1.Get()));
+ EXPECT_TRUE(map->find(key2.Get())->value.Contains(key2.Get()));
+}
+
namespace {
class ThingWithDestructor {
DISALLOW_NEW();
Original Bug Report
Double Free in LocalFrameView via HeapLinkedHashSet in HeapHashMap
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 without the security team.
Overview: A potential double-free vulnerability exists in Blink’s memory management when a HeapHashMap uses HeapLinkedHashSet as its value type. Empty buckets allocate a GC-managed backing store that gets swept during GC, and subsequent inserts into these buckets trigger a move-assignment that explicitly frees the already-swept backing.
Affected files:
third_party/blink/renderer/platform/heap/collection_support/heap_hash_table_backing.hthird_party/blink/renderer/core/frame/local_frame_view.hthird_party/blink/renderer/core/frame/local_frame_view.cc
Estimated timestamp from git blame: 2026-02-26
Final Assessment
A confirmed double-free vulnerability exists in Blink’s Oilpan garbage collection, providing a path to renderer Remote Code Execution (RCE). The issue is triggered by a collision between HeapHashMap’s empty bucket optimization and HeapLinkedHashSet’s default constructor allocating an out-of-line heap buffer.
Technical Breakdown
Initial logic and memory allocation parameters are validated: LocalFrameView::canvas_elements_needing_onpaint_ expands and populates empty buckets with default-constructed HeapLinkedHashSet objects. These objects immediately allocate a GC-managed vector backing for their list anchor. Because Oilpan’s TraceHashTableBackingInCollectionTrait::Trace intentionally skips empty buckets for performance, these initial allocations are systematically ignored during GC marking and are swept.
When an attacker inserts a new element that lands in an empty bucket, the system leaps directly into a destructive state transformation. The HashMapTranslator::Store operation executes a move-assignment (location.value = std::move(mapped)), forcing the old, already-freed Vector to clean up its state. The Vector::operator=(Vector&&) implementation calls clear(), skipping intermediate teardown logic and jumping straight to cppgc::subtle::FreeUnreferencedObject(). This issues an explicit free on memory that GC has already reclaimed, corrupting the Oilpan FreeList.
Potential Trigger Steps
(Note: These are suggested/potential steps; our tooling agent does not currently run live code to provide a working PoC.)
- Enable the
CanvasDrawElementexperimental feature flag. - Call
requestPaint()on anHTMLCanvasElementto initialize and expand thecanvas_elements_needing_onpaint_map, populating empty buckets. - Induce a Garbage Collection cycle to sweep the untraced vector backings.
- Call
requestPaint()on a second canvas element. The map insertion hits an empty bucket, move-assigns theHeapLinkedHashSet, and triggers the double-free. - Allocate attacker-controlled objects of the same size to consume the corrupted free list entries, leading to overlapping objects and type confusion.
Suggested Fix
Refactor BasicHeapLinkedHashSet and its underlying VectorBackedLinkedList so the default constructor does not immediately allocate an out-of-line buffer. Allocation of the anchor node should be deferred until the first actual element is inserted, preventing empty hash table buckets from holding live GC allocations.
Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.