CVE-2026-113150
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc |
modified | |
TESTthird_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/typed_arrays/dom_array_buffer.ccthird_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.ccthird_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.ccthird_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.ccthird_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.hthird_party/blink/renderer/modules/webcodecs/array_buffer_util.ccthird_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.ccthird_party/blink/renderer/core/layout/shapes/shape.cc
Patch
From 135c91fcaab579a8ff3f543bcfecec07d9ad984a Mon Sep 17 00:00:00 2001 From: Andrey Kosyakov <[email protected]> Date: Fri, 18 Sep 2026 14:02:10 -0700 Subject: [PATCH] Copy ArrayBuffer contents if we're trying to transfer with a non-1 use count Transferring ArrayBuffer contents while someone else still retains it doesn't look safe, so just defer to copying it. Fixed: 499250086 Change-Id: I584edb73c85c9f62736f24bc4b782721a7d73078 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423381 Reviewed-by: Michael Lippautz <[email protected]> Reviewed-by: Tom Sepez <[email protected]> Commit-Queue: Andrey Kosyakov <[email protected]> Reviewed-by: Jeremy Roman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1701073} --- diff --git a/third_party/blink/renderer/core/layout/shapes/shape.cc b/third_party/blink/renderer/core/layout/shapes/shape.cc index 5cb07c5..aeada5b 100644 --- a/third_party/blink/renderer/core/layout/shapes/shape.cc +++ b/third_party/blink/renderer/core/layout/shapes/shape.cc @@ -281,7 +281,7 @@ if (result.DataLength() != dst_size_bytes) { return false; } - result.Transfer(contents); + contents = std::move(result); } const SkSurfaceProps disable_lcd_props; @@ -325,7 +325,7 @@ ArrayBufferContents::kZeroInitialize); if (result.DataLength() != dst_size_bytes) return false; - result.Transfer(contents); + contents = std::move(result); } // Set |surface| to draw directly to |contents|. diff --git a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.cc b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.cc index 5b551b11..afb50f2 100644 --- a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.cc +++ b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.cc @@ -153,10 +153,18 @@ backing_store_.reset(); } -void ArrayBufferContents::Transfer(ArrayBufferContents& other) { +void ArrayBufferContents::TransferOrCopy(ArrayBufferContents& other) { DCHECK(!IsShared()); DCHECK(!other.IsValid()); - other.backing_store_ = std::move(backing_store_); + // Transfer may be done with the intent of sending the bytes to a different + // thread, so play it safe by reverting to copying contents if the use + // count indicates we're not the only user. + if (backing_store_.use_count() != 1) { + CopyTo(other); + backing_store_.reset(); + } else { + other.backing_store_ = std::move(backing_store_); + } } void ArrayBufferContents::ShareWith(ArrayBufferContents& other) { @@ -174,10 +182,18 @@ } void ArrayBufferContents::CopyTo(ArrayBufferContents& other) { - other = ArrayBufferContents( - DataLength(), 1, IsShared() ? kShared : kNotShared, kDontInitialize); - if (!IsValid() || !other.IsValid()) + if (!IsValid()) { + other = ArrayBufferContents(); return; + } + const std::optional<size_t> max_num_elements = + backing_store_->IsResizableByUserJavaScript() + ? std::make_optional(backing_store_->MaxByteLength()) + : std::nullopt; + other = ArrayBufferContents( + DataLength(), max_num_elements, 1, IsShared() ? kShared : kNotShared, + kDontInitialize, ArrayBufferContents::AllocationFailureBehavior::kCrash); + CHECK(other.IsValid()); other.ByteSpan().copy_from(ByteSpan()); } diff --git a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h index ee69e63..22732737 100644 --- a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h +++ b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h @@ -156,7 +156,7 @@ return backing_store_; } - void Transfer(ArrayBufferContents& other); + void TransferOrCopy(ArrayBufferContents& other); void ShareWith(ArrayBufferContents& other); void ShareNonSharedForInternalUse(ArrayBufferContents& other); void CopyTo(ArrayBufferContents& other); diff --git a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer.cc b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer.cc index 2374906..8c694de 100644 --- a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer.cc +++ b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer.cc @@ -157,16 +157,12 @@ return v8::Just(false); } - if (!Content()->IsValid()) { - // We transfer an empty ArrayBuffer, we can just allocate an empty content. - result = ArrayBufferContents(Content()->BackingStore()); - } else { - Content()->Transfer(result); - } - v8::HandleScope handle_scope(isolate); v8::LocalVector<v8::ArrayBuffer> buffer_handles(isolate); + // First detach all the JS wrappers, so we (likely) would be retaining the + // only reference to the backing store. ArrayBufferContents needs to know + // that to avoid an unnecessary copy. bool first = true; bool failed = false; ForArrayBuffersInAllWorlds( @@ -195,6 +191,16 @@ return v8::Nothing<bool>(); } + if (!Content()->IsValid()) { + // We transfer an empty ArrayBuffer, we can just allocate an empty content. + result = ArrayBufferContents( + 0, 1, ArrayBufferContents::kNotShared, + ArrayBufferContents::kDontInitialize, + ArrayBufferContents::AllocationFailureBehavior::kCrash); + } else { + Content()->TransferOrCopy(result); + } + Detach(); return v8::Just(true); } diff --git a/third_party/blink/renderer/modules/webcodecs/array_buffer_util.cc b/third_party/blink/renderer/modules/webcodecs/array_buffer_util.cc index 4b93f4a..63b3312 100644 --- a/third_party/blink/renderer/modules/webcodecs/array_buffer_util.cc +++ b/third_party/blink/renderer/modules/webcodecs/array_buffer_util.cc @@ -85,7 +85,7 @@ if (!result.IsValid()) { // We haven't found a matching arraybuffer yet, and this one meets // all the criteria. It is our result. - contents.Transfer(result); + result = std::move(contents); } } return result; diff --git a/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc b/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc index 279a04c..838c685 100644 --- a/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc +++ b/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc @@ -113,7 +113,7 @@ auto backing_store = v8_buffer->GetBackingStore(); auto* originalContentsData = static_cast<uint8_t*>(backing_store->Data()); { - ArrayBufferContents buffer_contents(backing_store); + ArrayBufferContents buffer_contents(std::move(backing_store)); uint8_t i = 0; std::ranges::generate(buffer_contents.ByteSpan(), [&i]() { return i++; }); } diff --git a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc index 6e4e03b2..f8915497 100644 --- a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc +++ b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc @@ -5,6 +5,8 @@ #include "third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h" #include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h" +#include "third_party/blink/renderer/platform/testing/task_environment.h" namespace blink { @@ -33,4 +35,28 @@ ASSERT_EQ(content2.Data(), nullptr); } +TEST_F(ArrayBufferContentsTest, CopyPreservesResizability) { + test::TaskEnvironment task_environment; + V8TestingScope v8_scope; + + ArrayBufferContents src(16, /*max_num_elements=*/64, 1, + ArrayBufferContents::kNotShared, + ArrayBufferContents::kDontInitialize); + ASSERT_TRUE(src.IsValid()); + ASSERT_TRUE(src.IsResizableByUserJavaScript()); + ASSERT_EQ(src.DataLength(), 16u); + ASSERT_EQ(src.MaxDataLength(), 64u); + static_cast<uint32_t*>(src.Data())[0] = 0x12345678u; + + ArrayBufferContents dst; + src.CopyTo(dst); + + ASSERT_TRUE(dst.IsValid()); + EXPECT_NE(src.Data(), dst.Data()); + EXPECT_TRUE(dst.IsResizableByUserJavaScript()); + EXPECT_EQ(dst.DataLength(), 16u); + EXPECT_EQ(dst.MaxDataLength(), 64u); + EXPECT_EQ(static_cast<const uint32_t*>(dst.Data())[0], 0x12345678u); +} + } // namespace blink diff --git a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc index 793cf99..a4623f3 100644 --- a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc +++ b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc @@ -7,23 +7,54 @@ #include "gin/array_buffer.h" #include "testing/gtest/include/gtest/gtest.h" #include "third_party/blink/public/platform/scheduler/test/renderer_scheduler_test_support.h" +#include "third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h" +#include "third_party/blink/renderer/bindings/core/v8/to_v8_traits.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h" #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h" +#include "third_party/blink/renderer/core/frame/local_frame.h" +#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h" #include "third_party/blink/renderer/platform/testing/task_environment.h" #include "v8/include/v8.h" namespace blink { +namespace { + +ScriptState* IsolatedWorldScriptState(V8TestingScope& v8_scope, + int32_t world_id) { + DOMWrapperWorld* world = + DOMWrapperWorld::EnsureIsolatedWorld(v8_scope.GetIsolate(), world_id); + v8_scope.GetFrame().GetWindowProxy(*world); + return ToScriptState(&v8_scope.GetFrame(), *world); +} + +v8::MaybeLocal<v8::Value> CallTransfer(ScriptState* script_state, + v8::Local<v8::ArrayBuffer> buffer) { + v8::Local<v8::Context> context = script_state->GetContext(); + v8::Local<v8::Value> transfer = + buffer->Get(context, V8String(script_state->GetIsolate(), "transfer")) + .ToLocalChecked(); + return transfer.As<v8::Function>()->Call(context, buffer, 0, nullptr); +} + +} // namespace + TEST(DOMArrayBufferTest, TransferredArrayBufferIsDetached) { test::TaskEnvironment task_environment; V8TestingScope v8_scope; ArrayBufferContents src(10, 4, ArrayBufferContents::kNotShared, ArrayBufferContents::kZeroInitialize); - auto* buffer = DOMArrayBuffer::Create(src); + void* original_data = src.Data(); + auto* buffer = DOMArrayBuffer::Create(std::move(src)); ArrayBufferContents dst; ASSERT_TRUE(buffer->Transfer(v8_scope.GetIsolate(), dst, v8_scope.GetExceptionState())); ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); ASSERT_EQ(true, buffer->IsDetached()); + EXPECT_EQ(nullptr, buffer->Data()); + EXPECT_EQ(0u, buffer->ByteLength()); + EXPECT_TRUE(dst.IsValid()); + EXPECT_EQ(original_data, dst.Data()); } TEST(DOMArrayBufferTest, TransferredEmptyArrayBufferIsDetached) { @@ -36,6 +67,7 @@ v8_scope.GetExceptionState())); ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); ASSERT_EQ(true, buffer->IsDetached()); + EXPECT_TRUE(dst.IsValid()); } TEST(DOMArrayBufferTest, WrapEmpty) { @@ -47,4 +79,139 @@ ASSERT_FALSE(wrapped.IsEmpty()); } +TEST(DOMArrayBufferTest, TransferCopiesWhenBackingStoreHasMultipleRefs) { + test::TaskEnvironment task_environment; + V8TestingScope v8_scope; + ArrayBufferContents src(4, 4, ArrayBufferContents::kNotShared, + ArrayBufferContents::kZeroInitialize); + static_cast<uint32_t*>(src.Data())[0] = 0x12345678u; + void* original_data = src.Data(); + // Keep `src` alive so `buffer` does not have unique ownership (use_count > + // 1). + auto* buffer = DOMArrayBuffer::Create(src); + ArrayBufferContents dst; + ASSERT_TRUE(buffer->Transfer(v8_scope.GetIsolate(), dst, + v8_scope.GetExceptionState())); + ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); + EXPECT_TRUE(buffer->IsDetached()); + EXPECT_EQ(nullptr, buffer->Data()); + EXPECT_EQ(0u, buffer->ByteLength()); + EXPECT_TRUE(dst.IsValid()); + EXPECT_NE(original_data, dst.Data()); + EXPECT_EQ(0x12345678u, static_cast<const uint32_t*>(dst.Data())[0]); +} + +TEST(DOMArrayBufferTest, TransferAfterJSTransferCopiesContents) { + test::TaskEnvironment task_environment; + V8TestingScope v8_scope; + v8::Isolate* isolate = v8_scope.GetIsolate(); + ScriptState* script_state = v8_scope.GetScriptState(); + + auto* buffer_a = DOMArrayBuffer::Create(4, 4); + static_cast<uint32_t*>(buffer_a->Data())[0] = 0x11111111u; + + v8::Local<v8::ArrayBuffer> wrapper_a = + ToV8Traits<DOMArrayBuffer>::ToV8(script_state, buffer_a) + .As<v8::ArrayBuffer>(); + ASSERT_EQ(buffer_a->Data(), wrapper_a->Data()); + + // Calling JS ArrayBuffer.prototype.transfer() detaches `wrapper_a` and + // returns a new JS ArrayBuffer over the same BackingStore, while `buffer_a` + // on the native side still holds a reference to that BackingStore. + v8::Local<v8::Value> transferred_val; + ASSERT_TRUE(CallTransfer(script_state, wrapper_a).ToLocal(&transferred_val)); + ASSERT_TRUE(wrapper_a->WasDetached()); + v8::Local<v8::ArrayBuffer> wrapper_b = transferred_val.As<v8::ArrayBuffer>(); + ASSERT_EQ(buffer_a->Data(), wrapper_b->Data()); + + // Materialize a new DOMArrayBuffer for `wrapper_b` and transfer it. + DOMArrayBuffer* buffer_b = NativeValueTraits<DOMArrayBuffer>::NativeValue( + isolate, wrapper_b, v8_scope.GetExceptionState()); + ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); + ASSERT_TRUE(buffer_b); + ASSERT_NE(buffer_a, buffer_b); + + ArrayBufferContents dst; + ASSERT_TRUE(buffer_b->Transfer(isolate, dst, v8_scope.GetExceptionState())); + ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); + EXPECT_TRUE(buffer_b->IsDetached()); + EXPECT_TRUE(wrapper_b->WasDetached()); + EXPECT_EQ(nullptr, buffer_b->Data()); + EXPECT_EQ(0u, buffer_b->ByteLength()); + + // Because `buffer_a` still held a reference to the original BackingStore, + // TransferOrCopy() must have copied the contents into a fresh BackingStore. + EXPECT_TRUE(dst.IsValid()); + // Note the below generally does not have to hold true if we change the + // implementation to also detach `buffer_a` when the wrapper is detached. + EXPECT_NE(buffer_a->Data(), dst.Data()); + EXPECT_EQ(0x11111111u, static_cast<const uint32_t*>(dst.Data())[0]); +} + +TEST(DOMArrayBufferTest, + TransferAfterJSTransferWithIsolatedWorldWrapperDoesNotAlias) { + test::TaskEnvironment task_environment; + V8TestingScope v8_scope; + v8::Isolate* isolate = v8_scope.GetIsolate(); + ScriptState* main_state = v8_scope.GetScriptState(); + static const int kIsolatedWorldId = 1; + ScriptState* isolated_state = + IsolatedWorldScriptState(v8_scope, kIsolatedWorldId); + ASSERT_TRUE(isolated_state); + + auto* buffer_a = DOMArrayBuffer::Create(4, 4); + static_cast<uint32_t*>(buffer_a->Data())[0] = 0x11111111u; + void* original_data = buffer_a->Data(); + + v8::Local<v8::ArrayBuffer> main_wrapper = + ToV8Traits<DOMArrayBuffer>::ToV8(main_state, buffer_a) + .As<v8::ArrayBuffer>(); + v8::Local<v8::ArrayBuffer> isolated_wrapper; + { + v8::Context::Scope isolated_scope(isolated_state->GetContext()); + isolated_wrapper = + ToV8Traits<DOMArrayBuffer>::ToV8(isolated_state, buffer_a) + .As<v8::ArrayBuffer>(); + } + ASSERT_EQ(original_data, main_wrapper->Data()); + ASSERT_EQ(original_data, isolated_wrapper->Data()); + + // Step 1: JS ArrayBuffer.prototype.transfer() in the main world detaches + // `main_wrapper`, leaving `isolated_wrapper` attached to `original_data`. + v8::Local<v8::Value> transferred_val; + ASSERT_TRUE(CallTransfer(main_state, main_wrapper).ToLocal(&transferred_val)); + ASSERT_TRUE(main_wrapper->WasDetached()); + ASSERT_FALSE(isolated_wrapper->WasDetached()); + v8::Local<v8::ArrayBuffer> transferred_wrapper = + transferred_val.As<v8::ArrayBuffer>(); + ASSERT_EQ(original_data, transferred_wrapper->Data()); + + // Step 2: Materialize a new DOMArrayBuffer for `transferred_wrapper` (as + // postMessage transfer does) and transfer it to `dst`. + DOMArrayBuffer* buffer_b = NativeValueTraits<DOMArrayBuffer>::NativeValue( + isolate, transferred_wrapper, v8_scope.GetExceptionState()); + ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); + ASSERT_TRUE(buffer_b); + + ArrayBufferContents dst; + ASSERT_TRUE(buffer_b->Transfer(isolate, dst, v8_scope.GetExceptionState())); + ASSERT_FALSE(v8_scope.GetExceptionState().HadException()); + EXPECT_TRUE(buffer_b->IsDetached()); + EXPECT_TRUE(transferred_wrapper->WasDetached()); + + // Step 3: Verify `dst` does not alias `isolated_wrapper`'s memory. ... (file diff truncated)
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc b/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc
index 279a04c..838c685 100644
--- a/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc
+++ b/third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc
@@ -113,7 +113,7 @@
auto backing_store = v8_buffer->GetBackingStore();
auto* originalContentsData = static_cast<uint8_t*>(backing_store->Data());
{
- ArrayBufferContents buffer_contents(backing_store);
+ ArrayBufferContents buffer_contents(std::move(backing_store));
uint8_t i = 0;
std::ranges::generate(buffer_contents.ByteSpan(), [&i]() { return i++; });
}
diff --git a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc
index 6e4e03b2..f8915497 100644
--- a/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc
+++ b/third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc
@@ -5,6 +5,8 @@
#include "third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
+#include "third_party/blink/renderer/platform/testing/task_environment.h"
namespace blink {
@@ -33,4 +35,28 @@
ASSERT_EQ(content2.Data(), nullptr);
}
+TEST_F(ArrayBufferContentsTest, CopyPreservesResizability) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope v8_scope;
+
+ ArrayBufferContents src(16, /*max_num_elements=*/64, 1,
+ ArrayBufferContents::kNotShared,
+ ArrayBufferContents::kDontInitialize);
+ ASSERT_TRUE(src.IsValid());
+ ASSERT_TRUE(src.IsResizableByUserJavaScript());
+ ASSERT_EQ(src.DataLength(), 16u);
+ ASSERT_EQ(src.MaxDataLength(), 64u);
+ static_cast<uint32_t*>(src.Data())[0] = 0x12345678u;
+
+ ArrayBufferContents dst;
+ src.CopyTo(dst);
+
+ ASSERT_TRUE(dst.IsValid());
+ EXPECT_NE(src.Data(), dst.Data());
+ EXPECT_TRUE(dst.IsResizableByUserJavaScript());
+ EXPECT_EQ(dst.DataLength(), 16u);
+ EXPECT_EQ(dst.MaxDataLength(), 64u);
+ EXPECT_EQ(static_cast<const uint32_t*>(dst.Data())[0], 0x12345678u);
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc
index 793cf99..a4623f3 100644
--- a/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc
+++ b/third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc
@@ -7,23 +7,54 @@
#include "gin/array_buffer.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/platform/scheduler/test/renderer_scheduler_test_support.h"
+#include "third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h"
+#include "third_party/blink/renderer/bindings/core/v8/to_v8_traits.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
+#include "third_party/blink/renderer/core/frame/local_frame.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "v8/include/v8.h"
namespace blink {
+namespace {
+
+ScriptState* IsolatedWorldScriptState(V8TestingScope& v8_scope,
+ int32_t world_id) {
+ DOMWrapperWorld* world =
+ DOMWrapperWorld::EnsureIsolatedWorld(v8_scope.GetIsolate(), world_id);
+ v8_scope.GetFrame().GetWindowProxy(*world);
+ return ToScriptState(&v8_scope.GetFrame(), *world);
+}
+
+v8::MaybeLocal<v8::Value> CallTransfer(ScriptState* script_state,
+ v8::Local<v8::ArrayBuffer> buffer) {
+ v8::Local<v8::Context> context = script_state->GetContext();
+ v8::Local<v8::Value> transfer =
+ buffer->Get(context, V8String(script_state->GetIsolate(), "transfer"))
+ .ToLocalChecked();
+ return transfer.As<v8::Function>()->Call(context, buffer, 0, nullptr);
+}
+
+} // namespace
+
TEST(DOMArrayBufferTest, TransferredArrayBufferIsDetached) {
test::TaskEnvironment task_environment;
V8TestingScope v8_scope;
ArrayBufferContents src(10, 4, ArrayBufferContents::kNotShared,
ArrayBufferContents::kZeroInitialize);
- auto* buffer = DOMArrayBuffer::Create(src);
+ void* original_data = src.Data();
+ auto* buffer = DOMArrayBuffer::Create(std::move(src));
ArrayBufferContents dst;
ASSERT_TRUE(buffer->Transfer(v8_scope.GetIsolate(), dst,
v8_scope.GetExceptionState()));
ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
ASSERT_EQ(true, buffer->IsDetached());
+ EXPECT_EQ(nullptr, buffer->Data());
+ EXPECT_EQ(0u, buffer->ByteLength());
+ EXPECT_TRUE(dst.IsValid());
+ EXPECT_EQ(original_data, dst.Data());
}
TEST(DOMArrayBufferTest, TransferredEmptyArrayBufferIsDetached) {
@@ -36,6 +67,7 @@
v8_scope.GetExceptionState()));
ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
ASSERT_EQ(true, buffer->IsDetached());
+ EXPECT_TRUE(dst.IsValid());
}
TEST(DOMArrayBufferTest, WrapEmpty) {
@@ -47,4 +79,139 @@
ASSERT_FALSE(wrapped.IsEmpty());
}
+TEST(DOMArrayBufferTest, TransferCopiesWhenBackingStoreHasMultipleRefs) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope v8_scope;
+ ArrayBufferContents src(4, 4, ArrayBufferContents::kNotShared,
+ ArrayBufferContents::kZeroInitialize);
+ static_cast<uint32_t*>(src.Data())[0] = 0x12345678u;
+ void* original_data = src.Data();
+ // Keep `src` alive so `buffer` does not have unique ownership (use_count >
+ // 1).
+ auto* buffer = DOMArrayBuffer::Create(src);
+ ArrayBufferContents dst;
+ ASSERT_TRUE(buffer->Transfer(v8_scope.GetIsolate(), dst,
+ v8_scope.GetExceptionState()));
+ ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
+ EXPECT_TRUE(buffer->IsDetached());
+ EXPECT_EQ(nullptr, buffer->Data());
+ EXPECT_EQ(0u, buffer->ByteLength());
+ EXPECT_TRUE(dst.IsValid());
+ EXPECT_NE(original_data, dst.Data());
+ EXPECT_EQ(0x12345678u, static_cast<const uint32_t*>(dst.Data())[0]);
+}
+
+TEST(DOMArrayBufferTest, TransferAfterJSTransferCopiesContents) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope v8_scope;
+ v8::Isolate* isolate = v8_scope.GetIsolate();
+ ScriptState* script_state = v8_scope.GetScriptState();
+
+ auto* buffer_a = DOMArrayBuffer::Create(4, 4);
+ static_cast<uint32_t*>(buffer_a->Data())[0] = 0x11111111u;
+
+ v8::Local<v8::ArrayBuffer> wrapper_a =
+ ToV8Traits<DOMArrayBuffer>::ToV8(script_state, buffer_a)
+ .As<v8::ArrayBuffer>();
+ ASSERT_EQ(buffer_a->Data(), wrapper_a->Data());
+
+ // Calling JS ArrayBuffer.prototype.transfer() detaches `wrapper_a` and
+ // returns a new JS ArrayBuffer over the same BackingStore, while `buffer_a`
+ // on the native side still holds a reference to that BackingStore.
+ v8::Local<v8::Value> transferred_val;
+ ASSERT_TRUE(CallTransfer(script_state, wrapper_a).ToLocal(&transferred_val));
+ ASSERT_TRUE(wrapper_a->WasDetached());
+ v8::Local<v8::ArrayBuffer> wrapper_b = transferred_val.As<v8::ArrayBuffer>();
+ ASSERT_EQ(buffer_a->Data(), wrapper_b->Data());
+
+ // Materialize a new DOMArrayBuffer for `wrapper_b` and transfer it.
+ DOMArrayBuffer* buffer_b = NativeValueTraits<DOMArrayBuffer>::NativeValue(
+ isolate, wrapper_b, v8_scope.GetExceptionState());
+ ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
+ ASSERT_TRUE(buffer_b);
+ ASSERT_NE(buffer_a, buffer_b);
+
+ ArrayBufferContents dst;
+ ASSERT_TRUE(buffer_b->Transfer(isolate, dst, v8_scope.GetExceptionState()));
+ ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
+ EXPECT_TRUE(buffer_b->IsDetached());
+ EXPECT_TRUE(wrapper_b->WasDetached());
+ EXPECT_EQ(nullptr, buffer_b->Data());
+ EXPECT_EQ(0u, buffer_b->ByteLength());
+
+ // Because `buffer_a` still held a reference to the original BackingStore,
+ // TransferOrCopy() must have copied the contents into a fresh BackingStore.
+ EXPECT_TRUE(dst.IsValid());
+ // Note the below generally does not have to hold true if we change the
+ // implementation to also detach `buffer_a` when the wrapper is detached.
+ EXPECT_NE(buffer_a->Data(), dst.Data());
+ EXPECT_EQ(0x11111111u, static_cast<const uint32_t*>(dst.Data())[0]);
+}
+
+TEST(DOMArrayBufferTest,
+ TransferAfterJSTransferWithIsolatedWorldWrapperDoesNotAlias) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope v8_scope;
+ v8::Isolate* isolate = v8_scope.GetIsolate();
+ ScriptState* main_state = v8_scope.GetScriptState();
+ static const int kIsolatedWorldId = 1;
+ ScriptState* isolated_state =
+ IsolatedWorldScriptState(v8_scope, kIsolatedWorldId);
+ ASSERT_TRUE(isolated_state);
+
+ auto* buffer_a = DOMArrayBuffer::Create(4, 4);
+ static_cast<uint32_t*>(buffer_a->Data())[0] = 0x11111111u;
+ void* original_data = buffer_a->Data();
+
+ v8::Local<v8::ArrayBuffer> main_wrapper =
+ ToV8Traits<DOMArrayBuffer>::ToV8(main_state, buffer_a)
+ .As<v8::ArrayBuffer>();
+ v8::Local<v8::ArrayBuffer> isolated_wrapper;
+ {
+ v8::Context::Scope isolated_scope(isolated_state->GetContext());
+ isolated_wrapper =
+ ToV8Traits<DOMArrayBuffer>::ToV8(isolated_state, buffer_a)
+ .As<v8::ArrayBuffer>();
+ }
+ ASSERT_EQ(original_data, main_wrapper->Data());
+ ASSERT_EQ(original_data, isolated_wrapper->Data());
+
+ // Step 1: JS ArrayBuffer.prototype.transfer() in the main world detaches
+ // `main_wrapper`, leaving `isolated_wrapper` attached to `original_data`.
+ v8::Local<v8::Value> transferred_val;
+ ASSERT_TRUE(CallTransfer(main_state, main_wrapper).ToLocal(&transferred_val));
+ ASSERT_TRUE(main_wrapper->WasDetached());
+ ASSERT_FALSE(isolated_wrapper->WasDetached());
+ v8::Local<v8::ArrayBuffer> transferred_wrapper =
+ transferred_val.As<v8::ArrayBuffer>();
+ ASSERT_EQ(original_data, transferred_wrapper->Data());
+
+ // Step 2: Materialize a new DOMArrayBuffer for `transferred_wrapper` (as
+ // postMessage transfer does) and transfer it to `dst`.
+ DOMArrayBuffer* buffer_b = NativeValueTraits<DOMArrayBuffer>::NativeValue(
+ isolate, transferred_wrapper, v8_scope.GetExceptionState());
+ ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
+ ASSERT_TRUE(buffer_b);
+
+ ArrayBufferContents dst;
+ ASSERT_TRUE(buffer_b->Transfer(isolate, dst, v8_scope.GetExceptionState()));
+ ASSERT_FALSE(v8_scope.GetExceptionState().HadException());
+ EXPECT_TRUE(buffer_b->IsDetached());
+ EXPECT_TRUE(transferred_wrapper->WasDetached());
+
+ // Step 3: Verify `dst` does not alias `isolated_wrapper`'s memory.
+ ASSERT_TRUE(dst.IsValid());
+ EXPECT_NE(dst.Data(), isolated_wrapper->Data());
+ EXPECT_EQ(0x11111111u, static_cast<const uint32_t*>(dst.Data())[0]);
+
+ // Writes to `dst` (e.g. on a worker thread) must not be visible in the
+ // isolated world wrapper, and vice versa.
+ static_cast<uint32_t*>(dst.Data())[0] = 0xDEADBEEFu;
+ EXPECT_EQ(0x11111111u,
+ static_cast<const uint32_t*>(isolated_wrapper->Data())[0]);
+
+ static_cast<uint32_t*>(isolated_wrapper->Data())[0] = 0xCAFEBABEu;
+ EXPECT_EQ(0xDEADBEEFu, static_cast<const uint32_t*>(dst.Data())[0]);
+}
+
} // namespace blink
Original Bug Report
Multi-world detachment bypass via ArrayBuffer.prototype.transfer()
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 without the security team.
Overview: A logic flaw exists where V8’s ArrayBuffer.prototype.transfer() detaches a JavaScript wrapper without notifying Blink. This allows isolated worlds (e.g., extensions) to retain access to a buffer after it is transferred to a worker, enabling cross-thread memory aliasing and TOCTOU attacks.
Affected files:
third_party/blink/renderer/core/typed_arrays/dom_array_buffer.ccv8/src/builtins/builtins-arraybuffer.ccv8/src/objects/js-array-buffer.ccthird_party/blink/renderer/bindings/core/v8/native_value_traits_buffer_sources.ccthird_party/blink/renderer/core/typed_arrays/dom_array_buffer_base.h
Estimated timestamp from git blame: 2025-12-10
Summary
A potential logic bug exists at the V8-Blink boundary where ArrayBuffer.prototype.transfer() detaches a JSArrayBuffer without notifying the Blink embedder. In Blink’s multi-world architecture, a DOMArrayBuffer can have multiple v8::ArrayBuffer wrappers (e.g., one for the main world and one for an isolated world like an extension), all sharing the same v8::BackingStore via a std::shared_ptr.
While Blink’s own transfer path (e.g. postMessage invoking DOMArrayBuffer::TransferDetachable) correctly detaches wrappers across all worlds, V8’s ArrayBuffer.prototype.transfer() builtin is a pure-V8 operation. When called on a non-resizable buffer, V8 reuses the backing store and detaches only the specific receiver wrapper. Wrappers in other worlds (like extension content scripts) remain attached to the original BackingStore. If the transferred buffer is subsequently sent to a worker thread, memory becomes shared across threads without the mandatory COOP/COEP headers required for SharedArrayBuffer.
Technical Details
- Incomplete Detachment: V8’s
builtins-arraybuffer.ccperforms a transfer by callingJSArrayBuffer::DetachInternalon the receiver. This only mutates the fields of that specificJSArrayBufferinstance. It does not invoke any embedder hooks to notify Blink that the underlyingDOMArrayBufferhas been logically detached. - State Divergence: Blink’s
DOMArrayBuffer(and itsArrayBufferContents) retains ownership of theBackingStoreafter a V8-initiated transfer. Any existing isolated world wrappers remain fully attached and functional. - Parallel Track Creation: The result of a V8
transfer()is a freshJSArrayBuffer. When this new buffer is passed toworker.postMessage(),ToDOMArrayBufferinnative_value_traits_buffer_sources.cccreates a new C++DOMArrayBufferinstance to represent it, as it has no existing Blink association. - Transfer Blindspot: When Blink finalizes the
postMessagetransfer, it callsDOMArrayBuffer::TransferDetachableon this newly createdDOMArrayBuffer. This function usesForArrayBuffersInAllWorldsto detach all associated V8 wrappers. However, it only knows about the freshly created V8 wrapper. It is completely unaware of the originalDOMArrayBufferand the isolated world wrapper, which are left silently attached to the sameBackingStorenow possessed by the worker.
Impact
- TOCTOU Vulnerabilities: An attacker page can use a worker to asynchronously modify a buffer while a benign extension in an isolated world is concurrently validating or processing that same buffer.
- Spectre/Side-Channel Attacks: Enables the construction of shared-memory timers across threads, bypassing the restrictions intended by Cross-Origin Opener Policy (COOP) and Cross-Origin Embedder Policy (COEP).
- Renderer Invariant Violation: The condition of having multiple non-detached wrappers for the same
BackingStoreviolates intended memory management state.
Note: These are potential impact steps based on code analysis; a working proof-of-concept has not been executed.
Potential Reproduction Steps
- Precondition: An extension content script is active on the page and reads an
ArrayBufferattribute from a dispatched event (e.g.,MediaEncryptedEvent.initData). - The main world creates an
ArrayBufferand aMediaEncryptedEventusing it. - The main world dispatches the event. The content script listener reads
e.initData. Blink creates an isolated-worldv8::ArrayBufferwrapper for the buffer, sharing theBackingStore. - The main world calls
ab.transfer(). V8 detaches the main-world wrapper but leaves the isolated-world wrapper attached to theBackingStore. - The main world transfers the result of
ab.transfer()to a worker viapostMessage. - Result: The worker thread and the main-thread isolated world now have simultaneous, non-atomic access to the same non-shared memory buffer.
Suggested Fix
V8 should notify the embedder when ArrayBuffer.prototype.transfer() detaches an ArrayBuffer so that Blink can properly update its state and detach all aliases across all DOMWrapperWorlds. Alternatively, DOMArrayBuffer needs a mechanism to detect V8-initiated detachments and actively propagate them to its other v8::ArrayBuffer wrappers, rather than relying on lazy polling.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.