CVE-2026-3921
Overview
Background
- `[PassAsSpan]`
- A WebIDL extended attribute that makes the Blink bindings generator hand a
BufferSource/typed-array argument to C++ as a rawbase::spanover the JS buffer’s memory instead of copying it. - `ByteSpanWithInlineStorage` / `SpanWithInlineStorage` / `SpanOrVector`
- Stack-allocated holder types in
pass_as_span.hthat carry the converted span; small views are copied intoinline_storage_, larger ones are a non-owning pointer into thev8::BackingStore. - Detach / `ArrayBuffer.prototype.transfer`
- Detaching an
ArrayBuffer(e.g.buf.transfer(0)) moves or drops itsv8::BackingStore; if no otherstd::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.
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
- Prepare a large buffer
Script creates a
Uint8Arraybig enough to avoid theinline_storage_copy path, so the converted argument is a raw pointer into the backing store (the regression test uses 10000 bytes). - Call a `[PassAsSpan]` API with a re-entrant later argument
Script calls
TextDecoder.decode(arr, options), whereoptionshas astreamgetter. - Detach during conversion
The bindings convert
arrto a span first. Convertingoptionsthen invokes the getter, which runsarr.buffer.transfer(0)and frees the original backing store. - Stale span consumed
TextDecoder::decodereads the dangling span and accesses freed memory. The WPT test shows that after the fix the decode still returns 10000'*'characters. - 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
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
| Function | Change | Notes |
|---|---|---|
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 | |
SpanWithInlineStoragethird_party/blink/renderer/bindings/core/v8/pass_as_span.h |
modified | |
SpanOrVectorthird_party/blink/renderer/bindings/core/v8/pass_as_span.h |
modified | |
TESTthird_party/blink/renderer/bindings/core/v8/native_value_traits_impl_test.cc |
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.hthird_party/blink/renderer/bindings/scripts/bind_gen/blink_v8_bridge.pythird_party/blink/web_tests/external/wpt/encoding/textdecoder-arguments.any.js
Audit Directions
- Raw spans into JS buffers across re-entrant conversionsLook for other bindings or hand-written C++ that take a
GetContents/Data()pointer from anArrayBuffer/view and then call into JS (dictionary getters,ToString,valueOf, promise resolution) before using it. The TODO inblink_v8_bridge.pyadmits the “onlyNoAllocDirectCallis safe” heuristic is approximate. - `IsDetachable()` and non-pinned pathsCheck whether any buffer that reports non-detachable can still have its memory freed or resized (for example resizable
ArrayBuffers shrinking, or WASM memory growth).MaybeSetBackingStoreskips pinning those, and growth or shrink would invalidate the span without a detach. - Copy/move of span holdersAudit 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 inoperator=would have silently re-opened the bug.
Patch
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');
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