CVE-2026-95353
Overview
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 repeatedGet()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
HandleScopeper 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
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.
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
- 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. - Trigger allocation in the callback
The embedder
callbackperforms 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. - Dangle the element reference
The moving GC relocates or frees the backing
FixedArray, so the cachedelementspointer and theIndirectHandlefake handle now reference stale memory.(inferred) - 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
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
| Function | Change | Notes |
|---|---|---|
forsrc/api/api.cc |
modified | |
FOR_WITH_HANDLE_SCOPEsrc/api/api.cc |
modified | |
ifsrc/api/api.cc |
modified | |
TEST_Ftest/unittests/api/v8-array-unittest.cc |
modified | |
fortest/unittests/api/v8-array-unittest.cc |
modified |
Files Changed
include/v8-container.htest/unittests/api/v8-array-unittest.ccsrc/api/api.cc
Audit Directions
- Fake-handle-over-raw-pointer patternsSearch for
IndirectHandle/DirectHandleconstructed fromreinterpret_cast<Address*>(&local)or from stack-cachedTaggedvalues passed into callbacks, where a callback could allocate. - Unenforced "no allocation / no GC" callback contractsAudit fast-path APIs whose doc comments assert a callback must not allocate or call back into V8 but do not enforce it with
DisallowHeapAllocation/DisallowGarbageCollectionaround the sensitive access. - Cached backing-store pointers across callbacksLook for loops that read
array->elements()(or similar backing stores) once into a rawTaggedand index it while invoking embedder callbacks that may relocate or free that store.
Patch
From 3186b30e34b4ecca7f99a5e0dc4f2f031442483a Mon Sep 17 00:00:00 2001 From: Andrey Kosyakov <[email protected]> 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 <[email protected]> Auto-Submit: Andrey Kosyakov <[email protected]> Reviewed-by: Igor Sheludko <[email protected]> Commit-Queue: Igor Sheludko <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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:
- Stale Write: During the same iteration, the callback writes a
CppHeapPointerhandle to the stale, evacuated address of theArrayBufferelement viav8::Object::Wrap. - Stale Read: In the subsequent iteration,
FastIterateArrayreads elements from the danglingFixedArraypointer, yielding arbitrary/garbage values treated strictly asTagged<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
- Heap Grooming: The attacker grooms the V8 heap such that incremental major marking is currently active.
- Allocation Limit: The attacker ensures that the V8 heap allocation limit is currently overshot by a large margin (meaning
heap->AllocationLimitOvershotByLargeMargin()evaluates totrue). - Payload Construction: The attacker creates a large array containing fresh
ArrayBufferobjects 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 BlinkDOMArrayBufferwrapper objects instantiated on thecppgcheap. - 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
- 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). - V8 Fast Iteration Entry: Because
DOMArrayBufferimplementsScriptWrappable, the Blink bindings callv8::Array::Iterate(include/v8-container.h:79), attempting to execute a fast callback for each element in the array. - V8 delegates
v8::Array::Iteratetoi::FastIterateArray(src/api/api.cc:8199). FastIterateArraychecksCanUseFastIterationwhich evaluates to true for standard arrays.- Execution enters a
DisallowJavascriptExecutionscope (src/api/api.cc:8212). While this scope intercepts and fatals on V8 JS execution re-entry, it does not block V8 GC sweeps orcppgcmemory allocations. FastIterateArraydetermines the elements kind of the array (e.g.,PACKED_ELEMENTS).- 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
- The
forloop begins iterating over the array length (src/api/api.cc:8222). - During the first iteration (
i=0), the element at index0is fetched into a second raw C++ stack local variable:Tagged<Object> element = elements->get(static_cast<int>(i));(src/api/api.cc:8223). - V8 constructs a temporary
IndirectHandle<Object>namedfake_handlethat points directly to the memory address of the localelementvariable on the C++ stack (src/api/api.cc:8226-8227). - V8 invokes the Blink-provided callback, passing the
fake_handlecontaining the raw, unprotected pointer (src/api/api.cc:8228).
Phase 4: Blink Allocates on the cppgc Heap
- The Blink callback maps to
NativeValueTraits<DOMArrayBuffer>::NativeValuewhich invokesToDOMArrayBuffer(third_party/blink/renderer/bindings/core/v8/native_value_traits_buffer_sources.cc:286). - Inside
ToDOMArrayBuffer, Blink notices that the current V8ArrayBufferdoes not possess a Blink wrapper. - Blink allocates a new
DOMArrayBufferobject to act as the wrapper usingDOMArrayBuffer::Create(contents). DOMArrayBuffer::CreateutilizesMakeGarbageCollected<DOMArrayBuffer>to allocate memory on thecppgc(Oilpan) heap (include/cppgc/allocation.h:325).
Phase 5: Synchronous Mark-Compact Garbage Collection is Triggered
- The
cppgcallocator callscppgc::ObjectAllocator::OutOfLineAllocateGCSafePoint(src/heap/cppgc/object-allocator.cc:152). - This notifies the stats collector via
StatsCollector::NotifySafePointForConservativeCollection(src/heap/cppgc/stats-collector.cc:60). - The stats collector invokes the
CppHeapobserver function:CppHeap::AllocatedObjectSizeIncreased(src/heap/cppgc-js/cpp-heap.cc:1056). CppHeapinvokesReportBufferedAllocationSizeIfPossible(src/heap/cppgc-js/cpp-heap.cc:1066).- Because
IsGCAllowed()evaluates to true and incremental marking is active, execution proceeds to check the allocation limits. - Due to the attacker’s heap grooming (Step 2), the condition
heap->AllocationLimitOvershotByLargeMargin()evaluates to true (src/heap/cppgc-js/cpp-heap.cc:1098). - 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
- The mark-compact garbage collector begins execution.
- The V8 heap page containing the array’s
FixedArraybacking store is selected as an evacuation candidate for compaction. - The
FixedArraycontaining the elements is relocated to a completely new address on a different heap page. - By default, V8 does not scan the C++ stack for raw pointers. Because
v8_enable_direct_handleisfalse(gni/v8.gni:200),ConservativeStackScanningModeForMajorGCreturnsStackScanMode::kNone(src/heap/heap.h:414). - Because there is no conservative stack scanning, the raw
Tagged<FixedArray> elementsandTagged<Object> elementstack variables in the frozenFastIterateArraystack frame are not updated to reflect the new object locations. They now contain stale, dangling addresses pointing to the evacuated (freed) page memory. - The garbage collection cycle completes, and execution returns to
ToDOMArrayBuffer.
Phase 7: Stale Write (Same Iteration)
- Blink links the newly allocated
array_bufferto the V8 object viaarray_buffer->AssociateWithWrapper(...). - The V8 object provided to
AssociateWithWrapperis backed by thefake_handle, which dereferences the un-updated, danglingelementstack local. - The execution path reaches
v8::Object::Wrap(src/api/api.cc:6237). Wrapattempts to link the wrapper by storing aCppHeapPointerinside the V8 object viaSetCppHeapWrappable.- This calls
WriteLazilyInitializedCppHeapPointerField(src/sandbox/cppheap-pointer-inl.h:56). - The Stale Write (In-Cage UAF): V8 performs an atomic 32-bit release store (
slot.Release_StoreHandle(new_handle)) to write theCppHeapPointerHandledirectly to the object field located at the dangling, evacuated pointer address. This constitutes an out-of-bounds, in-cage Write Use-After-Free. - The Blink callback completes and returns
v8::Array::CallbackResult::kContinueto V8 (third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h:962).
Phase 8: Stale Read & Fake Object Confusion (Subsequent Iteration)
FastIterateArrayresumes execution. Since the result iskContinue, theforloop continues (src/api/api.cc:8229).- The loop counter
iis incremented. - The Stale Read (In-Cage UAF):
FastIterateArrayattempts to fetch the next element in the array using the danglingelementspointer:Tagged<Object> element = elements->get(static_cast<int>(i));(src/api/api.cc:8223). - This reads garbage data (or attacker-groomed data placed during the GC evacuation) from the freed heap page.
- The garbage data is treated strictly as a valid
Tagged<Object>by V8. - A new
fake_handleis bound to this invalidTagged<Object>memory. - The Blink callback is invoked again with the garbage pointer (
src/api/api.cc:8228). - 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_handleingni/v8.gni:200. It defaults tofalse. As a result,ConservativeStackScanningModeForMajorGCreturnsStackScanMode::kNone(src/heap/heap.h:406-415). - DisallowJavascriptExecution: Traced in
src/common/assert-scope.handsrc/execution/execution.cc. This is an Isolate-level assertion scope checked strictly duringExecution::Invoke. It does not intercept memory allocations or GC cycles. Indeed,DisallowJavascriptExecutionis actively used insideHeap::CollectGarbage(src/heap/heap.cc:1467), proving it permits GC. - cppgc Triggers V8 GC: The call tree from
MakeGarbageCollectedleads tocppgc::ObjectAllocator::OutOfLineAllocateGCSafePoint. This callsStatsCollector::NotifySafePointForConservativeCollection, which invokesCppHeap::AllocatedObjectSizeIncreased(src/heap/cppgc-js/cpp-heap.cc:1056). InsideReportBufferedAllocationSizeIfPossible, the path explicitly executesheap->FinalizeIncrementalMarkingAtomicallyif allocation limits are breached. - In-Cage Stale Write: Evaluated
v8::Object::Wrap. It delegates toCppHeapObjectWrapper::SetCppHeapWrappable, which invokesWriteLazilyInitializedCppHeapPointerField(src/sandbox/cppheap-pointer-inl.h:56). Under pointer compression, this safely allocates an externalCppHeapPointerTableentry, but performs an atomic 32-bit release store (slot.Release_StoreHandle) directly into the in-cage V8JSObjectmemory 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.