High patch-now CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in TextEncoding
DescriptionUse after free in TextEncoding
ComponentTextEncoding
Sub-componentBindings
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (unverified)
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker484946544
Fix commitec1a6357e246 (chromium/src) +141/-48
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedPranamya Keshkamat & Cantina.xyz
Reported2026-02-17 (per vendor advisory)
Fix landed2026-02-24 (public git)
Disclosed2026-03-10
Patch gap14 days — fix public in source before the advisory

Background

`[PassAsSpan]`
A WebIDL extended attribute that makes the Blink bindings generator hand a BufferSource/typed-array argument to C++ as a raw base::span over the JS buffer’s memory instead of copying it.
`ByteSpanWithInlineStorage` / `SpanWithInlineStorage` / `SpanOrVector`
Stack-allocated holder types in pass_as_span.h that carry the converted span; small views are copied into inline_storage_, larger ones are a non-owning pointer into the v8::BackingStore.
Detach / `ArrayBuffer.prototype.transfer`
Detaching an ArrayBuffer (e.g. buf.transfer(0)) moves or drops its v8::BackingStore; if no other std::shared_ptr<v8::BackingStore> reference exists, the backing memory is freed.
Argument-conversion re-entrancy
The generated binding converts arguments left to right, and converting a later argument (e.g. a dictionary like TextDecodeOptions) can run user JS getters before the C++ implementation is called.

Root Cause Analysis

For a [PassAsSpan] argument, NativeValueTraits<PassAsSpan<...>>::ArgumentValue (in native_value_traits_impl.h) stored only a bare base::span pointing into the ArrayBuffer’s backing store (result.Assign(bindings::internal::GetArrayData(...)) or view->GetContents(...)) and took no ownership of that memory. The bindings then went on to convert later arguments; for TextDecoder.decode(input, options) the options dictionary conversion invokes the user-defined stream getter, which can call arr.buffer.transfer(0) and detach the buffer, dropping the last reference to its v8::BackingStore and freeing it. When decode() finally ran, it read the stale span, a use-after-free on renderer heap memory. The invariant broken is that a span handed to C++ must stay valid until the callee returns, and that does not hold when JS can run between conversion and use.

The fix adds a kSupportReentry flag. When it is set, MaybeSetBackingStore() stores array_buffer->GetBackingStore() (a std::shared_ptr<v8::BackingStore>) in the holder for detachable buffers, keeping the memory alive for the life of the stack object. The copy-assignment operator also propagates backing_store_ so the reference survives the ArgumentValue return.

Key insight
The bindings handed C++ a non-owning view into a JS ArrayBuffer while still allowing JS to run (via later argument conversion) before that view was consumed, so a detach could free the memory under it. The fix pins the v8::BackingStore with a shared_ptr for the duration of the call whenever re-entry is possible.

Attack Path

  1. Prepare a large buffer Script creates a Uint8Array big enough to avoid the inline_storage_ copy path, so the converted argument is a raw pointer into the backing store (the regression test uses 10000 bytes).
  2. Call a `[PassAsSpan]` API with a re-entrant later argument Script calls TextDecoder.decode(arr, options), where options has a stream getter.
  3. Detach during conversion The bindings convert arr to a span first. Converting options then invokes the getter, which runs arr.buffer.transfer(0) and frees the original backing store.
  4. Stale span consumed TextDecoder::decode reads the dangling span and accesses freed memory. The WPT test shows that after the fix the decode still returns 10000 '*' characters.
  5. Heap reuse An attacker who reclaims the freed allocation could make the decoder read controlled or sensitive bytes (inferred). Turning this into a write primitive or code execution is not shown by the patch (inferred).

Impact Assessment

A malicious web page can trigger a use-after-free read of freed ArrayBuffer backing memory in the renderer process. This could at least leak heap contents into the decoded string and might support further memory corruption (inferred). No special preconditions are needed beyond running script. The advisory rates this High and does not say it was exploited in the wild.

Changed Functions

FunctionChangeNotes
CORE_EXPORT
third_party/blink/renderer/bindings/core/v8/pass_as_span.h
modified
ByteSpanWithInlineStorage
third_party/blink/renderer/bindings/core/v8/pass_as_span.h
modified
SpanWithInlineStorage
third_party/blink/renderer/bindings/core/v8/pass_as_span.h
modified
SpanOrVector
third_party/blink/renderer/bindings/core/v8/pass_as_span.h
modified
TEST
third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
modified

