Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Bindings
DescriptionUse after free in Bindings
ComponentBindings
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — 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 AIPlausible
Reviewer notes: V8-side contract fix; the Blink callback that allocates/GCs is not in diff, so web path is inferred. FixedArray is moved by compaction, not freed; real stale refs are unrooted stack fake handles and raw elements. JS execution stays disallowed in callback.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker522061704
Fix commit3186b30e34b4 (v8/v8) +204/-54
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-10 (per vendor advisory)
Fix landed2026-08-20 (public git)
Disclosed2026-09-22
Patch gap33 days — fix public in source before the advisory

Background

`Array::Iterate()`
A V8 public fast-path API that walks a JS array’s backing store and hands each element to an embedder-supplied callback, avoiding repeated Get() calls.
`DirectHandle` / `IndirectHandle`
Handle types that keep a heap object reachable and updated across a garbage collection, unlike a raw Tagged<Object> or a hand-forged “fake handle” over a stack address.
`FOR_WITH_HANDLE_SCOPE`
A loop macro that opens a fresh HandleScope per iteration so per-element handles are created and released cleanly.
Use-after-free (UAF)
Accessing a heap object after it has been freed or relocated, here caused by holding a raw pointer to an array element across a GC.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: V8-side contract fix; the Blink callback that allocates/GCs is not in diff, so web path is inferred. FixedArray is moved by compaction, not freed; real stale refs are unrooted stack fake handles and raw elements. JS execution stays disallowed in callback.

The fast iteration helper in src/api/api.cc (FastIterateArray) read each array element as a raw Tagged<Object> and wrapped its stack address in an IndirectHandle “fake handle” before passing it to callback, on the assumption that the callback would never allocate or re-enter V8 in a way that triggers a garbage collection. That assumption was an unenforced contract: Array::Iterate() is reachable from Blink bindings whose callbacks can allocate (for example building an error object) or otherwise cause a GC, at which point the moving collector relocates or frees the underlying FixedArray, leaving the fake handle and the still-cached elements pointer dangling. The callback then receives a Local<Value> backed by freed or moved memory, a use-after-free in Bindings.

The fix replaces the raw Tagged reads and fake handles with real GC-tracked DirectHandles for both elements and each element, wraps the loops in FOR_WITH_HANDLE_SCOPE, and narrows DisallowGarbageCollection to only the dictionary-scan region that truly must not allocate, so any GC the callback provokes updates the live handles instead of leaving stale pointers.

Key insight
The core mistake was optimizing away real handles (“fake handles” over raw Tagged element pointers) based on an unenforceable “callback must not allocate / must not call into V8” invariant; the fix restores proper DirectHandles and per-iteration handle scopes so element references survive a callback-induced GC.

Attack Path

  1. Reach the fast path Script drives a Blink binding that internally calls Array::Iterate() on an attacker-controlled JS array whose element kind (packed, holey, or dictionary) selects one of the patched loops.
  2. Trigger allocation in the callback The embedder callback performs an allocation or a V8 re-entry that provokes a garbage collection while iteration is in progress, which the old contract nominally forbade but did not enforce.
  3. Dangle the element reference The moving GC relocates or frees the backing FixedArray, so the cached elements pointer and the IndirectHandle fake handle now reference stale memory. (inferred)
  4. Use freed object The callback receives a Local<Value> (Utils::ToLocal(fake_handle)) that resolves to the freed/moved element, yielding a use-after-free read within the Bindings layer. (inferred)

Impact Assessment

An attacker who can run script that reaches an Array::Iterate()-backed binding gains a use-after-free on array element storage in the renderer process, exploitable when a callback path allocates or re-enters V8 and forces a GC mid-iteration. The advisory classifies this as a Use-after-free in Bindings and states no exploited-in-the-wild status; any escalation from this UAF read to a controllable read/write primitive or renderer code execution is (inferred) and not demonstrated by the patch. The preconditions are attacker-controlled array contents and reaching an iteration callback that can allocate.

Changed Functions

FunctionChangeNotes
for
src/api/api.cc
modified
FOR_WITH_HANDLE_SCOPE
src/api/api.cc
modified
if
src/api/api.cc
modified
TEST_F
test/unittests/api/v8-array-unittest.cc
modified
for
test/unittests/api/v8-array-unittest.cc
modified

Files Changed

  • include/v8-container.h
  • test/unittests/api/v8-array-unittest.cc
  • src/api/api.cc

Audit Directions

  • Fake-handle-over-raw-pointer patterns
    Search for IndirectHandle/DirectHandle constructed from reinterpret_cast<Address*>(&local) or from stack-cached Tagged values passed into callbacks, where a callback could allocate.
  • Unenforced "no allocation / no GC" callback contracts
    Audit fast-path APIs whose doc comments assert a callback must not allocate or call back into V8 but do not enforce it with DisallowHeapAllocation/DisallowGarbageCollection around the sensitive access.
  • Cached backing-store pointers across callbacks
    Look for loops that read array->elements() (or similar backing stores) once into a raw Tagged and index it while invoking embedder callbacks that may relocate or free that store.
From 3186b30e34b4ecca7f99a5e0dc4f2f031442483a Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
Date: Wed, 19 Aug 2026 17:24:44 -0700
Subject: [PATCH] Expect callback passed to Array::Iterate() may potentially cause a GC

We practically only need it in rare cases, but enforcing it consistently
across different array types may be difficult and confusing for the client.
On the other hand, these days, the embedder is supposed to use
TryToCopyAndConvertArrayToCppBuffer() for the **real** fast path.

