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 <[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 {
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