Files Changed

  • PRESUBMIT.py
  • third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.cc
  • third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h
  • third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
  • third_party/blink/renderer/bindings/core/v8/pass_as_span.h
  • third_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.py
  • third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js

Audit Directions

  • Raw spans into JS buffers across re-entrant conversions
    Look for other bindings or hand-written C++ that take a GetContents/Data() pointer from an ArrayBuffer/view and then call into JS (dictionary getters, ToString, valueOf, promise resolution) before using it. The TODO in blink_v8_bridge.py admits the “only NoAllocDirectCall is safe” heuristic is approximate.
  • `IsDetachable()` and non-pinned paths
    Check whether any buffer that reports non-detachable can still have its memory freed or resized (for example resizable ArrayBuffers shrinking, or WASM memory growth). MaybeSetBackingStore skips pinning those, and growth or shrink would invalidate the span without a detach.
  • Copy/move of span holders
    Audit similar inline-storage or span wrapper types to make sure copy/move operations carry any ownership token along with the pointer. Here a missing backing_store_ copy in operator= would have silently re-opened the bug.
diff --git a/PRESUBMIT.py b/PRESUBMIT.py
index 31866c50..4462298 100644
--- a/PRESUBMIT.py
+++ b/PRESUBMIT.py
@@ -766,6 +766,7 @@
             r'^third_party/blink/renderer/core/typed_arrays/dom_array_buffer\.cc',
             '^third_party/blink/renderer/bindings/core/v8/' +
             'v8_wasm_response_extensions.cc',
+            '^third_party/blink/renderer/bindings/core/v8/pass_as_span.h',
             r'^gin/array_buffer\.(cc|h)',
             r'^gin/per_isolate_data\.(cc|h)',
             '^chrome/services/sharing/nearby/',
diff --git a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.cc b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.cc
index 1c6e430c..973b712e 100644
--- a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.cc
+++ b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.cc
@@ -94,20 +94,4 @@
       value, JSEventHandler::HandlerType::kOnErrorEventHandler);
 }
 
-namespace bindings::internal {
-
-ByteSpanWithInlineStorage& ByteSpanWithInlineStorage::operator=(
-    const ByteSpanWithInlineStorage& r) {
-  if (r.span_.data() == r.inline_storage_) {
-    auto span = base::span(inline_storage_);
-    span.copy_from(base::span(r.inline_storage_));
-    span_ = span.first(r.span_.size());
-  } else {
-    span_ = r.span_;
-  }
-  return *this;
-}
-
-}  // namespace bindings::internal
-
 }  // namespace blink
diff --git a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h
index 61ea716..90b68b0a6 100644
--- a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h
+++ b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl.h
@@ -1649,15 +1649,16 @@
                           v8::Local<v8::Value> value,
                           ExceptionState& exception_state) = delete;
 