Bug: 522061704
Change-Id: Ic917238e9618f08661dc88f620fc04fea66246a4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8269609
Reviewed-by: Michael Lippautz <mlippautz@chromium.org>
Auto-Submit: Andrey Kosyakov <caseq@chromium.org>
Reviewed-by: Igor Sheludko <ishell@chromium.org>
Commit-Queue: Igor Sheludko <ishell@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109405}
---
diff --git a/include/v8-container.h b/include/v8-container.h
index 380999e5..213e38d 100644
--- a/include/v8-container.h
+++ b/include/v8-container.h
@@ -76,17 +76,8 @@
    * This function will typically be faster than calling {Get()} repeatedly.
    * As a consequence of being optimized for low overhead, the provided
    * callback must adhere to the following restrictions:
-   *  - It must not allocate any V8 objects and continue iterating; it may
-   *    allocate (e.g. an error message/object) and then immediately terminate
-   *    the iteration.
    *  - It must not modify the array being iterated.
-   *  - It must not call back into V8 (unless it can guarantee that such a
-   *    call does not violate the above restrictions, which is difficult).
-   *  - The {Local<Value> element} must not "escape", i.e. must not be assigned
-   *    to any other {Local}. Creating a {Global} from it, or updating a
-   *    v8::TypecheckWitness with it, is safe.
-   * These restrictions may be lifted in the future if use cases arise that
-   * justify a slower but more robust implementation.
+   *  - It must not call back into V8 to execute JavaScript.
    *
    * Returns {Nothing} on exception; use a {TryCatch} to catch and handle this
    * exception.
