Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in GarbageCollection
DescriptionUse after free in GarbageCollection
ComponentGarbageCollection
Sub-componentWtf
Bug ClassUAF
Tracker499571442
Fix commite0b31bdbe914 (chromium/src) +42/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/platform/heap/test/heap_test.cc
modified
ThingWithDestructor
third_party/blink/renderer/platform/heap/test/heap_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/wtf/vector_backed_linked_list.h
  • third_party/blink/renderer/platform/heap/test/heap_test.cc
From e0b31bdbe9144f084915122cb63eed91a6581203 Mon Sep 17 00:00:00 2001
From: Anton Bikineev <[email protected]>
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 <[email protected]>
Reviewed-by: Michael Lippautz <[email protected]>
Auto-Submit: Anton Bikineev <[email protected]>
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();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
Loading diff…

Original Bug Report

reported by [email protected]

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.h
  • third_party/blink/renderer/core/frame/local_frame_view.h
  • third_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.)

  1. Enable the CanvasDrawElement experimental feature flag.
  2. Call requestPaint() on an HTMLCanvasElement to initialize and expand the canvas_elements_needing_onpaint_ map, populating empty buckets.
  3. Induce a Garbage Collection cycle to sweep the untraced vector backings.
  4. Call requestPaint() on a second canvas element. The map insertion hits an empty bucket, move-assigns the HeapLinkedHashSet, and triggers the double-free.
  5. 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.

View on issue tracker