CVE-2026-3921
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTthird_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc |
modified | |
CORE_EXPORTthird_party/blink/renderer/bindings/core/v8/pass_as_span.h |
modified | |
ByteSpanWithInlineStoragethird_party/blink/renderer/bindings/core/v8/pass_as_span.h |
modified |
Files Changed
PRESUBMIT.pythird_party/blink/renderer/bindings/core/v8/native_value_traits_impl.ccthird_party/blink/renderer/bindings/core/v8/native_value_traits_impl.hthird_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.ccthird_party/blink/renderer/bindings/core/v8/pass_as_span.h
Patch
From ec1a6357e246afedf1e57c6e050c47d12f703003 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
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 <japhet@chromium.org>
Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
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 {
Regression Test / PoC
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');
Original Bug Report
[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):
-
Save
passasspan_textdecoder_uaf.htmllocally. -
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 -
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" -
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