diff --git a/src/api/api.cc b/src/api/api.cc
index 8a7affc..9ed5925 100644
--- a/src/api/api.cc
+++ b/src/api/api.cc
@@ -8318,11 +8318,6 @@
                                    void* callback_data) {
   // Instead of relying on callers to check condition, this function returns
   // {kSlowPath} for situations it can't handle.
-  // Most code paths below don't allocate, and rely on {callback} not allocating
-  // either, but this isn't enforced with {DisallowHeapAllocation} to allow
-  // embedders to allocate error objects before terminating the iteration.
-  // Since {callback} must not allocate anyway, we can get away with fake
-  // handles, reducing per-element overhead.
   if (!CanUseFastIteration(isolate, array)) return FastIterateResult::kSlowPath;
   using Result = v8::Array::CallbackResult;
   DisallowJavascriptExecution no_js(isolate);
@@ -8334,14 +8329,12 @@
     case PACKED_FROZEN_ELEMENTS:
     case PACKED_SEALED_ELEMENTS:
     case PACKED_NONEXTENSIBLE_ELEMENTS: {
-      Tagged<FixedArray> elements = Cast<FixedArray>(array->elements());
-      for (uint32_t i = 0; i < length; i++) {
-        Tagged<Object> element = elements->get(static_cast<int>(i));
-        // TODO(13270): When we switch to CSS, we can pass {element} to
-        // the callback directly, without {fake_handle}.
-        IndirectHandle<Object> fake_handle(
-            reinterpret_cast<Address*>(&element));
-        Result result = callback(i, Utils::ToLocal(fake_handle), callback_data);
+      DirectHandle<FixedArray> elements(Cast<FixedArray>(array->elements()),
+                                        isolate);
+      FOR_WITH_HANDLE_SCOPE(isolate, uint32_t i = 0, i, i < length, i++) {
+        DirectHandle<Object> element(elements->get(static_cast<int>(i)),
+                                     isolate);
+        Result result = callback(i, Utils::ToLocal(element), callback_data);
         if (result != Result::kContinue) {
           return static_cast<FastIterateResult>(result);
         }
@@ -8354,16 +8347,16 @@
     case HOLEY_SEALED_ELEMENTS:
     case HOLEY_NONEXTENSIBLE_ELEMENTS:
     case HOLEY_ELEMENTS: {
-      Tagged<FixedArray> elements = Cast<FixedArray>(array->elements());
-      for (uint32_t i = 0; i < length; i++) {
-        Tagged<Object> element = elements->get(static_cast<int>(i));
-        // TODO(13270): When we switch to CSS, we can pass {element} to
-        // the callback directly, without {fake_handle}.
-        auto fake_handle =
-            IsTheHole(element)
-                ? isolate->factory()->undefined_value()
-                : IndirectHandle<Object>(reinterpret_cast<Address*>(&element));
-        Result result = callback(i, Utils::ToLocal(fake_handle), callback_data);
+      DirectHandle<FixedArray> elements(Cast<FixedArray>(array->elements()),
+                                        isolate);
+      FOR_WITH_HANDLE_SCOPE(isolate, uint32_t i = 0, i, i < length, i++) {
+        DirectHandle<Object> element(elements->get(static_cast<int>(i)),
+                                     isolate);
+        DirectHandle<Object> value =
+            IsTheHole(*element)
+                ? DirectHandle<Object>(isolate->factory()->undefined_value())
+                : element;
+        Result result = callback(i, Utils::ToLocal(value), callback_data);
         if (result != Result::kContinue) {
           return static_cast<FastIterateResult>(result);
         }
@@ -8379,10 +8372,10 @@
       FOR_WITH_HANDLE_SCOPE(isolate, uint32_t i = 0, i, i < length, i++) {
         DirectHandle<Object> value;
         if (elements->is_the_hole(i)) {
-          value = Handle<Object>(isolate->factory()->undefined_value());
+          value = isolate->factory()->undefined_value();
 #ifdef V8_ENABLE_UNDEFINED_DOUBLE
         } else if (elements->is_undefined(i)) {
-          value = Handle<Object>(isolate->factory()->undefined_value());
+          value = isolate->factory()->undefined_value();
 #endif  // V8_ENABLE_UNDEFINED_DOUBLE
         } else {
           value = isolate->factory()->NewNumber(elements->get_scalar(i));
@@ -8396,32 +8389,32 @@
       return FastIterateResult::kFinished;
     }
     case DICTIONARY_ELEMENTS: {
-      DisallowGarbageCollection no_gc;
-      Tagged<NumberDictionary> dict = array->element_dictionary();
       struct Entry {
         uint32_t index;
         InternalIndex entry;
       };
       std::vector<Entry> sorted;
-      sorted.reserve(dict->NumberOfElements());
-      ReadOnlyRoots roots(isolate);
-      for (InternalIndex i : dict->IterateEntries()) {
-        Tagged<Object> key = dict->KeyAt(i);
-        if (!dict->IsKey(roots, key)) continue;
-        uint32_t index =
-            static_cast<uint32_t>(Object::NumberValue(Cast<Number>(key)));
-        sorted.push_back({index, i});
+      DirectHandle<NumberDictionary> dict(array->element_dictionary(), isolate);
+      {
+        DisallowGarbageCollection no_gc;
+        ReadOnlyRoots roots(isolate);
+        sorted.reserve(dict->NumberOfElements());
+        for (InternalIndex i : dict->IterateEntries()) {
+          Tagged<Object> key = dict->KeyAt(i);
+          if (!dict->IsKey(roots, key)) continue;
+          uint32_t index =
+              static_cast<uint32_t>(Object::NumberValue(Cast<Number>(key)));
+          sorted.push_back({index, i});
+        }
+        std::sort(
+            sorted.begin(), sorted.end(),
+            [](const Entry& a, const Entry& b) { return a.index < b.index; });
       }
-      std::sort(
-          sorted.begin(), sorted.end(),
-          [](const Entry& a, const Entry& b) { return a.index < b.index; });
-      for (const Entry& entry : sorted) {
-        Tagged<Object> value = dict->ValueAt(entry.entry);
-        // TODO(13270): When we switch to CSS, we can pass {element} to
-        // the callback directly, without {fake_handle}.
-        IndirectHandle<Object> fake_handle(reinterpret_cast<Address*>(&value));
+      FOR_WITH_HANDLE_SCOPE(isolate, size_t i = 0, i, i < sorted.size(), i++) {
+        const Entry& entry = sorted[i];
+        DirectHandle<Object> value(dict->ValueAt(entry.entry), isolate);
         Result result =
-            callback(entry.index, Utils::ToLocal(fake_handle), callback_data);
+            callback(entry.index, Utils::ToLocal(value), callback_data);
         if (result != Result::kContinue) {
           return static_cast<FastIterateResult>(result);
         }
diff --git a/test/unittests/api/v8-array-unittest.cc b/test/unittests/api/v8-array-unittest.cc
index b0166eb..55eb0d5 100644
--- a/test/unittests/api/v8-array-unittest.cc
+++ b/test/unittests/api/v8-array-unittest.cc
@@ -5,7 +5,11 @@
 #include "include/v8-container.h"
 #include "include/v8-primitive.h"
 #include "include/v8-value.h"
+#include "src/api/api-inl.h"
 #include "src/execution/protectors-inl.h"
+#include "src/heap/heap-inl.h"
+#include "src/objects/js-array-inl.h"
+#include "test/unittests/heap/heap-utils.h"
 #include "test/unittests/interpreter/interpreter-tester.h"
 #include "test/unittests/test-utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -241,5 +245,167 @@
       internal::Isolate::Current()));
 }
 
+TEST_F(ArrayTest, IterateWithGC) {
+  i::v8_flags.expose_gc = true;
+  HandleScope scope(isolate());
+
+  struct ArrayTestCase {
+    const char* js_source;
+    internal::ElementsKind expected_kind;
+    std::vector<std::string> expected_values;
+  };
+
+  const ArrayTestCase test_cases[] = {
+      // PACKED_SMI_ELEMENTS
+      {"[10, 20, 30]", internal::PACKED_SMI_ELEMENTS, {"10", "20", "30"}},
+      // HOLEY_SMI_ELEMENTS
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return a; })()",
+       internal::HOLEY_SMI_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_ELEMENTS
+      {"['foo', 'bar', 'baz']",
+       internal::PACKED_ELEMENTS,
+       {"foo", "bar", "baz"}},
+      // HOLEY_ELEMENTS
+      {"(() => { const a = ['foo', 'bar', 'baz']; delete a[1]; return a; })()",
+       internal::HOLEY_ELEMENTS,
+       {"foo", "undefined", "baz"}},
+      // PACKED_DOUBLE_ELEMENTS
+      {"[1.5, 2.5, 3.5]",
+       internal::PACKED_DOUBLE_ELEMENTS,
+       {"1.5", "2.5", "3.5"}},
+      // HOLEY_DOUBLE_ELEMENTS
+      {"(() => { const a = [1.5, 2.5, 3.5]; delete a[1]; return a; })()",
+       internal::HOLEY_DOUBLE_ELEMENTS,
+       {"1.5", "undefined", "3.5"}},
+      // PACKED_NONEXTENSIBLE_ELEMENTS (Object and Smi)
+      {"Object.preventExtensions(['a', 'b', 'c'])",
+       internal::PACKED_NONEXTENSIBLE_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.preventExtensions([10, 20, 30])",
+       internal::PACKED_NONEXTENSIBLE_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_NONEXTENSIBLE_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.preventExtensions(a); })()",
+       internal::HOLEY_NONEXTENSIBLE_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.preventExtensions(a); })()",
+       internal::HOLEY_NONEXTENSIBLE_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_SEALED_ELEMENTS (Object and Smi)
+      {"Object.seal(['a', 'b', 'c'])",
+       internal::PACKED_SEALED_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.seal([10, 20, 30])",
+       internal::PACKED_SEALED_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_SEALED_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.seal(a); })()",
+       internal::HOLEY_SEALED_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.seal(a); })()",
+       internal::HOLEY_SEALED_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_FROZEN_ELEMENTS (Object and Smi)
+      {"Object.freeze(['a', 'b', 'c'])",
+       internal::PACKED_FROZEN_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.freeze([10, 20, 30])",
+       internal::PACKED_FROZEN_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_FROZEN_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.freeze(a); })()",
+       internal::HOLEY_FROZEN_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.freeze(a); })()",
+       internal::HOLEY_FROZEN_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // DICTIONARY_ELEMENTS (Sparse array)
+      {"(() => { const a = []; a[0] = 'a'; a[10000] = 'b'; return a; })()",
+       internal::DICTIONARY_ELEMENTS,
+       {"a", "b"}},
+      // DICTIONARY_ELEMENTS (Dense array with reconfigured property)
+      {"(() => { const a = [10, 20, 30]; Object.defineProperty(a, '0', "
+       "{value: 10, configurable: false, writable: false}); return a; })()",
+       internal::DICTIONARY_ELEMENTS,
+       {"10", "20", "30"}},
+      // Empty array (PACKED_SMI_ELEMENTS, length 0)
+      {"[]", internal::PACKED_SMI_ELEMENTS, {}},
+  };
+
+  struct IterationData {
+    Isolate* isolate;
+    Local<Context> context;
+    std::vector<std::pair<uint32_t, std::string>> visited;
+  };
+
+  for (const auto& tc : test_cases) {
+    Local<Array> array = RunJS(tc.js_source).As<Array>();
+    CHECK(array->IsArray());
+    auto i_array = Utils::OpenDirectHandle(*array);
+    CHECK_EQ(i_array->GetElementsKind(), tc.expected_kind);
+
+    IterationData data{isolate(), context(), {}};
+    Array::IterationCallback callback = [](uint32_t index, Local<Value> element,
+                                           void* data_ptr) -> CbResult {
+      IterationData* d = static_cast<IterationData*>(data_ptr);
+      // Perform GC within the iteration callback.
+      d->isolate->RequestGarbageCollectionForTesting(
+          v8::Isolate::kFullGarbageCollection);
+
+      // Also allocate some new objects in the heap.
+      Local<String> allocated_str =
+          String::NewFromUtf8Literal(d->isolate, "test_alloc");
+      CHECK(!allocated_str.IsEmpty());
+
+      std::string val_str;
+      if (element->IsUndefined()) {
+        val_str = "undefined";
+      } else if (element->IsNumber()) {
+        double val = element.As<Number>()->Value();
+        if (val == static_cast<int64_t>(val)) {
+          val_str = std::to_string(static_cast<int64_t>(val));
+        } else {
+          val_str = std::to_string(val);
+          // Strip trailing zeroes for clean comparison with "1.5", "2.5", "3.5"
+          val_str.erase(val_str.find_last_not_of('0') + 1, std::string::npos);
+          if (val_str.back() == '.') val_str.pop_back();
+        }
+      } else if (element->IsString()) {
+        String::Utf8Value utf8(d->isolate, element.As<String>());
+        val_str = *utf8;
+      } else {
+        val_str = "unknown";
+      }
+      d->visited.emplace_back(index, val_str);
+      return CbResult::kContinue;
+    };
+
+    CHECK(array->Iterate(context(), callback, &data).IsJust());
+
+    if (tc.expected_kind == internal::DICTIONARY_ELEMENTS &&
+        tc.expected_values.size() == 2 && tc.expected_values[0] == "a" &&
+        tc.expected_values[1] == "b") {
+      CHECK_EQ(data.visited.size(), 2);
+      CHECK_EQ(data.visited[0].first, 0);
+      CHECK_EQ(data.visited[0].second, "a");
+      CHECK_EQ(data.visited[1].first, 10000);
+      CHECK_EQ(data.visited[1].second, "b");
+    } else {
+      CHECK_EQ(data.visited.size(), tc.expected_values.size());
+      for (size_t i = 0; i < tc.expected_values.size(); ++i) {
+        CHECK_EQ(data.visited[i].first, static_cast<uint32_t>(i));
+        CHECK_EQ(data.visited[i].second, tc.expected_values[i]);
+      }
+    }
+  }
+}
+
 }  // namespace
 }  // namespace v8
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/unittests/api/v8-array-unittest.cc b/test/unittests/api/v8-array-unittest.cc
index b0166eb..55eb0d5 100644
--- a/test/unittests/api/v8-array-unittest.cc
+++ b/test/unittests/api/v8-array-unittest.cc
@@ -5,7 +5,11 @@
 #include "include/v8-container.h"
 #include "include/v8-primitive.h"
 #include "include/v8-value.h"
