Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in V8
DescriptionInteger overflow in V8
ComponentV8
Sub-componentCppgc
Bug ClassInteger Overflow
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker511218177
Fix commitc055ccbcde8e (v8/v8) +28/-24
CWECWE-472
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-08 (per vendor advisory)
Fix landed2026-05-13 (public git)
Disclosed2026-06-02
Patch gap20 days — fix public in source before the advisory

Background

cppgc
V8’s C++ garbage collector (Oilpan) for on-heap C++ objects such as Blink DOM nodes; it marks concurrently while the mutator keeps allocating.
`ObjectStartBitmap`
A per-NormalPage bitmap with one bit per allocation granule marking each object’s start, used to map an interior/inner address back to its HeapObjectHeader via FindHeader.
`FindHeader`
Scans the bitmap backwards from the cell containing the queried address to the nearest set bit and computes the object-start offset; object_start_number = cell_index * kBitsPerCell + (kBitsPerCell-1) - CountLeadingZeros(byte).
Weak memory ordering
On architectures like ARM64, stores can be observed out of order across threads unless synchronized with sufficiently strong memory ordering (release vs. seq-cst).

Root Cause Analysis

The bitmap setter stored the object-start bit with std::memory_order_release, while the corresponding cppgc::Member pointer write to the new object used its own ordering. On a weak-memory architecture the concurrent marker could observe the freshly published Member pointer before the object-start bit became visible, then perform a conservative/inner-address lookup through FindHeader. With the bit not yet propagated, the backward scan in FindHeader finds no set bit in the current cell and keeps decrementing cell_index; when it reaches a zero byte, CountLeadingZeros(0) combined with the offset arithmetic underflows and yields an object_start_number corresponding to roughly page_base - 8, i.e. an out-of-page/underflowed header pointer that is then dereferenced.

The fix strengthens the bitmap store to std::memory_order_seq_cst so the mark bit cannot be reordered after the pointer write, guaranteeing the marker that sees the pointer also sees the bit. It additionally hardens FindHeader with CHECK_NE(0, byte) to crash safely rather than return page_base - 8, and as a drive-by adds an explicit bounds check to the conservative inner-address lookup (if (address >= header + header->AllocatedSize()) return nullptr) that the old DCHECK-only implementation lacked in release builds.

Key insight
A release-ordered bitmap write let the concurrent marker see a new Member pointer before its object-start bit, so FindHeader scanned into a zero cell and underflowed to a bogus header near page_base - 8; switching the store to seq_cst (plus a CHECK_NE(0, byte) and a real bounds check) restores the pointer-then-bit visibility ordering.

Attack Path

  1. Race allocation vs. concurrent marker On ARM64, allocate a cppgc object and publish it into a cppgc::Member while the concurrent marker is running, so the marker can observe the pointer before the object-start bit is visible.
  2. Trigger the reordered read The concurrent marker traces the Member and calls into the inner-address lookup, reaching FindHeader for an address whose start bit has not yet propagated.
  3. Underflow in `FindHeader` The backward scan finds no set bit, decrements cell_index into a zero byte, and the offset arithmetic underflows to an object-start of about page_base - 8, returning a header pointer outside the object/page.
  4. Corrupt via bogus header The marker dereferences the out-of-bounds HeapObjectHeader (reading a GC-info index / size from attacker-influenced adjacent memory), yielding memory corruption during GC (inferred; the diff shows the underflow and the defensive CHECK/bounds check, not a concrete exploit primitive).

Impact Assessment

An attacker with renderer script that drives cppgc allocation can, on weak-memory hardware, race the concurrent marker into an integer underflow in FindHeader that produces an out-of-bounds HeapObjectHeader and corrupt GC state or crash the process. Exploitation is timing/architecture dependent (ARM64) and requires concurrent marking to be active; escalation beyond memory corruption is (inferred). The advisory classifies this as an integer overflow in V8 and does not state exploited-in-the-wild.

Files Changed

  • src/heap/cppgc/heap-page.cc
  • src/heap/cppgc/heap-page.h
  • src/heap/cppgc/object-start-bitmap.h

Audit Directions

  • Publish-order of metadata vs. pointers
    Audit any concurrent GC/marker data structure where a side-table bit or descriptor is written with release/relaxed while the referenced pointer is published separately; require seq_cst or a proven happens-before between the two.
  • Backward bitmap scans
    Look for scans that walk cells to a “nearest set bit” and compute an offset; ensure a zero/empty terminal cell is handled (crash or error) rather than producing a below-base address via CountLeadingZeros/index arithmetic.
  • `DCHECK`-only bounds guards
    Search for inner-address/header lookups whose in-bounds invariant is enforced only by DCHECK; these silently return out-of-range results in release builds and should carry runtime checks.
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>
Loading diff…

Original Bug Report

reported by [email protected]

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.h
  • v8/src/heap/cppgc/member-storage.h
  • v8/src/heap/cppgc/concurrent-marker.cc
  • v8/src/heap/cppgc/heap-page.h
  • v8/src/heap/cppgc/trace-trait.cc
  • v8/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:

  1. Grooming the heap to control the last 8 bytes of Page A.
  2. Forging a HeapObjectHeader in those 8 bytes, setting the 14-bit GCInfoIndex (offset +4 on 64-bit) to correspond to a useful Trace callback, and leaving the mark bit (offset +6) as 0.
  3. 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:

  1. Fix the Math: Modify ObjectStartBitmap::FindHeader to safely handle the case where the bitmap is entirely zero. If byte == 0 after the loop, the function should explicitly crash (CHECK(false)) or return nullptr instead of relying on the underflowing arithmetic.
  2. Memory Ordering: Ensure that the write barrier for cppgc::Member assignment enforces sequential consistency or proper acquire-release semantics to prevent the relaxed pointer store from being reordered before the ObjectStartBitmap update.

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.

View on issue tracker