CVE-2026-14432
Overview
Background
- `CppHeapPointerTable` / `ExternalPointerTable`
- sandbox pointer tables that store tagged pointers behind handles (indices), so that in-sandbox memory holds only handles and the real pointers live in a separate table with a per-entry mark bit.
- Evacuation entry
- during table compaction, a special entry created by
MaybeCreateEvacuationEntrythat records a source handle location so sweeping can relocate the entry out of the evacuation area and update the handle. - Mark bit / write barrier
- the GC sets an entry’s mark bit to keep it alive; the mutator’s write barrier and entry stores historically also set the mark bit when writing a new value.
- `start_of_evacuation_area`
- the index boundary above which entries are being evacuated; sweeping asserts a recorded
old_index >= start_of_evacuation_area.
Root Cause Analysis
When marking an entry in CppHeapPointerTable/ExternalPointerTable, the pre-patch table-level marking routine unconditionally called MaybeCreateEvacuationEntry(space, index, handle_location) and then at(index).Mark(), with the entry Mark() performing a single non-looping compare_exchange_strong that could silently fail and leave the entry unmarked. Concurrently, a mutator store or write barrier would write a new value and, in doing so, unconditionally set the mark bit (new_payload.SetMarkBit()), so a race could clear-then-reset the mark bit and let the same handle_location pass the “create evacuation entry” step twice, generating duplicate evacuation entries for one handle. During sweeping the second evacuation entry crashed because the handle had already been updated to the new low index, violating the old_index >= start_of_evacuation_area assertion.
The fix makes CppHeapPointerTableEntry::Mark() return a bool indicating whether it actually transitioned un-marked→marked (using a CAS loop that returns false if the mark bit was already set), and reorders the table-level routine to if (!at(index).Mark()) return; before calling MaybeCreateEvacuationEntry, guaranteeing at most one evacuation entry per handle location. It also stops entry stores/writes from unconditionally setting the mark bit: the store and exchange paths now CAS in a loop and preserve the existing mark bit only if (old_payload.HasMarkBitSet()), removing “black allocation” for the EPT, and heap-write-barrier.cc adds a SURVIVOR_TO_EXTERNAL_POINTER remembered-set insert for young-generation hosts under minor marking to compensate.
Mark() returning true and makes stores preserve rather than force the mark bit, so exactly one evacuation entry exists per handle.Attack Path
- Enable compaction with a young/marked entry
With the pointer-table sandbox enabled, an entry inside the evacuation area is subject to compaction while concurrent marking and mutator writes are in flight (advisory bug class
UAF). - Race mark bit clear/set A concurrent mutator store or write barrier writes a new value and re-sets the mark bit while GC is marking, so the mark bit is cleared and set again for the same handle. (per commit message)
- Duplicate evacuation entries
Because the pre-patch code created an evacuation entry without checking whether
Mark()truly transitioned the entry, the samehandle_locationproduces two evacuation entries. - Sweep-time corruption
Sweeping processes the second entry after the handle has already been updated to the new low index, failing the
old_index >= start_of_evacuation_areaassertion and mishandling an already-evacuated handle, the memory-safety condition the patch closes; any exploitation beyond this table corruption is (inferred).
Impact Assessment
V8_COMPRESS_POINTERS) with table compaction active and a mark/write race. The advisory classifies this as UAF / “Use after free in V8” and it is a sandbox-hardening fix (reviewed by the sandbox team); no exploited-in-the-wild status is stated, and a sandbox escape or code-execution outcome beyond the pointer-table corruption is (inferred).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
Audit Directions
- Non-looping CAS for markingAudit other pointer-table and GC mark routines that use a single
compare_exchange_strongwithout checking success or looping, since a lost CAS can leave state (mark bit) inconsistent under concurrency. - Side effects tied to a write's mark bitReview code that treats “entry was written” as equivalent to “entry is marked” (black allocation), and confirm evacuation-entry or remembered-set bookkeeping does not double-fire when the mark bit is cleared and re-set.
- Idempotency of evacuation-entry creationLook for
MaybeCreateEvacuationEntry-style calls that are not guarded by a genuine mark-transition check, ensuring at most one evacuation entry perhandle_locationacross all tables that support compaction.
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.