+#include "src/api/api-inl.h"
 #include "src/execution/protectors-inl.h"
+#include "src/heap/heap-inl.h"
+#include "src/objects/js-array-inl.h"
+#include "test/unittests/heap/heap-utils.h"
 #include "test/unittests/interpreter/interpreter-tester.h"
 #include "test/unittests/test-utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -241,5 +245,167 @@
       internal::Isolate::Current()));
 }
 
+TEST_F(ArrayTest, IterateWithGC) {
+  i::v8_flags.expose_gc = true;
+  HandleScope scope(isolate());
+
+  struct ArrayTestCase {
+    const char* js_source;
+    internal::ElementsKind expected_kind;
+    std::vector<std::string> expected_values;
+  };
+
+  const ArrayTestCase test_cases[] = {
+      // PACKED_SMI_ELEMENTS
+      {"[10, 20, 30]", internal::PACKED_SMI_ELEMENTS, {"10", "20", "30"}},
+      // HOLEY_SMI_ELEMENTS
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return a; })()",
+       internal::HOLEY_SMI_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_ELEMENTS
+      {"['foo', 'bar', 'baz']",
+       internal::PACKED_ELEMENTS,
+       {"foo", "bar", "baz"}},
+      // HOLEY_ELEMENTS
+      {"(() => { const a = ['foo', 'bar', 'baz']; delete a[1]; return a; })()",
+       internal::HOLEY_ELEMENTS,
+       {"foo", "undefined", "baz"}},
+      // PACKED_DOUBLE_ELEMENTS
+      {"[1.5, 2.5, 3.5]",
+       internal::PACKED_DOUBLE_ELEMENTS,
+       {"1.5", "2.5", "3.5"}},
+      // HOLEY_DOUBLE_ELEMENTS
+      {"(() => { const a = [1.5, 2.5, 3.5]; delete a[1]; return a; })()",
+       internal::HOLEY_DOUBLE_ELEMENTS,
+       {"1.5", "undefined", "3.5"}},
+      // PACKED_NONEXTENSIBLE_ELEMENTS (Object and Smi)
+      {"Object.preventExtensions(['a', 'b', 'c'])",
+       internal::PACKED_NONEXTENSIBLE_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.preventExtensions([10, 20, 30])",
+       internal::PACKED_NONEXTENSIBLE_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_NONEXTENSIBLE_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.preventExtensions(a); })()",
+       internal::HOLEY_NONEXTENSIBLE_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.preventExtensions(a); })()",
+       internal::HOLEY_NONEXTENSIBLE_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_SEALED_ELEMENTS (Object and Smi)
+      {"Object.seal(['a', 'b', 'c'])",
+       internal::PACKED_SEALED_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.seal([10, 20, 30])",
+       internal::PACKED_SEALED_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_SEALED_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.seal(a); })()",
+       internal::HOLEY_SEALED_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.seal(a); })()",
+       internal::HOLEY_SEALED_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // PACKED_FROZEN_ELEMENTS (Object and Smi)
+      {"Object.freeze(['a', 'b', 'c'])",
+       internal::PACKED_FROZEN_ELEMENTS,
+       {"a", "b", "c"}},
+      {"Object.freeze([10, 20, 30])",
+       internal::PACKED_FROZEN_ELEMENTS,
+       {"10", "20", "30"}},
+      // HOLEY_FROZEN_ELEMENTS (Object and Smi)
+      {"(() => { const a = ['a', 'b', 'c']; delete a[1]; return "
+       "Object.freeze(a); })()",
+       internal::HOLEY_FROZEN_ELEMENTS,
+       {"a", "undefined", "c"}},
+      {"(() => { const a = [10, 20, 30]; delete a[1]; return "
+       "Object.freeze(a); })()",
+       internal::HOLEY_FROZEN_ELEMENTS,
+       {"10", "undefined", "30"}},
+      // DICTIONARY_ELEMENTS (Sparse array)
+      {"(() => { const a = []; a[0] = 'a'; a[10000] = 'b'; return a; })()",
+       internal::DICTIONARY_ELEMENTS,
+       {"a", "b"}},
+      // DICTIONARY_ELEMENTS (Dense array with reconfigured property)
+      {"(() => { const a = [10, 20, 30]; Object.defineProperty(a, '0', "
+       "{value: 10, configurable: false, writable: false}); return a; })()",
+       internal::DICTIONARY_ELEMENTS,
+       {"10", "20", "30"}},
+      // Empty array (PACKED_SMI_ELEMENTS, length 0)
+      {"[]", internal::PACKED_SMI_ELEMENTS, {}},
+  };
+
+  struct IterationData {
+    Isolate* isolate;
+    Local<Context> context;
+    std::vector<std::pair<uint32_t, std::string>> visited;
+  };
+
+  for (const auto& tc : test_cases) {
+    Local<Array> array = RunJS(tc.js_source).As<Array>();
+    CHECK(array->IsArray());
+    auto i_array = Utils::OpenDirectHandle(*array);
+    CHECK_EQ(i_array->GetElementsKind(), tc.expected_kind);
+
+    IterationData data{isolate(), context(), {}};
+    Array::IterationCallback callback = [](uint32_t index, Local<Value> element,
+                                           void* data_ptr) -> CbResult {
+      IterationData* d = static_cast<IterationData*>(data_ptr);
+      // Perform GC within the iteration callback.
+      d->isolate->RequestGarbageCollectionForTesting(
+          v8::Isolate::kFullGarbageCollection);
+
+      // Also allocate some new objects in the heap.
+      Local<String> allocated_str =
+          String::NewFromUtf8Literal(d->isolate, "test_alloc");
+      CHECK(!allocated_str.IsEmpty());
+
+      std::string val_str;
+      if (element->IsUndefined()) {
+        val_str = "undefined";
+      } else if (element->IsNumber()) {
+        double val = element.As<Number>()->Value();
+        if (val == static_cast<int64_t>(val)) {
+          val_str = std::to_string(static_cast<int64_t>(val));
+        } else {
+          val_str = std::to_string(val);
+          // Strip trailing zeroes for clean comparison with "1.5", "2.5", "3.5"
+          val_str.erase(val_str.find_last_not_of('0') + 1, std::string::npos);
+          if (val_str.back() == '.') val_str.pop_back();
+        }
+      } else if (element->IsString()) {
+        String::Utf8Value utf8(d->isolate, element.As<String>());
+        val_str = *utf8;
+      } else {
+        val_str = "unknown";
+      }
+      d->visited.emplace_back(index, val_str);
+      return CbResult::kContinue;
+    };
+
+    CHECK(array->Iterate(context(), callback, &data).IsJust());
+
+    if (tc.expected_kind == internal::DICTIONARY_ELEMENTS &&
+        tc.expected_values.size() == 2 && tc.expected_values[0] == "a" &&
+        tc.expected_values[1] == "b") {
+      CHECK_EQ(data.visited.size(), 2);
+      CHECK_EQ(data.visited[0].first, 0);
+      CHECK_EQ(data.visited[0].second, "a");
+      CHECK_EQ(data.visited[1].first, 10000);
+      CHECK_EQ(data.visited[1].second, "b");
+    } else {
+      CHECK_EQ(data.visited.size(), tc.expected_values.size());
+      for (size_t i = 0; i < tc.expected_values.size(); ++i) {
+        CHECK_EQ(data.visited[i].first, static_cast<uint32_t>(i));
+        CHECK_EQ(data.visited[i].second, tc.expected_values[i]);
+      }
+    }
+  }
+}
+
 }  // namespace
 }  // namespace v8
