Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Bindings
DescriptionMemory corruption in Bindings
ComponentBindings
Sub-componentBlink>Bindings
Bug ClassMemory Corruption
Tracker499250086
Fix commit135c91fcaab5 (chromium/src) +233/-18
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc
modified
TEST
third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/typed_arrays/dom_array_buffer.cc
  • third_party/blink/renderer/core/typed_arrays/dom_array_buffer_test.cc
  • third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.cc
  • third_party/blink/renderer/core/messaging/blink_transferable_message_mojom_traits_test.cc
  • third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents.h
  • third_party/blink/renderer/modules/webcodecs/array_buffer_util.cc
  • third_party/blink/renderer/core/typed_arrays/array_buffer/array_buffer_contents_test.cc
  • third_party/blink/renderer/core/layout/shapes/shape.cc
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • v8/src/builtins/builtins-arraybuffer.cc
  • v8/src/objects/js-array-buffer.cc
  • third_party/blink/renderer/bindings/core/v8/native_value_traits_buffer_sources.cc
  • third_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

  1. Incomplete Detachment: V8’s builtins-arraybuffer.cc performs a transfer by calling JSArrayBuffer::DetachInternal on the receiver. This only mutates the fields of that specific JSArrayBuffer instance. It does not invoke any embedder hooks to notify Blink that the underlying DOMArrayBuffer has been logically detached.
  2. State Divergence: Blink’s DOMArrayBuffer (and its ArrayBufferContents) retains ownership of the BackingStore after a V8-initiated transfer. Any existing isolated world wrappers remain fully attached and functional.
  3. Parallel Track Creation: The result of a V8 transfer() is a fresh JSArrayBuffer. When this new buffer is passed to worker.postMessage(), ToDOMArrayBuffer in native_value_traits_buffer_sources.cc creates a new C++ DOMArrayBuffer instance to represent it, as it has no existing Blink association.
  4. Transfer Blindspot: When Blink finalizes the postMessage transfer, it calls DOMArrayBuffer::TransferDetachable on this newly created DOMArrayBuffer. This function uses ForArrayBuffersInAllWorlds to detach all associated V8 wrappers. However, it only knows about the freshly created V8 wrapper. It is completely unaware of the original DOMArrayBuffer and the isolated world wrapper, which are left silently attached to the same BackingStore now 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 BackingStore violates 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

  1. Precondition: An extension content script is active on the page and reads an ArrayBuffer attribute from a dispatched event (e.g., MediaEncryptedEvent.initData).
  2. The main world creates an ArrayBuffer and a MediaEncryptedEvent using it.
  3. The main world dispatches the event. The content script listener reads e.initData. Blink creates an isolated-world v8::ArrayBuffer wrapper for the buffer, sharing the BackingStore.
  4. The main world calls ab.transfer(). V8 detaches the main-world wrapper but leaves the isolated-world wrapper attached to the BackingStore.
  5. The main world transfers the result of ab.transfer() to a worker via postMessage.
  6. 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.

View on issue tracker