-  static bindings::internal::ByteSpanWithInlineStorage ArgumentValue(
-      v8::Isolate* isolate,
-      int argument_index,
-      v8::Local<v8::Value> value,
-      ExceptionState& exception_state) {
-    bindings::internal::ByteSpanWithInlineStorage result;
+  static bindings::internal::ByteSpanWithInlineStorage<T::support_reentry>
+  ArgumentValue(v8::Isolate* isolate,
+                int argument_index,
+                v8::Local<v8::Value> value,
+                ExceptionState& exception_state) {
+    bindings::internal::ByteSpanWithInlineStorage<T::support_reentry> result;
     if (value->IsArrayBuffer()) {
-      result.Assign(
-          bindings::internal::GetArrayData(value.As<v8::ArrayBuffer>()));
+      v8::Local<v8::ArrayBuffer> array_buffer = value.As<v8::ArrayBuffer>();
+      result.MaybeSetBackingStore(array_buffer);
+      result.Assign(bindings::internal::GetArrayData(array_buffer));
       return result;
     }
     if (T::allow_shared && value->IsSharedArrayBuffer()) {
@@ -1667,11 +1668,14 @@
     }
     if (value->IsArrayBufferView()) {
       v8::Local<v8::ArrayBufferView> view = value.As<v8::ArrayBufferView>();
-      if (!T::allow_shared && view->HasBuffer() &&
-          view->Buffer()->GetBackingStore()->IsShared()) [[unlikely]] {
-        exception_state.ThrowTypeError(
-            "The provided ArrayBufferView value must not be shared.");
-        return result;
+      if (view->HasBuffer()) {
+        if (!T::allow_shared && view->Buffer()->GetBackingStore()->IsShared())
+            [[unlikely]] {
+          exception_state.ThrowTypeError(
+              "The provided ArrayBufferView value must not be shared.");
+          return result;
+        }
+        result.MaybeSetBackingStore(view->Buffer());
       }
       result.Assign(view->GetContents(result.GetInlineStorage()));
       return result;
@@ -1699,11 +1703,14 @@
     using Traits = bindings::internal::TypedArrayElementTraits<ElementType>;
     if (Traits::IsViewOfType(value)) [[likely]] {
       v8::Local<v8::ArrayBufferView> view = value.As<v8::ArrayBufferView>();
-      if (!T::allow_shared && view->HasBuffer() &&
-          view->Buffer()->GetBackingStore()->IsShared()) [[unlikely]] {
-        exception_state.ThrowTypeError(
-            "The provided ArrayBufferView value must not be shared.");
-        return result;
+      if (view->HasBuffer()) {
+        if (!T::allow_shared && view->Buffer()->GetBackingStore()->IsShared())
+            [[unlikely]] {
+          exception_state.ThrowTypeError(
+              "The provided ArrayBufferView value must not be shared.");
+          return result;
+        }
+        result.MaybeSetBackingStore(view->Buffer());
       }
       result.Assign(view->GetContents(result.GetInlineStorage()));
       return result;
diff --git a/third_party/blink/renderer/bindings/core/v8/pass_as_span.h b/third_party/blink/renderer/bindings/core/v8/pass_as_span.h
index f135303..17ea6a35e 100644
--- a/third_party/blink/renderer/bindings/core/v8/pass_as_span.h
+++ b/third_party/blink/renderer/bindings/core/v8/pass_as_span.h
@@ -15,6 +15,7 @@
 
 namespace bindings::internal {
 
+template <bool kSupportReentry>
 class CORE_EXPORT ByteSpanWithInlineStorage {
   STACK_ALLOCATED();
 
@@ -24,10 +25,28 @@
   ByteSpanWithInlineStorage() = default;
   ByteSpanWithInlineStorage(const ByteSpanWithInlineStorage& r) { *this = r; }
 
-  ByteSpanWithInlineStorage& operator=(const ByteSpanWithInlineStorage& r);
+  ByteSpanWithInlineStorage& operator=(const ByteSpanWithInlineStorage& r) {
+    if (r.span_.data() == r.inline_storage_) {
+      auto span = base::span(inline_storage_);
+      span.copy_from(base::span(r.inline_storage_));
+      span_ = span.first(r.span_.size());
+    } else {
+      span_ = r.span_;
+      backing_store_ = r.backing_store_;
+    }
+    return *this;
+  }
 
   void Assign(base::span<const uint8_t> span) { span_ = span; }
   void Assign(v8::MemorySpan<const uint8_t> span) { span_ = span; }
+  void MaybeSetBackingStore(v8::Local<v8::ArrayBuffer> array_buffer) {
+    if constexpr (kSupportReentry) {
+      if (array_buffer->IsDetachable()) {
+        backing_store_ = array_buffer->GetBackingStore();
+      }
+    }
+  }
+
   // This class allows implicit conversion to span, because it's an internal
   // class tightly coupled to the bindings generator that knows how to use it.
   // Note rvalue conversion is explicitly disabled.
@@ -41,6 +60,9 @@
  private:
   base::span<const uint8_t> span_;
   uint8_t inline_storage_[kInlineStorageSize];
+  struct Void {};
+  std::conditional_t<kSupportReentry, std::shared_ptr<v8::BackingStore>, Void>
+      backing_store_;
 };
 
 template <typename T>
@@ -50,7 +72,7 @@
       static_cast<const uint8_t*>(array->Data()), array->ByteLength());
 }
 
-template <typename T>
+template <typename T, bool kSupportReentry>
 class SpanWithInlineStorage {
   STACK_ALLOCATED();
 
@@ -68,16 +90,19 @@
                                      bytes.size() / sizeof(T)));
   }
 
+  void MaybeSetBackingStore(v8::Local<v8::ArrayBuffer> array_buffer) {
+    bytes_.MaybeSetBackingStore(array_buffer);
+  }
   void Assign(base::span<const uint8_t> span) { bytes_.Assign(span); }
   v8::MemorySpan<uint8_t> GetInlineStorage() {
     return bytes_.GetInlineStorage();
   }
 
  private:
-  ByteSpanWithInlineStorage bytes_;
+  ByteSpanWithInlineStorage<kSupportReentry> bytes_;
 };
 
-template <typename T>
+template <typename T, bool kSupportReentry>
 class SpanOrVector {
   STACK_ALLOCATED();
 
@@ -89,6 +114,9 @@
   operator base::span<const T>() const&& = delete;
   const base::span<const T> as_span() const { return span_.as_span(); }
 
+  void MaybeSetBackingStore(v8::Local<v8::ArrayBuffer> array_buffer) {
+    span_.MaybeSetBackingStore(array_buffer);
+  }
   void Assign(base::span<const uint8_t> span) { span_.Assign(span); }
   void Assign(Vector<T> vec) {
     vector_ = std::move(vec);
@@ -107,7 +135,7 @@
   }
 
  private:
-  SpanWithInlineStorage<T> span_;
+  SpanWithInlineStorage<T, kSupportReentry> span_;
   Vector<T> vector_;
 };
 
@@ -167,6 +195,7 @@
     kNone,
     kAllowShared = 1 << 0,
     kAllowSequence = 1 << 1,
+    kSupportReentry = 1 << 2,
   };
 };
 