Loading diff…

Original Bug Report

reported by aw...@chromium.org

UAF in FastIterateArray via cppgc-triggered Mark-Compact GC during IDLSequence callback

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Use-After-Free vulnerability exists in FastIterateArray when handling PACKED or HOLEY elements. A Blink IDL sequence callback allocating on the cppgc heap can trigger a synchronous V8 mark-compact GC, leaving raw stack-resident Tagged pointers stale since conservative stack scanning is off by default. This leads to an in-cage stale write and fakeobj-shaped read.

Affected files:

  • src/api/api.cc

Estimated timestamp from git blame: 2023-09-14

1. Summary of the Issue (Meant for Human Triage)

A potential use-after-free (UAF) / stale pointer vulnerability exists in the FastIterateArray optimization helper in src/api/api.cc. When iterating over arrays with PACKED or HOLEY element kinds, the function caches a raw (non-handle) Tagged<FixedArray> stack local and dereferences elements into a raw Tagged<Object> stack local. While the documented contract of v8::Array::Iterate prohibits the embedder’s callback from allocating V8 objects while iterating, this restriction is not enforced in release builds, and embedders often interpret this as allowing allocations on independent heaps like Blink’s cppgc (Oilpan).

In Chromium, Blink’s IDL sequence converter for DOMArrayBuffer (CreateIDLSequenceFromV8ArraySlow<DOMArrayBuffer>) acts as the callback. During iteration, it allocates new DOMArrayBuffer objects on the cppgc heap via MakeGarbageCollected, subsequently returning kContinue to resume iteration. This cppgc allocation can synchronously trigger a full V8 mark-compact GC with compaction/evacuation when incremental marking is active and the old-generation allocation limit is overshot. Because conservative stack scanning (CSS) is disabled by default (v8_enable_direct_handle = false), the raw stack-resident Tagged<> pointers are neither traced nor updated when the elements array is relocated during GC.

