Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in TextEncoding
DescriptionUse after free in TextEncoding
ComponentTextEncoding
Bug ClassUAF
Tracker484946544
Fix commitec1a6357e246 (chromium/src) +141/-48
CISA KEVNot listed
CreditedPranamya Keshkamat & Cantina.xyz
Disclosed2026-03-10

Changed Functions

FunctionChangeNotes
TEST
third_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc
modified
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

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
From ec1a6357e246afedf1e57c6e050c47d12f703003 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <[email protected]>
Date: Tue, 24 Feb 2026 11:34:39 -0800
Subject: [PATCH] [bindings] Retain underlying array buffer for [PassAsSpan] arrays...

if re-entry into JS is possible while converting other params or within the function

Bug: 484946544
Change-Id: I5ca911656d37bb77b10168da6455dfc403587d82
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7595948
Reviewed-by: Nate Chapin <[email protected]>
Commit-Queue: Andrey Kosyakov <[email protected]>
Reviewed-by: Daniel Cheng <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1589594}
---

diff --git a/PRESUBMIT.py b/PRESUBMIT.py
index 31866c5..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/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/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 {
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 [email protected]

[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