@@ -182,6 +211,8 @@
 struct PassAsSpan : public PassAsSpanMarkerBase {
   static constexpr bool allow_shared = flags & Flags::kAllowShared;
   static constexpr bool allow_sequence = flags & Flags::kAllowSequence;
+  static constexpr bool support_reentry = flags & Flags::kSupportReentry;
+
   static constexpr bool is_typed = !std::is_same_v<T, void>;
 
   static_assert(is_typed || !allow_sequence);
@@ -189,10 +220,11 @@
   using ElementType = T;
   using ReturnType = std::conditional_t<
       allow_sequence,
-      bindings::internal::SpanOrVector<T>,
-      std::conditional_t<is_typed,
-                         bindings::internal::SpanWithInlineStorage<T>,
-                         bindings::internal::ByteSpanWithInlineStorage>>;
+      bindings::internal::SpanOrVector<T, support_reentry>,
+      std::conditional_t<
+          is_typed,
+          bindings::internal::SpanWithInlineStorage<T, support_reentry>,
+          bindings::internal::ByteSpanWithInlineStorage<support_reentry>>>;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.py b/third_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.py
index 78817a2e..f1d14c8ce 100644
--- a/third_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.py
+++ b/third_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.py
@@ -437,12 +437,13 @@
             and not (idl_type.is_nullable or argument.default_value)
             and (apply_optional_to_last_arg
                  or argument != argument.owner.arguments[-1])):
-        return "IDLOptional<{}>".format(_native_value_tag_impl(idl_type))
+        return "IDLOptional<{}>".format(
+            _native_value_tag_impl(idl_type, argument))
 
-    return _native_value_tag_impl(idl_type)
+    return _native_value_tag_impl(idl_type, argument)
 
 
-def _pass_as_span_conversion_arguments(idl_type):
+def _pass_as_span_conversion_arguments(idl_type, support_reentry):
     real_type = idl_type.unwrap(typedef=True)
     types = real_type.flattened_member_types if real_type.is_union else [
         real_type
@@ -480,15 +481,18 @@
         "AllowShared" in t.effective_annotations for t in types)
     if allow_shared:
         flags.append("PassAsSpanMarkerBase::Flags::kAllowShared")