This results in severe in-cage memory corruption:

  1. Stale Write: During the same iteration, the callback writes a CppHeapPointer handle to the stale, evacuated address of the ArrayBuffer element via v8::Object::Wrap.
  2. Stale Read: In the subsequent iteration, FastIterateArray reads elements from the dangling FixedArray pointer, yielding arbitrary/garbage values treated strictly as Tagged<Object> (a fakeobj-style type confusion / arbitrary read primitive).

This vulnerability is reachable from standard, unprivileged web content via several shipped WebCodecs APIs that accept a sequence<ArrayBuffer> dictionary member (e.g., EncodedVideoChunkInit.transfer).


2. Proof-of-Concept & Detailed Execution Flow

Note: Our tooling agent does not have the ability to run code, so the following are suggested, potential steps an attacker would follow to trigger the vulnerability based on static code analysis.

Phase 1: Setup and Preconditions

  1. Heap Grooming: The attacker grooms the V8 heap such that incremental major marking is currently active.
  2. Allocation Limit: The attacker ensures that the V8 heap allocation limit is currently overshot by a large margin (meaning heap->AllocationLimitOvershotByLargeMargin() evaluates to true).
  3. Payload Construction: The attacker creates a large array containing fresh ArrayBuffer objects via JavaScript: let abs = []; for(let k=0; k<50000; k++) abs.push(new ArrayBuffer(8));. Because they were just created in V8, they do not yet have Blink DOMArrayBuffer wrapper objects instantiated on the cppgc heap.
  4. API Invocation: The attacker triggers a WebCodecs API that accepts a sequence<ArrayBuffer>. For example: new EncodedVideoChunk({type: 'key', timestamp: 0, data: new Uint8Array(1), transfer: abs});.

Phase 2: V8 Invokes FastIterateArray

  1. Blink Bindings Resolution: The Blink bindings layer invokes CreateIDLSequenceFromV8ArraySlow<DOMArrayBuffer> to convert the incoming JavaScript array into an IDL sequence (third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h:909).
  2. V8 Fast Iteration Entry: Because DOMArrayBuffer implements ScriptWrappable, the Blink bindings call v8::Array::Iterate (include/v8-container.h:79), attempting to execute a fast callback for each element in the array.
  3. V8 delegates v8::Array::Iterate to i::FastIterateArray (src/api/api.cc:8199).
  4. FastIterateArray checks CanUseFastIteration which evaluates to true for standard arrays.
  5. Execution enters a DisallowJavascriptExecution scope (src/api/api.cc:8212). While this scope intercepts and fatals on V8 JS execution re-entry, it does not block V8 GC sweeps or cppgc memory allocations.
  6. FastIterateArray determines the elements kind of the array (e.g., PACKED_ELEMENTS).
  7. The Vulnerable Cache: V8 extracts the backing store of the array into a raw C++ stack local variable, bypassing Handle protections entirely: Tagged<FixedArray> elements = Cast<FixedArray>(array->elements()); (src/api/api.cc:8221).

