CVE-2026-14432
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
whilesrc/sandbox/cppheap-pointer-table-inl.h |
modified | |
whilesrc/sandbox/external-pointer-table-inl.h |
modified |
Files Changed
src/heap/heap-write-barrier.ccsrc/sandbox/cppheap-pointer-table-inl.hsrc/sandbox/cppheap-pointer-table.hsrc/sandbox/external-pointer-table-inl.h
Patch
From 493ea0b937d9a6c479d5358a5dced227bdf0fd5c Mon Sep 17 00:00:00 2001 From: Michael Lippautz <[email protected]> Date: Mon, 15 Jun 2026 20:03:25 +0000 Subject: [PATCH] Reland "sandbox: Fix duplicate creation of evacuation entries" This is a reland of commit 0f9a757a7da9e4701d6b6402d888a8f0d195ac33 Original change's description: > sandbox: Fix duplicate creation of evacuation entries > > When marking pointers in CppHeapPointerTable and ExternalPointerTable, > a race with a concurrent mutator write could cause the mark bit to be > cleared and then set again, generating duplicate evacuation entries > for the same handle location. Sweeping then crashed when processing > the second entry because the handle had already been updated to the > new (low) index, failing the old_index >= start_of_evacuation_area > assertion. > > This also removes black allocation (automatically always setting the > mark bit) for the EPT. > > TAG=agy > > Bug: 508200984 > Change-Id: I21060784886b4c5a18ac75862b99cca9b1d62981 > Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7915143 > Reviewed-by: Samuel Groß <[email protected]> > Commit-Queue: Michael Lippautz <[email protected]> > Reviewed-by: Arash Kazemi <[email protected]> > Cr-Commit-Position: refs/heads/main@{#107961} Bug: 508200984, 524290062 Change-Id: I8668c4eeb9bc8296fe75687c6d08adf47b5ca457 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7943563 Auto-Submit: Michael Lippautz <[email protected]> Commit-Queue: Arash Kazemi <[email protected]> Reviewed-by: Samuel Groß <[email protected]> Reviewed-by: Arash Kazemi <[email protected]> Cr-Commit-Position: refs/heads/main@{#108012} --- diff --git a/src/heap/heap-write-barrier.cc b/src/heap/heap-write-barrier.cc index 9790f7a..03cfc9c 100644 --- a/src/heap/heap-write-barrier.cc +++ b/src/heap/heap-write-barrier.cc @@ -160,6 +160,13 @@ ExternalPointerHandle handle = slot.Relaxed_LoadHandle(); table.Mark(space, handle, slot.address()); + + if (marking_barrier->is_minor() && HeapLayout::InYoungGeneration(host)) { + MutablePage* host_page = + MutablePage::FromHeapObject(marking_barrier->heap()->isolate(), host); + RememberedSet<SURVIVOR_TO_EXTERNAL_POINTER>::Insert<AccessMode::ATOMIC>( + host_page, host_page->Offset(slot.address())); + } #endif // V8_COMPRESS_POINTERS } diff --git a/src/sandbox/cppheap-pointer-table-inl.h b/src/sandbox/cppheap-pointer-table-inl.h index ffc153e..b477196 100644 --- a/src/sandbox/cppheap-pointer-table-inl.h +++ b/src/sandbox/cppheap-pointer-table-inl.h @@ -44,10 +44,18 @@ DCHECK_EQ(0, value >> (kBitsPerSystemPointer - kCppHeapPointerPayloadShift)); DCHECK_NE(tag, CppHeapPointerTag::kFreeEntryTag); DCHECK_NE(tag, CppHeapPointerTag::kEvacuationEntryTag); - DCHECK(payload_.load(std::memory_order_relaxed).ContainsPointer()); - - Payload new_payload(value, tag); - payload_.store(new_payload, std::memory_order_relaxed); + auto old_payload = payload_.load(std::memory_order_relaxed); + while (true) { + DCHECK(old_payload.ContainsPointer()); + Payload new_payload(value, tag); + if (old_payload.HasMarkBitSet()) { + new_payload.SetMarkBit(); + } + if (payload_.compare_exchange_weak(old_payload, new_payload, + std::memory_order_relaxed)) { + break; + } + } } bool CppHeapPointerTableEntry::HasPointer( @@ -73,18 +81,20 @@ return payload.ExtractFreelistLink(); } -void CppHeapPointerTableEntry::Mark() { +bool CppHeapPointerTableEntry::Mark() { auto old_payload = payload_.load(std::memory_order_relaxed); - DCHECK(old_payload.ContainsPointer()); - - auto new_payload = old_payload; - new_payload.SetMarkBit(); - - // We don't need to perform the CAS in a loop: if the new value is not equal - // to the old value, then the mutator must've just written a new value into - // the entry. The mutator will also set the markbit through the write barrier. - payload_.compare_exchange_strong(old_payload, new_payload, - std::memory_order_relaxed); + while (true) { + DCHECK(old_payload.ContainsPointer()); + if (old_payload.HasMarkBitSet()) { + return false; + } + auto new_payload = old_payload; + new_payload.SetMarkBit(); + if (payload_.compare_exchange_weak(old_payload, new_payload, + std::memory_order_relaxed)) { + return true; + } + } } void CppHeapPointerTableEntry::MakeEvacuationEntry(Address handle_location) { @@ -160,13 +170,16 @@ uint32_t index = HandleToIndex(handle); DCHECK(space->Contains(index)); - // If the table is being compacted and the entry is inside the evacuation - // area, then allocate and set up an evacuation entry for it. - MaybeCreateEvacuationEntry(space, index, handle_location); + // Bail out in case the entry was already marked. + if (!at(index).Mark()) { + return; + } - // Even if the entry is marked for evacuation, it still needs to be marked as - // alive as it may be visited during sweeping before being evacuation. - at(index).Mark(); + // If the table is being compacted and the entry is inside the evacuation + // area, then allocate and set up an evacuation entry for it. Only one such + // entry must exist for any given `handle_location` which is ensured by + // properly bailing out on marked entries above. + MaybeCreateEvacuationEntry(space, index, handle_location); } // static diff --git a/src/sandbox/cppheap-pointer-table.h b/src/sandbox/cppheap-pointer-table.h index 0ef2e7a..34ee242 100644 --- a/src/sandbox/cppheap-pointer-table.h +++ b/src/sandbox/cppheap-pointer-table.h @@ -81,8 +81,9 @@ // Invalidates the source entry. inline void Evacuate(CppHeapPointerTableEntry& dest); - // Mark this entry as alive during table garbage collection. - inline void Mark(); + // Mark this entry as alive during table garbage collection. Returns true if + // the entry transitioned from un-marked to marked, and false otherwise. + inline bool Mark(); static constexpr bool IsWriteProtected = false; diff --git a/src/sandbox/external-pointer-table-inl.h b/src/sandbox/external-pointer-table-inl.h index d0fce98..6f12a37 100644 --- a/src/sandbox/external-pointer-table-inl.h +++ b/src/sandbox/external-pointer-table-inl.h @@ -45,11 +45,17 @@ DCHECK_EQ(0, value & kExternalPointerTagAndMarkbitMask); DCHECK(payload_.load(std::memory_order_relaxed).ContainsPointer()); - Payload new_payload(value, tag); - // Writing an entry currently also marks it as alive. In the future, we might - // want to drop this and instead use write barriers where necessary. - new_payload.SetMarkBit(); - payload_.store(new_payload, std::memory_order_relaxed); + auto old_payload = payload_.load(std::memory_order_relaxed); + while (true) { + Payload new_payload(value, tag); + if (old_payload.HasMarkBitSet()) { + new_payload.SetMarkBit(); + } + if (payload_.compare_exchange_weak(old_payload, new_payload, + std::memory_order_relaxed)) { + break; + } + } MaybeUpdateRawPointerForLSan(value); } @@ -65,15 +71,19 @@ // The 2nd most significant byte must be empty as we store the tag in int. DCHECK_EQ(0, value & kExternalPointerTagAndMarkbitMask); - Payload new_payload(value, tag); - // Writing an entry currently also marks it as alive. In the future, we might - // want to drop this and instead use write barriers where necessary. - new_payload.SetMarkBit(); - Payload old_payload = - payload_.exchange(new_payload, std::memory_order_relaxed); - DCHECK(old_payload.ContainsPointer()); - MaybeUpdateRawPointerForLSan(value); - return old_payload.Untag(tag); + auto old_payload = payload_.load(std::memory_order_relaxed); + while (true) { + DCHECK(old_payload.ContainsPointer());
Original Bug Report
Race condition in TrustedPointerTableEntry allows Use-After-Free via dropped mark bits
Flapjack has identified a security issue and generated a PoC.
Build variant: Default
Build arguments: v8_enable_sandbox=true v8_enable_memory_corruption_api=true
Shell command: d8 --stress-incremental-marking --concurrent-marking-high-priority-threads --omit-quit --fuzzing --disallow-unsafe-flags --disable-in-process-stack-traces
Return code: 134
<details>
<summary>stdout</summary>
iter 10
iter 20
iter 30
iter 40
iter 50
</details>
<details>
<summary>stderr</summary>
#
# Fatal error in ../../v8/src/sandbox/trusted-pointer-table-inl.h, line 113
# Debug check failed: success || old_payload.HasMarkBitSet().
#
#
#
#FailureMessage Object: 0x714986ffc490
==== C stack trace ===============================
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7149d512dfce]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libplatform.so(+0x1644d) [0x7149d50b644d]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x194) [0x7149d51100e4]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libbase.so(+0x50995) [0x7149d510f995]
/home/rjlothian_google_com/chromium/src/out/Default/libv8.so(+0x25ea33d) [0x7149d772333d]
/home/rjlothian_google_com/chromium/src/out/Default/libv8.so(+0x25ea231) [0x7149d7723231]
/home/rjlothian_google_com/chromium/src/out/Default/libv8.so(v8::internal::ConcurrentMarking::RunMajor(v8::JobDelegate*, v8::base::EnumSet<v8::internal::CodeFlushMode, int>, unsigned int, bool)+0x6c5) [0x7149d76ee135]
/home/rjlothian_google_com/chromium/src/out/Default/libv8.so(+0x25c6722) [0x7149d76ff722]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libplatform.so(+0x146a3) [0x7149d50b46a3]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libplatform.so(v8::platform::DefaultWorkerThreadsTaskRunner::WorkerThread::Run()+0xc3) [0x7149d50b7a43]
/home/rjlothian_google_com/chromium/src/out/Default/libv8_libbase.so(+0x6d671) [0x7149d512c671]
/lib/x86_64-linux-gnu/libc.so.6(+0x9caa4) [0x7149d3deeaa4]
/lib/x86_64-linux-gnu/libc.so.6(__clone+0x44) [0x7149d3e7ba64]
</details>
Overview: A data race exists between the garbage collector’s Mark() method and the mutator’s Publish() or Unpublish() operations on a TrustedPointerTableEntry. This race can cause a live entry’s mark bit to be dropped, leading to premature freeing during the sweep phase. Exploiting this results in a Use-After-Free and Object Confusion within V8’s Trusted Space.
Affected files:
v8/src/sandbox/trusted-pointer-table-inl.h
Estimated timestamp from git blame: Unknown (Google3 checkout)
Root Cause
A race condition in v8/src/sandbox/trusted-pointer-table-inl.h allows a concurrent mutator to accidentally clear or prevent the setting of the garbage collector’s mark bit on a TrustedPointerTableEntry.
The Mark() function attempts to set the mark bit using a non-looping compare_exchange_strong. It incorrectly assumes that any CAS failure implies another thread has already set the mark bit:
void TrustedPointerTableEntry::Mark() {
auto old_payload = payload_.load(std::memory_order_relaxed);
auto new_payload = old_payload;
new_payload.SetMarkBit();
bool success = payload_.compare_exchange_strong(old_payload, new_payload,
std::memory_order_relaxed);
DCHECK(success || old_payload.HasMarkBitSet());
USE(success);
}
Concurrently, the Publish() and Unpublish() methods modify the entry’s tag using a simple store operation:
void TrustedPointerTableEntry::Unpublish() {
auto old_payload = payload_.load(std::memory_order_relaxed);
auto new_payload = old_payload;
new_payload.SetTag(kUnpublishedIndirectPointerTag);
payload_.store(new_payload, std::memory_order_release);
}
If Publish() or Unpublish() execute concurrently with Mark(), two race scenarios lead to an unmarked entry:
- CAS Failure: If the tag is modified between
Mark()’sloadandcompare_exchange_strong, the CAS fails. SinceMark()does not loop, the entry remains unmarked. - Bit Overwrite: If
Mark()succeeds first, the concurrentstoreinPublish()/Unpublish()will blindly overwrite the payload using the olderold_payloadvalue (which does not have the mark bit set), effectively erasing the mark bit.
During the subsequent sweep phase, the GC considers the entry unmarked and moves it to the freelist. If the old handle remains accessible (e.g., via stale pointers on the stack or in the sandbox), it can be used to access whatever new trusted object re-allocates that freelist entry, resulting in Use-After-Free and Object Confusion within V8’s Trusted Space.
Suggested Fix
Update operations that modify the TrustedPointerTableEntry payload to use a compare_exchange_weak loop to ensure they preserve concurrently updated bits.
For Mark():
void TrustedPointerTableEntry::Mark() {
auto old_payload = payload_.load(std::memory_order_relaxed);
while (!old_payload.HasMarkBitSet()) {
auto new_payload = old_payload;
new_payload.SetMarkBit();
if (payload_.compare_exchange_weak(old_payload, new_payload, std::memory_order_relaxed)) {
break;
}
}
}
Similarly, Publish() and Unpublish() should be refactored to use a CAS loop instead of a direct store to avoid overwriting a concurrently set mark bit.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us make improvement over time. The PoC was run in a VM and it seemed to be legit - if not, let us know and we can strengthen our checker. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.