+    if support_reentry:
+        flags.append("PassAsSpanMarkerBase::Flags::kSupportReentry")
 
     return [
         " | ".join(flags) or "PassAsSpanMarkerBase::Flags::kNone", native_type
     ]
 
 
-def _native_value_tag_impl(idl_type):
+def _native_value_tag_impl(idl_type, argument=None):
     """Returns the tag type of NativeValueTraits."""
     assert isinstance(idl_type, web_idl.IdlType)
+    assert argument is None or isinstance(argument, web_idl.Argument)
 
     if idl_type.is_event_handler:
         return "IDL{}".format(idl_type.identifier)
@@ -496,7 +500,12 @@
     real_type = idl_type.unwrap(typedef=True)
 
     if "PassAsSpan" in idl_type.effective_annotations:
-        conversion_arguments = _pass_as_span_conversion_arguments(idl_type)
+        assert argument, "PassAsSpan can only appear on an argument"
+        # TODO(caseq): This works for now. Refine this to check no args
+        # involving JS calls (strings, dicts) is passed after this one.
+        support_reentry = "NoAllocDirectCall" not in argument.owner.extended_attributes
+        conversion_arguments = _pass_as_span_conversion_arguments(
+            idl_type, support_reentry)
         return "PassAsSpan<{}>".format(", ".join(conversion_arguments))
 
     if (real_type.is_boolean or real_type.is_numeric or real_type.is_string
diff --git a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
index 366b7d0f..30ef8f65 100644
--- a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
+++ b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
@@ -754,5 +754,50 @@
                            std::numeric_limits<double>::infinity(), 42));
 }
 
+using PassAsSpanWithReentry =
+    PassAsSpan<PassAsSpanMarkerBase::Flags::kSupportReentry, void>;
+
+template <typename T>
+using TypedPassAsSpanWithReentry =
+    PassAsSpan<PassAsSpanMarkerBase::Flags::kSupportReentry, T>;
+
+TEST(NativeValueTraitsImplTest, TypedPassAsSpanDetach) {
+  test::TaskEnvironment task_environment;
+  V8TestingScope scope;
+  NonThrowableExceptionState exception_state;
+
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arrbuf = new Uint8Array(10000).fill(42).buffer;
+    )");
+    auto converted = NativeValueTraits<PassAsSpanWithReentry>::ArgumentValue(
+        scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arrbuf.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arr1 = new Uint8Array(10000).fill(42);
+    )");
+    auto converted = NativeValueTraits<PassAsSpanWithReentry>::ArgumentValue(
+        scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arr1.buffer.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arr2 = new Uint16Array(10000).fill(42);
+    )");
+    auto converted =
+        NativeValueTraits<TypedPassAsSpanWithReentry<uint16_t>>::ArgumentValue(
+            scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arr2.buffer.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+}
+
 }  // namespace
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js b/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
index 2d137b7a..74a52fa 100644
--- a/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
+++ b/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
@@ -48,3 +48,18 @@
     'Undefined as first arg should flush the stream');
 
 }, 'TextDecoder decode() with undefined and options');
+
+test(t => {
+  const decoder = new TextDecoder();
+
+  const arr = new Uint8Array(10000).fill(42);
+  const options = {
+    get stream() {
+      arr.buffer.transfer(0);
+      return false;
+    }
+  };
+  assert_equals(
+    decoder.decode(arr, options), Array(10000 + 1).join('*'),
+    'Decoding should work with underlying array buffer detached during options conversion');
+}, 'TextDecoder decode() with array buffer detached during arg conversion');
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
index 366b7d0f..30ef8f65 100644
--- a/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
+++ b/third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
@@ -754,5 +754,50 @@
                            std::numeric_limits<double>::infinity(), 42));
 }
 