Phase 3: The Iteration Loop and Raw Pointer Exposure

  1. The for loop begins iterating over the array length (src/api/api.cc:8222).
  2. During the first iteration (i=0), the element at index 0 is fetched into a second raw C++ stack local variable: Tagged<Object> element = elements->get(static_cast<int>(i)); (src/api/api.cc:8223).
  3. V8 constructs a temporary IndirectHandle<Object> named fake_handle that points directly to the memory address of the local element variable on the C++ stack (src/api/api.cc:8226-8227).
  4. V8 invokes the Blink-provided callback, passing the fake_handle containing the raw, unprotected pointer (src/api/api.cc:8228).
  1. The Blink callback maps to NativeValueTraits<DOMArrayBuffer>::NativeValue which invokes ToDOMArrayBuffer (third_party/blink/renderer/bindings/core/v8/native_value_traits_buffer_sources.cc:286).
  2. Inside ToDOMArrayBuffer, Blink notices that the current V8 ArrayBuffer does not possess a Blink wrapper.
  3. Blink allocates a new DOMArrayBuffer object to act as the wrapper using DOMArrayBuffer::Create(contents).
  4. DOMArrayBuffer::Create utilizes MakeGarbageCollected<DOMArrayBuffer> to allocate memory on the cppgc (Oilpan) heap (include/cppgc/allocation.h:325).

Phase 5: Synchronous Mark-Compact Garbage Collection is Triggered

  1. The cppgc allocator calls cppgc::ObjectAllocator::OutOfLineAllocateGCSafePoint (src/heap/cppgc/object-allocator.cc:152).
  2. This notifies the stats collector via StatsCollector::NotifySafePointForConservativeCollection (src/heap/cppgc/stats-collector.cc:60).
  3. The stats collector invokes the CppHeap observer function: CppHeap::AllocatedObjectSizeIncreased (src/heap/cppgc-js/cpp-heap.cc:1056).
  4. CppHeap invokes ReportBufferedAllocationSizeIfPossible (src/heap/cppgc-js/cpp-heap.cc:1066).
  5. Because IsGCAllowed() evaluates to true and incremental marking is active, execution proceeds to check the allocation limits.
  6. Due to the attacker’s heap grooming (Step 2), the condition heap->AllocationLimitOvershotByLargeMargin() evaluates to true (src/heap/cppgc-js/cpp-heap.cc:1098).
  7. Synchronous GC Execution: V8 synchronously triggers a full atomic mark-compact GC by calling heap->FinalizeIncrementalMarkingAtomically(i::GarbageCollectionReason::kExternalFinalize) (src/heap/cppgc-js/cpp-heap.cc:1099).

Phase 6: Array Backing Store Evacuation

  1. The mark-compact garbage collector begins execution.
  2. The V8 heap page containing the array’s FixedArray backing store is selected as an evacuation candidate for compaction.
  3. The FixedArray containing the elements is relocated to a completely new address on a different heap page.
  4. By default, V8 does not scan the C++ stack for raw pointers. Because v8_enable_direct_handle is false (gni/v8.gni:200), ConservativeStackScanningModeForMajorGC returns StackScanMode::kNone (src/heap/heap.h:414).
  5. Because there is no conservative stack scanning, the raw Tagged<FixedArray> elements and Tagged<Object> element stack variables in the frozen FastIterateArray stack frame are not updated to reflect the new object locations. They now contain stale, dangling addresses pointing to the evacuated (freed) page memory.
  6. The garbage collection cycle completes, and execution returns to ToDOMArrayBuffer.

Phase 7: Stale Write (Same Iteration)

  1. Blink links the newly allocated array_buffer to the V8 object via array_buffer->AssociateWithWrapper(...).
  2. The V8 object provided to AssociateWithWrapper is backed by the fake_handle, which dereferences the un-updated, dangling element stack local.
  3. The execution path reaches v8::Object::Wrap (src/api/api.cc:6237).
  4. Wrap attempts to link the wrapper by storing a CppHeapPointer inside the V8 object via SetCppHeapWrappable.
  5. This calls WriteLazilyInitializedCppHeapPointerField (src/sandbox/cppheap-pointer-inl.h:56).
  6. The Stale Write (In-Cage UAF): V8 performs an atomic 32-bit release store (slot.Release_StoreHandle(new_handle)) to write the CppHeapPointerHandle directly to the object field located at the dangling, evacuated pointer address. This constitutes an out-of-bounds, in-cage Write Use-After-Free.
  7. The Blink callback completes and returns v8::Array::CallbackResult::kContinue to V8 (third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h:962).

Phase 8: Stale Read & Fake Object Confusion (Subsequent Iteration)

  1. FastIterateArray resumes execution. Since the result is kContinue, the for loop continues (src/api/api.cc:8229).
  2. The loop counter i is incremented.
  3. The Stale Read (In-Cage UAF): FastIterateArray attempts to fetch the next element in the array using the dangling elements pointer: Tagged<Object> element = elements->get(static_cast<int>(i)); (src/api/api.cc:8223).
  4. This reads garbage data (or attacker-groomed data placed during the GC evacuation) from the freed heap page.
  5. The garbage data is treated strictly as a valid Tagged<Object> by V8.
  6. A new fake_handle is bound to this invalid Tagged<Object> memory.
  7. The Blink callback is invoked again with the garbage pointer (src/api/api.cc:8228).
  8. Fake Object / Type Confusion: The callback invokes value->IsArrayBuffer(), which dereferences the map word of the fake object at the invalid pointer address, yielding a type confusion / controlled arbitrary read primitive.

