CVE-2026-10963
Overview
Files Changed
src/heap/cppgc/heap-page.ccsrc/heap/cppgc/heap-page.hsrc/heap/cppgc/object-start-bitmap.h
Patch
From c055ccbcde8ea8c6f5356fd9f5fb3d6a6b73a9ee Mon Sep 17 00:00:00 2001 From: Anton Bikineev <[email protected]> Date: Tue, 12 May 2026 14:02:17 +0200 Subject: [PATCH] [cppgc] Fix memory ordering race in ObjectStartBitmap A memory ordering race on weak memory architectures (like ARM64) between mutator allocations and the concurrent marker could lead to an integer underflow in ObjectStartBitmap::FindHeader. This happened because the concurrent marker could observe a new pointer in a cppgc::Member before the corresponding bit was set in the ObjectStartBitmap. The CL fixes it by using a seq-cst OSB write. As a drive-by, it fixes the issue with conservative object lookup. Bug: 511218177 Change-Id: I89e8fa26966daf755380719277be0a1656268940 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7840600 Auto-Submit: Anton Bikineev <[email protected]> Commit-Queue: Michael Lippautz <[email protected]> Reviewed-by: Michael Lippautz <[email protected]> Cr-Commit-Position: refs/heads/main@{#107284} --- diff --git a/src/heap/cppgc/heap-page.cc b/src/heap/cppgc/heap-page.cc index 7202b72..5b1b806 100644 --- a/src/heap/cppgc/heap-page.cc +++ b/src/heap/cppgc/heap-page.cc @@ -108,6 +108,7 @@ static_cast<ConstAddress>(address))) { return nullptr; } + return LargePage::From(this)->ObjectHeader(); } else { const NormalPage* normal_page = NormalPage::From(this); if (!normal_page->PayloadContains(static_cast<ConstAddress>(address))) { @@ -117,14 +118,20 @@ DCHECK(!NormalPageSpace::From(normal_page->space()) .linear_allocation_buffer() .size()); - } - // |address| is on the heap, so we FromInnerAddress can get the header. - const HeapObjectHeader* header = - ObjectHeaderFromInnerAddressImpl(this, address); - if (header->IsFree()) return nullptr; - DCHECK_NE(kFreeListGCInfoIndex, header->GetGCInfoIndex()); - return header; + const PlatformAwareObjectStartBitmap& bitmap = + NormalPage::From(this)->object_start_bitmap(); + const HeapObjectHeader* header = bitmap.FindHeader<AccessMode::kNonAtomic>( + static_cast<ConstAddress>(address)); + if (address >= reinterpret_cast<ConstAddress>(header) + + header->AllocatedSize<AccessMode::kAtomic>()) { + // Found an object below the current address. + return nullptr; + } + if (header->IsFree()) return nullptr; + DCHECK_NE(kFreeListGCInfoIndex, header->GetGCInfoIndex()); + return header; + } } #if defined(CPPGC_YOUNG_GENERATION) diff --git a/src/heap/cppgc/heap-page.h b/src/heap/cppgc/heap-page.h index 313f2f5..cf3afb2 100644 --- a/src/heap/cppgc/heap-page.h +++ b/src/heap/cppgc/heap-page.h @@ -321,21 +321,6 @@ return static_cast<const BasePage*>(BasePageHandle::FromPayload(payload)); } -template <AccessMode mode = AccessMode::kNonAtomic> -const HeapObjectHeader* ObjectHeaderFromInnerAddressImpl(const BasePage* page, - const void* address) { - if (page->is_large()) { - return LargePage::From(page)->ObjectHeader(); - } - const PlatformAwareObjectStartBitmap& bitmap = - NormalPage::From(page)->object_start_bitmap(); - const HeapObjectHeader* header = - bitmap.FindHeader<mode>(static_cast<ConstAddress>(address)); - DCHECK_LT(address, reinterpret_cast<ConstAddress>(header) + - header->AllocatedSize<AccessMode::kAtomic>()); - return header; -} - template <AccessMode mode> HeapObjectHeader& BasePage::ObjectHeaderFromInnerAddress(void* address) const { return const_cast<HeapObjectHeader&>( @@ -352,8 +337,15 @@ // the page |type_| field). This can occur when tracing a Member holding a // reference to a mixin type SynchronizedLoad(); + if (is_large()) { + return *LargePage::From(this)->ObjectHeader(); + } + const PlatformAwareObjectStartBitmap& bitmap = + NormalPage::From(this)->object_start_bitmap(); const HeapObjectHeader* header = - ObjectHeaderFromInnerAddressImpl<mode>(this, address); + bitmap.FindHeader<mode>(static_cast<ConstAddress>(address)); + DCHECK_LT(address, reinterpret_cast<ConstAddress>(header) + + header->AllocatedSize<AccessMode::kAtomic>()); DCHECK_NE(kFreeListGCInfoIndex, header->GetGCInfoIndex<mode>()); return *header; } diff --git a/src/heap/cppgc/object-start-bitmap.h b/src/heap/cppgc/object-start-bitmap.h index 112ed65..15b9de4 100644 --- a/src/heap/cppgc/object-start-bitmap.h +++ b/src/heap/cppgc/object-start-bitmap.h @@ -129,6 +129,8 @@ DCHECK_LT(0u, cell_index); byte = load<mode>(--cell_index); } + // Crash safely instead of returning page_base - 8. + CHECK_NE(0, byte); const int leading_zeroes = v8::base::bits::CountLeadingZeros(byte); object_start_number = (cell_index * kBitsPerCell) + (kBitsPerCell - 1) - leading_zeroes; @@ -166,8 +168,11 @@ object_start_bit_map_[cell_index] = value; return; } + // Use seq cst here to avoid a situation when a pointer write may be reordered + // before the setting of the mark bit, which may lead to the concurrent marker + // missing the right object (because the bit is not yet propagated). std::atomic_ref<uint8_t>(object_start_bit_map_[cell_index]) - .store(value, std::memory_order_release); + .store(value, std::memory_order_seq_cst); } template <AccessMode mode>
Original Bug Report
cppgc: Memory ordering race in ObjectStartBitmap allows RCE via underflow
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A memory ordering race on weak memory architectures (like ARM64) between mutator allocations and the concurrent marker leads to an integer underflow in ObjectStartBitmap::FindHeader. This returns an out-of-bounds pointer into an adjacent heap page, causing the marker to trace forged headers and internal page structures, leading to arbitrary code execution.
Affected files:
v8/src/heap/cppgc/object-start-bitmap.hv8/src/heap/cppgc/member-storage.hv8/src/heap/cppgc/concurrent-marker.ccv8/src/heap/cppgc/heap-page.hv8/src/heap/cppgc/trace-trait.ccv8/src/heap/cppgc/marking-state.h
Estimated timestamp from git blame: 2023-02-27
Summary
A vulnerability exists in V8’s cppgc (Oilpan) garbage collector. On weak memory architectures like ARM64, a memory ordering race can occur when assigning a newly allocated object to a cppgc::Member. The concurrent marker can observe the new pointer before the corresponding bit is set in the ObjectStartBitmap. This stale state triggers an integer underflow in ObjectStartBitmap::FindHeader, resulting in an out-of-bounds pointer. Because cppgc pages lack guard pages, this pointer resolves to the payload of the adjacent preceding page, allowing an attacker to forge a HeapObjectHeader, force type confusion on internal heap metadata, and achieve arbitrary code execution in the renderer process.
Technical Details
1. The Memory Ordering Race
When a mutator thread allocates a new object on a fresh page, it registers the object’s start address in the page’s bitmap. This is done via ObjectStartBitmap::SetBit<AccessMode::kAtomic>, which performs a std::memory_order_release store.
Subsequently, if this new pointer is assigned to a cppgc::Member of an object currently being traced, the pointer is written using std::memory_order_relaxed (v8/include/cppgc/internal/member-storage.h:102). The assignment triggers a write barrier, but the fast path only performs relaxed checks and does not emit a strong memory fence (like memory_order_seq_cst).
On ARM64, a Store-Release prevents prior operations from being reordered after it, but it does not prevent subsequent operations from being reordered before it. Consequently, the relaxed store of the Member pointer can become globally visible before the release store to the bitmap.
2. The Integer Underflow
The concurrent marker background thread, reading the Member via an acquire/relaxed load, observes the new pointer but not the updated bitmap. To trace the object (specifically a mixin), it calls ObjectStartBitmap::FindHeader to locate the HeapObjectHeader.
FindHeader scans the bitmap backwards to find the nearest set bit. Because the page is fresh and the mutator’s bit isn’t visible, every cell is 0. The loop (while (!byte && cell_index) { byte = load<mode>(--cell_index); }) decrements cell_index until it reaches 0.
The function then executes:
const int leading_zeroes = v8::base::bits::CountLeadingZeros(byte);
object_start_number = (cell_index * kBitsPerCell) + (kBitsPerCell - 1) - leading_zeroes;
Passing 0 to CountLeadingZeros returns 8 (v8/src/base/bits.h). Substituting the values yields (0 * 8) + 7 - 8 = -1. Because object_start_number is a size_t, -1 underflows to SIZE_MAX.
The code then calculates object_offset = object_start_number * kAllocationGranularity (where granularity is 8). Due to two’s complement arithmetic, SIZE_MAX * 8 results in -8. FindHeader returns page_base - 8.
3. Heap Adjacency and Exploitation
In cppgc, NormalPage allocations are 128KB contiguous blocks within the Caged Heap, with no implicit guard pages between them (v8/src/heap/cppgc/page-memory.cc). The payload of a NormalPage can extend precisely to the 128KB boundary. Therefore, page_base - 8 points to the last 8 bytes of the payload of the immediately preceding page (Page A).
An attacker can reliably trigger this condition by:
- Grooming the heap to control the last 8 bytes of
Page A. - Forging a
HeapObjectHeaderin those 8 bytes, setting the 14-bitGCInfoIndex(offset+4on 64-bit) to correspond to a usefulTracecallback, and leaving the mark bit (offset+6) as0. - Allocating a new mixin object on
Page B(triggering the race).
When FindHeader returns page_base - 8, the marker casts it to a HeapObjectHeader and reads the attacker’s forged GCInfoIndex. It then performs an atomic CAS to flip the mark bit at page_base - 2, which succeeds.
Finally, the marker calculates the payload pointer by adding sizeof(HeapObjectHeader) (8 bytes) to the returned pointer: (page_base - 8) + 8 = page_base. It then invokes the attacker’s chosen Trace function on this pointer.
Because page_base is exactly offset 0 of Page B, it points to the NormalPage struct itself. The Trace function interprets the internal V8 heap metadata (such as Space*, SlotSet*, and the massive ObjectStartBitmap array) as user-defined pointers. This massive type confusion allows the attacker to corrupt Oilpan’s internal state, achieve arbitrary out-of-bounds writes during the sweep phase, and ultimately execute arbitrary code within the renderer process.
Suggested Fix
There are two primary ways to fix this vulnerability:
- Fix the Math: Modify
ObjectStartBitmap::FindHeaderto safely handle the case where the bitmap is entirely zero. Ifbyte == 0after the loop, the function should explicitly crash (CHECK(false)) or returnnullptrinstead of relying on the underflowing arithmetic. - Memory Ordering: Ensure that the write barrier for
cppgc::Memberassignment enforces sequential consistency or proper acquire-release semantics to prevent the relaxed pointer store from being reordered before theObjectStartBitmapupdate.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.