+using PassAsSpanWithReentry =
+    PassAsSpan<PassAsSpanMarkerBase::Flags::kSupportReentry, void>;
+
+template <typename T>
+using TypedPassAsSpanWithReentry =
+    PassAsSpan<PassAsSpanMarkerBase::Flags::kSupportReentry, T>;
+
+TEST(NativeValueTraitsImplTest, TypedPassAsSpanDetach) {
+  test::TaskEnvironment task_environment;
+  V8TestingScope scope;
+  NonThrowableExceptionState exception_state;
+
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arrbuf = new Uint8Array(10000).fill(42).buffer;
+    )");
+    auto converted = NativeValueTraits<PassAsSpanWithReentry>::ArgumentValue(
+        scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arrbuf.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arr1 = new Uint8Array(10000).fill(42);
+    )");
+    auto converted = NativeValueTraits<PassAsSpanWithReentry>::ArgumentValue(
+        scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arr1.buffer.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+  {
+    v8::Local<v8::Object> v8_object = EvaluateScriptForObject(scope, R"(
+        self.arr2 = new Uint16Array(10000).fill(42);
+    )");
+    auto converted =
+        NativeValueTraits<TypedPassAsSpanWithReentry<uint16_t>>::ArgumentValue(
+            scope.GetIsolate(), 0, v8_object, exception_state);
+
+    EvaluateScriptForObject(scope, "self.arr2.buffer.transfer(0)");
+    EXPECT_THAT(converted.as_span(), testing::Contains(42).Times(10000));
+  }
+}
+
 }  // namespace
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js b/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
index 2d137b7a..74a52fa 100644
--- a/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
+++ b/third_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
@@ -48,3 +48,18 @@
     'Undefined as first arg should flush the stream');
 
 }, 'TextDecoder decode() with undefined and options');
+
+test(t => {
+  const decoder = new TextDecoder();
+
+  const arr = new Uint8Array(10000).fill(42);
+  const options = {
+    get stream() {
+      arr.buffer.transfer(0);
+      return false;
+    }
+  };
+  assert_equals(
+    decoder.decode(arr, options), Array(10000 + 1).join('*'),
+    'Decoding should work with underlying array buffer detached during options conversion');
+}, 'TextDecoder decode() with array buffer detached during arg conversion');
Loading diff…

Original Bug Report

reported by pk...@gmail.com

[PassAsSpan] ArrayBuffer.transfer() re-entrancy UAF / SEGV in TextDecoder.decode

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS Blink’s [PassAsSpan] WebIDL fast-path can capture an unowned base::span<const uint8_t> (pointer+length) over a TypedArray / ArrayBuffer backing store. Later argument conversions (e.g. dictionary member access) can execute attacker-controlled JavaScript (getters / proxies). Calling ArrayBuffer.prototype.transfer(0) in that re-entrancy window detaches the buffer and can invalidate / unmap the backing store while native code still consumes the previously captured span.

This is reachable from web content via TextDecoder.decode(input, options) when options.stream is a getter that calls ab.transfer(0).

VERSION Chrome Version: Chromium 146.0.7670.0 (ASan build) [dev] Operating System: Ubuntu 24.04.2 LTS Repro date: 2026-02-17 04:34:41 UTC

REPRODUCTION CASE Attached file: passasspan_textdecoder_uaf.html

Repro steps (headless, no GUI required):

  1. Save passasspan_textdecoder_uaf.html locally.

  2. Run an ASan Chromium/Chrome build (example path from this environment):

    /home/ubuntu/chromium-asan/out/linux-release-1579808/chrome --headless=new --no-sandbox --remote-debugging-port=9222 about:blank
    
  3. In another shell, open the PoC in a new tab:

    curl -X PUT "http://127.0.0.1:9222/json/new?file:///absolute/path/to/passasspan_textdecoder_uaf.html"
    
  4. Observe a renderer/tab crash and a SIGSEGV stack trace printed to stderr.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION Type of crash: renderer / tab crash (SIGSEGV) Crash State:

  • Signal: Received signal 11 SEGV_ACCERR ...

  • Symbolized top frames (from this environment):

    blink::TextCodecUtf8::Decode(...) at third_party/blink/renderer/platform/wtf/text/text_codec_utf8.cc:393
    blink::TextDecoder::Decode(...) at third_party/blink/renderer/modules/encoding/text_decoder.cc:120
    blink::TextDecoder::decode(...) at third_party/blink/renderer/modules/encoding/text_decoder.cc:93
    v8_text_decoder::DecodeOperationCallback(...) at gen/third_party/blink/renderer/bindings/modules/v8/v8_text_decoder.cc:219
    

Client ID (if relevant): N/A

CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: Pranamya Keshkamat & Cantina.xyz

View on issue tracker