Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentSandbox
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: Diff/commit only show duplicate evacuation entries crashing a sweep assertion; UAF outcome inferred from advisory. Requires GC mark/mutator race during table compaction, not attacker-deterministic 'default'. Omits added minor-GC remembered-set insert in write barrier; V8_COMPRESS_POINTERS is default, not a precondition.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker524290062
Fix commit493ea0b937d9 (v8/v8) +93/-59
CWECWE-416 (Use-after-free)
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-06-15 (per vendor advisory)
Fix landed2026-06-16 (public git)
Disclosed2026-06-30
Patch gap14 days — fix public in source before the advisory

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 MaybeCreateEvacuationEntry that 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.

Key insight
The core mistake was creating an evacuation entry before (and independently of) confirming the entry actually transitioned to marked, combined with writes that unconditionally re-set the mark bit; the fix gates evacuation-entry creation on Mark() returning true and makes stores preserve rather than force the mark bit, so exactly one evacuation entry exists per handle.

Attack Path

  1. 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).
  2. 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)
  3. Duplicate evacuation entries Because the pre-patch code created an evacuation entry without checking whether Mark() truly transitioned the entry, the same handle_location produces two evacuation entries.
  4. 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_area assertion and mishandling an already-evacuated handle, the memory-safety condition the patch closes; any exploitation beyond this table corruption is (inferred).

Impact Assessment

An attacker running JavaScript that exercises the CppHeap/external-pointer tables under concurrent marking and compaction can cause duplicate evacuation entries for one handle, leading to a use-after-free/corruption of pointer-table state during sweeping in the renderer process; the precondition is the pointer-compression sandbox build (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

FunctionChangeNotes
while
src/sandbox/cppheap-pointer-table-inl.h
modified
while
src/sandbox/external-pointer-table-inl.h
modified

Files Changed

  • src/heap/heap-write-barrier.cc
  • src/sandbox/cppheap-pointer-table-inl.h
  • src/sandbox/cppheap-pointer-table.h
  • src/sandbox/external-pointer-table-inl.h

Audit Directions

  • Non-looping CAS for marking
    Audit other pointer-table and GC mark routines that use a single compare_exchange_strong without checking success or looping, since a lost CAS can leave state (mark bit) inconsistent under concurrency.
  • Side effects tied to a write's mark bit
    Review 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 creation
    Look for MaybeCreateEvacuationEntry-style calls that are not guarded by a genuine mark-transition check, ensuring at most one evacuation entry per handle_location across all tables that support compaction.
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());
Loading diff…

Original Bug Report

reported by [email protected]

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:

  1. CAS Failure: If the tag is modified between Mark()’s load and compare_exchange_strong, the CAS fails. Since Mark() does not loop, the entry remains unmarked.
  2. Bit Overwrite: If Mark() succeeds first, the concurrent store in Publish()/Unpublish() will blindly overwrite the payload using the older old_payload value (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.

View on issue tracker