CVE-2025-8880
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/wasm/streaming-decoder.cc |
modified |
Files Changed
src/wasm/streaming-decoder.cc
Patch
From 1eda9300e26ec1aad95f98b6cb8e0d3ca6fb1e92 Mon Sep 17 00:00:00 2001
From: Clemens Backes <clemensb@chromium.org>
Date: Fri, 25 Jul 2025 17:03:18 +0200
Subject: [PATCH] [wasm] Harden against concurrent modification of streamed bytes
The embedder could pass a byte vector which points inside the sandbox,
hence we should make sure to only read it once to protect against
concurrent modification.
This CL feeds the decoder the bytes from the copy we did anyways instead
of using the incoming bytes.
R=jkummerow@chromium.org
Bug: 433533359
Change-Id: I0f8072fa6d75929f0f2dee7564d42778c634787f
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6787532
Reviewed-by: Jakob Kummerow <jkummerow@chromium.org>
Commit-Queue: Clemens Backes <clemensb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#101641}
---
diff --git a/src/wasm/streaming-decoder.cc b/src/wasm/streaming-decoder.cc
index 12ff803..381680b 100644
--- a/src/wasm/streaming-decoder.cc
+++ b/src/wasm/streaming-decoder.cc
@@ -230,45 +230,61 @@
};
void AsyncStreamingDecoder::OnBytesReceived(base::Vector<const uint8_t> bytes) {
- DCHECK(!full_wire_bytes_.empty());
+ TRACE_STREAMING("OnBytesReceived(%zu bytes)\n", bytes.size());
+
+ // Note: The bytes are passed by the embedder, and they might point into the
+ // sandbox. Hence we copy them once and then process those copied bytes, to
+ // avoid being vulnerable to concurrent modification.
+ // Since we might not be able to store the bytes contiguously in memory,
+ // remember up to two byte vectors to process after copying.
+ base::Vector<const uint8_t> copied_bytes[2] = {{}, {}};
+
// Fill the previous vector, growing up to 16kB. After that, allocate new
// vectors on overflow.
+ DCHECK(!full_wire_bytes_.empty());
+ std::vector<uint8_t>* last_wire_byte_vector = &full_wire_bytes_.back();
+ size_t existing_vector_size = last_wire_byte_vector->size();
size_t remaining_capacity =
- std::max(full_wire_bytes_.back().capacity(), size_t{16} * KB) -
- full_wire_bytes_.back().size();
+ std::max(last_wire_byte_vector->capacity(), size_t{16} * KB) -
+ existing_vector_size;
size_t bytes_for_existing_vector = std::min(remaining_capacity, bytes.size());
- full_wire_bytes_.back().insert(full_wire_bytes_.back().end(), bytes.data(),
- bytes.data() + bytes_for_existing_vector);
+ last_wire_byte_vector->insert(last_wire_byte_vector->end(), bytes.data(),
+ bytes.data() + bytes_for_existing_vector);
+ copied_bytes[0] =
+ base::VectorOf(last_wire_byte_vector->data() + existing_vector_size,
+ bytes_for_existing_vector);
if (bytes.size() > bytes_for_existing_vector) {
// The previous vector's capacity is not enough to hold all new bytes, and
// it's bigger than 16kB, so expensive to copy. Allocate a new vector for
// the remaining bytes, growing exponentially.
size_t new_capacity = std::max(bytes.size() - bytes_for_existing_vector,
- 2 * full_wire_bytes_.back().capacity());
+ 2 * last_wire_byte_vector->capacity());
full_wire_bytes_.emplace_back();
- full_wire_bytes_.back().reserve(new_capacity);
- full_wire_bytes_.back().insert(full_wire_bytes_.back().end(),
- bytes.data() + bytes_for_existing_vector,
- bytes.end());
+ last_wire_byte_vector = &full_wire_bytes_.back();
+ last_wire_byte_vector->reserve(new_capacity);
+ last_wire_byte_vector->insert(last_wire_byte_vector->end(),
+ bytes.data() + bytes_for_existing_vector,
+ bytes.end());
+ copied_bytes[1] = base::VectorOf(*last_wire_byte_vector);
}
+ // Do not access `bytes` any more after copying.
+ DCHECK_EQ(bytes.size(), copied_bytes[0].size() + copied_bytes[1].size());
+ bytes = {};
if (deserializing()) return;
- TRACE_STREAMING("OnBytesReceived(%zu bytes)\n", bytes.size());
-
- size_t current = 0;
- while (ok() && current < bytes.size()) {
- size_t num_bytes =
- state_->ReadBytes(this, bytes.SubVector(current, bytes.size()));
- current += num_bytes;
- module_offset_ += num_bytes;
- if (state_->offset() == state_->buffer().size()) {
- state_ = state_->Next(this);
+ for (base::Vector<const uint8_t> vec : copied_bytes) {
+ size_t current = 0;
+ while (ok() && current < vec.size()) {
+ size_t num_bytes = state_->ReadBytes(this, vec.SubVectorFrom(current));
+ current += num_bytes;
+ module_offset_ += num_bytes;
+ if (state_->offset() == state_->buffer().size()) {
+ state_ = state_->Next(this);
+ }
}
}
- if (ok()) {
- processor_->OnFinishedChunk();
- }
+ if (ok()) processor_->OnFinishedChunk();
}
size_t AsyncStreamingDecoder::DecodingState::ReadBytes(
Original Bug Report
Consumers of ReadableStream subject to data race with SharedArrayBuffer, leading to RCE + V8 Sandbox bypass
VULNERABILITY DETAILS
Summary
Consumers of ReadableStream may not be aware of concurrent mutation of the supplied chunk when interpreting it as a ArrayBuffer / TypedArray. For example, ReadableStreamBytesConsumer takes any Uint8Array chunk and directly uses its backing store. This potentially leads to any of its consumers operating on concurrently mutated buffers when backed by SharedArrayBuffer, leading to data races deep down the end users of the buffer.
This primitive ultimately results in RCE + V8 Sandbox bypass directly via one of its consumers, blink::FetchDataLoaderForWasmStreaming -> wasm::FetchDataLoaderForWasmStreaming, by causing inconsistencies with wire bytes vs. section payload.
Note that this also implies that there may be more:
- Stream consumers that return buffers subject to data races
- Exploitable (racy) end users of the stream consumers
- V8 Sandbox bypasses in non-SAB backed buffers using other “BYOB” zero-copy transferred buffers, e.g. through
ReadableByteStreamControllerproducer.
> Disclaimer: I am currently unsure whether this should be considered a bug within the stream controller (i.e. producer), the reader (consumer), or the end user of the buffer. I am leaning towards either of the latter two as the stream controller may enqueue anything, and it is up to the reader to do any checks and keep a backing copy if necessary.
Details
Consumers of ReadableStream may not be aware of concurrent mutation of the supplied chunk when interpreting it as a ArrayBuffer / TypedArray. For example, ReadableStreamBytesConsumer takes any Uint8Array chunk and directly uses its backing store.
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;l=27
class ReadableStreamBytesConsumer::BytesConsumerReadRequest final
: public ReadRequest {
public:
explicit BytesConsumerReadRequest(ReadableStreamBytesConsumer* consumer)
: consumer_(consumer) {}
void ChunkSteps(ScriptState* script_state,
v8::Local<v8::Value> chunk,
ExceptionState& exception_state) const override {
if (!chunk->IsUint8Array()) { // [!] SAB-backed Uint8Array allowed
consumer_->OnRejected();
return;
}
ScriptState::Scope scope(script_state);
consumer_->OnRead(
NativeValueTraits<MaybeShared<DOMUint8Array>>::NativeValue(
script_state->GetIsolate(), chunk, exception_state)
.Get());
DCHECK(!exception_state.HadException());
}
// ...
}
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;drc=7df382b48e30a3d6193403397c5efe24e4341cd9;l=179
void ReadableStreamBytesConsumer::OnRead(DOMUint8Array* buffer) {
// ...
is_reading_ = false;
if (state_ == PublicState::kClosed)
return;
DCHECK_EQ(state_, PublicState::kReadableOrWaiting);
pending_buffer_ = buffer; // [!] buffer directly stored as pending_buffer_
if (client_)
client_->OnStateChange();
}
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;drc=7df382b48e30a3d6193403397c5efe24e4341cd9;l=79
BytesConsumer::Result ReadableStreamBytesConsumer::BeginRead(
base::span<const char>& buffer) {
buffer = {};
if (state_ == PublicState::kErrored)
return Result::kError;
if (state_ == PublicState::kClosed)
return Result::kDone;
if (pending_buffer_) {
// The UInt8Array has become detached due to, for example, the site
// transferring it away via postMessage(). Since we were in the middle
// of reading the array we must error out.
if (pending_buffer_->IsDetached()) {
SetErrored();
return Result::kError;
}
DCHECK_LE(pending_offset_, pending_buffer_->length());
buffer =
base::as_chars(pending_buffer_->ByteSpan().subspan(pending_offset_)); // [!] slice taken directly from pending_buffer_
return Result::kOk;
}
if (!is_reading_) {
// ...
}
return Result::kShouldWait;
}
Thus, the buffer returned by ReadableStreamBytesConsumer is subject to data races if 1. backed by SAB, or 2. the V8 Sandbox is already compromised. However, some consumers do not consider this and directly operate on the buffer.
Take for example FetchDataLoaderForWasmStreaming, which drives AsyncStreamingDecoder. This runs on Wasm async streaming compilation (WebAssembly.{compile,instantiate}Streaming()):
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/bindings/core/v8/v8_wasm_response_extensions.cc;drc=26eb194ec1259c0436ac792393ea7bca97df8f7f;l=196
void OnStateChange() override {
TRACE_EVENT0(TRACE_DISABLED_BY_DEFAULT("devtools.timeline"),
"v8.wasm.compileConsume");
// Continue reading until we either finished, aborted, or no data is
// available any more (handled below).
while (streaming_) {
// |buffer| is owned by |consumer_|.
base::span<const char> buffer;
BytesConsumer::Result result = consumer_->BeginRead(buffer); // [!] acquires racy buffer
if (result == BytesConsumer::Result::kShouldWait)
return;
if (result == BytesConsumer::Result::kOk) {
// Ignore more bytes after an abort (streaming == nullptr).
if (!buffer.empty()) {
// ...
streaming_->OnBytesReceived(bytes.data(), bytes.size()); // [!] supplies it down to AsyncStreamingDecoder
}
result = consumer_->EndRead(buffer.size());
}
// ...
}
// ...
}
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/streaming-decoder.cc;drc=10cf92abc256d6f918accc819b3468a04724a287;l=232
void AsyncStreamingDecoder::OnBytesReceived(base::Vector<const uint8_t> bytes) {
DCHECK(!full_wire_bytes_.empty());
// Fill the previous vector, growing up to 16kB. After that, allocate new
// vectors on overflow.
size_t remaining_capacity =
std::max(full_wire_bytes_.back().capacity(), size_t{16} * KB) -
full_wire_bytes_.back().size();
size_t bytes_for_existing_vector = std::min(remaining_capacity, bytes.size());
full_wire_bytes_.back().insert(full_wire_bytes_.back().end(), bytes.data(), // [!] (Read #1) fills up full_wire_bytes_ from bytes (read #1), later concatenated into ModuleWireBytes
bytes.data() + bytes_for_existing_vector);
if (bytes.size() > bytes_for_existing_vector) {
// ... copy remaining buffer
}
// ...
size_t current = 0;
while (ok() && current < bytes.size()) {
size_t num_bytes =
state_->ReadBytes(this, bytes.SubVector(current, bytes.size())); // [!] (Read #2) fills up DecodingState -> SectionBuffer when reading sections
current += num_bytes;
module_offset_ += num_bytes;
if (state_->offset() == state_->buffer().size()) {
state_ = state_->Next(this);
}
}
if (ok()) {
processor_->OnFinishedChunk();
}
}
We now see an obvious, exploitable data race in this case - Wasm module validation uses section data from Read #2, but after all validation passes the total wire bytes that the Wasm module stores are from Read #1. Thus, attackers may bypass any validation and use invalid Wasm code as wire bytes. Exploiting this is trivial as Wasm function body validation can be completely bypassed, allowing arbitrary type-incompatible operations.
Bisect
Bisect uncertain as root cause is ambiguous, see the disclaimer section at summary.
VERSION
Chrome Version: ?? ~ latest
Operating System: All
REPRODUCTION CASE
Attached as exp.html that attempts an arbitrary uncaged write of write64(0x424242424242n, 0x4343434344454647n).
Note that COOP and COEP must be set so that SharedArrayBuffers may be instantiated and be sent over to workers. It may be possible to lift this limitation by using shared Wasm memory (to instantiate a SAB) + VideoFrame::CopyToAsync() (for the data race), although this has not been tested. Use either --enable-features=SharedArrayBuffer command line flag, or add headers Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.
Repro tested on Linux, Chrome for Testing 137.0.7151.55 and 140.0.7312.0.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Type of crash: Renderer
Crash State: Crashes on fully arbitrary write attempt from JIT-compiled Wasm function
CREDIT INFORMATION
Reporter credit: Seunghyun Lee (@0x10n) of CMU CyLab
Marking any rewards for charity in advance.
Chaning this primitive to RCE is trivial, will upload soon(tm).
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/bindings/core/v8/v8_wasm_response_extensions.cc;drc=26eb194ec1259c0436ac792393ea7bca97df8f7f;l=196
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;drc=7df382b48e30a3d6193403397c5efe24e4341cd9;l=179
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;drc=7df382b48e30a3d6193403397c5efe24e4341cd9;l=79
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/fetch/readable_stream_bytes_consumer.cc;l=27
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/streaming-decoder.cc;drc=10cf92abc256d6f918accc819b3468a04724a287;l=232