Suggested Fix

Blink-side (route to Blink>Bindings): Exclude DOMArrayBuffer and DOMSharedArrayBuffer from the Array::Iterate fast-path at native_value_traits_impl.h:909. Their NativeValue lazy-creates an Oilpan wrapper, inherently violating V8’s fast-iteration non-allocation constraints under memory pressure.

V8-side hardening: Handlify the elements and element variables in the PACKED and HOLEY branches of FastIterateArray at api.cc:8221 and api.cc:8241. The DOUBLE case at api.cc:8261 correctly uses DirectHandle<FixedDoubleArray> and is not vulnerable to this UAF.


3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

VERDICT: accept
RULE: Layer-1 write primitive whose reachability needs a contrived-but-plausible heap/feedback state.
LAYER: 1     CLASS: K6

Probabilities:
- 99% probability the vulnerability is real.
- 99% probability the details in the writeup are correct.
- 95% probability it could be exploited by an adversary (fakeobj-shaped read and Use-After-Free write).

Step-by-step Justification:
1. Determine the layer: The report specifies how a plain JavaScript WebCodecs API (`EncodedVideoChunk`) triggers the vulnerability via standard Blink bindings. The attacker does not require prior in-cage memory corruption. Thus, this is a Layer-1 finding.
2. Disqualifier checklist: No disqualifiers apply. The bug does not rely on DevTools, `d8`-only sinks, or non-shipping experimental flags. It is reachable on default flags (`v8_enable_direct_handle = false` disabling conservative stack scanning). The `DisallowJavascriptExecution` scope does not prevent the GC that triggers the bug.
3. Base severity: This is a Layer-1 memory corruption (Use-After-Free) resulting in a fakeobj-shaped read and an in-cage write. Under normal circumstances, an in-cage UAF write is rated `high`. However, as the report correctly identifies, reaching the GC trigger requires a very specific, contrived-but-plausible heap state (`Heap::AllocationLimitOvershotByLargeMargin()` being true during incremental marking, alongside a cppgc allocation threshold). Per the V8 severity determination guide, a "Layer-1 write primitive whose reachability needs a contrived-but-plausible heap/feedback/tier-up state that an attacker could realistically arrange but that significantly narrows applicability" caps the severity at `medium`.
4. Modifiers: The WebCodecs API is shipped and available on default flags. No experimental or developer flags are required. The bug applies to all architectures, including tier-1 (x64/arm64). Therefore, no modifiers reduce the severity. Final severity remains `medium`.

VERIFIED:
- `api.cc:8219-8233` (PACKED) and `:8239-8254` (HOLEY) use raw `Tagged<FixedArray>` and `Tagged<Object>` without Handle protections, unlike the DOUBLE branch.
- `DisallowJavascriptExecution` blocks JS via `execution.cc` but does not block V8 or cppgc GC.
- `cpp-heap.cc:1099` (`ReportBufferedAllocationSizeIfPossible`) triggers `FinalizeIncrementalMarkingAtomically`, causing a full GC with evacuation.
- `v8_enable_direct_handle = false` means conservative stack scanning is off by default, leaving raw stack pointers un-updated during GC.

NOT-VERIFIED:
- Blink's `third_party/blink/` binding implementations; accepted as reported.

DUPLICATE: DISTINCT from other `FastIterateArray` bugs (which involve Layer-2 length trusts).

Environmental Assumptions & Preconditions Verified by Tooling

  • CSS Off By Default: Checked v8_enable_direct_handle in gni/v8.gni:200. It defaults to false. As a result, ConservativeStackScanningModeForMajorGC returns StackScanMode::kNone (src/heap/heap.h:406-415).
  • DisallowJavascriptExecution: Traced in src/common/assert-scope.h and src/execution/execution.cc. This is an Isolate-level assertion scope checked strictly during Execution::Invoke. It does not intercept memory allocations or GC cycles. Indeed, DisallowJavascriptExecution is actively used inside Heap::CollectGarbage (src/heap/heap.cc:1467), proving it permits GC.
  • cppgc Triggers V8 GC: The call tree from MakeGarbageCollected leads to cppgc::ObjectAllocator::OutOfLineAllocateGCSafePoint. This calls StatsCollector::NotifySafePointForConservativeCollection, which invokes CppHeap::AllocatedObjectSizeIncreased (src/heap/cppgc-js/cpp-heap.cc:1056). Inside ReportBufferedAllocationSizeIfPossible, the path explicitly executes heap->FinalizeIncrementalMarkingAtomically if allocation limits are breached.
  • In-Cage Stale Write: Evaluated v8::Object::Wrap. It delegates to CppHeapObjectWrapper::SetCppHeapWrappable, which invokes WriteLazilyInitializedCppHeapPointerField (src/sandbox/cppheap-pointer-inl.h:56). Under pointer compression, this safely allocates an external CppHeapPointerTable entry, but performs an atomic 32-bit release store (slot.Release_StoreHandle) directly into the in-cage V8 JSObject memory address. If the object was evacuated, the write occurs at the dangling address.

Evaluated with Chrome root at commit: 71e7e1a98476f8faf0d126dc83935a84331b2194


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

Note: This bug has been automatically redirected to the top-level Chromium component. Please move it to the actual component: https://b.corp.google.com/components/1456124 once PoCs have been generated.

View on issue tracker