CVE-2025-8880
Overview
Background
- Streaming compilation
- V8’s WebAssembly pipeline that decodes and compiles module bytes incrementally as they arrive via
AsyncStreamingDecoder::OnBytesReceived. - `base::Vector<const uint8_t>`
- a lightweight non-owning view of a byte range (pointer plus length) that references memory the caller still owns.
- V8 sandbox
- a bounded memory region for untrusted heap objects where an attacker can concurrently mutate contents, so any pointer into it must be treated as adversarial.
- TOCTOU (double-read)
- a time-of-check-to-time-of-use flaw where the same untrusted memory is read more than once and can change between reads.
Root Cause Analysis
AsyncStreamingDecoder::OnBytesReceived received an embedder-supplied bytes view whose backing store could point into the V8 sandbox and be modified concurrently by an attacker. The original code copied bytes into full_wire_bytes_ for storage, but then re-read the original bytes view again in the decode loop via bytes.size() and bytes.SubVector(current, bytes.size()), so the length and contents used for parsing could differ from what was copied. This double-read violated the invariant that the decoder must operate on a stable, immutable snapshot of the input, letting the size and data observed during copying diverge from the size and data driving state_->ReadBytes.
The fix records the copied ranges in a copied_bytes[2] array pointing into the freshly written full_wire_bytes_ storage, clears bytes = {} immediately after copying, and iterates over copied_bytes instead of the incoming view, so every subsequent read comes from V8-owned memory that the embedder cannot mutate.
bytes vector twice—once to copy and again to drive decoding—allowing concurrent modification between the reads; the fix eliminates the second read by decoding exclusively from the internal copied_bytes snapshot and nulling out bytes.Attack Path
- Supply sandbox-backed bytes
The embedder (or attacker-influenced code) passes an
OnBytesReceivedbytesview whose backing memory lives inside the V8 sandbox. - Race the copy against the read
A concurrent thread mutates the sandbox memory between the moment
OnBytesReceivedcopies intofull_wire_bytes_and the moment it re-readsbytes.size()/bytes.SubVectorfor decoding. - Desynchronize length and data
The decode loop bounds itself on the now-changed
bytes.size()whilestate_->ReadBytesconsumes from the altered view, so parsing runs on data inconsistent with what was validated and stored. - Corrupt decoder state
The mismatched length/content drives
state_->ReadBytesandstate_ = state_->Next(this)past intended boundaries, corrupting streaming decoder state and enabling memory-safety violations.
Impact Assessment
OnBytesReceived (memory pointing into the sandbox), turning a sandbox write into out-of-bounds behavior in trusted V8 code outside the sandbox.Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/wasm/streaming-decoder.cc |
modified |
Files Changed
src/wasm/streaming-decoder.cc
Audit Directions
- Untrusted view double-readSearch for functions taking a
base::Vector/pointer from the embedder or sandbox that read it more than once (e.g., once to copy and again to size or process) and require a single-read snapshot instead. - Copy-then-reuse-originalFlag code that copies input into internal storage but then keeps operating on the original incoming buffer rather than the copy, especially where
.size()and.data()are re-read separately. - Sandbox-pointer inputsAudit API boundaries where the embedder can hand V8 memory that may point into the sandbox, ensuring such buffers are snapshotted and the original reference is nulled before further use.
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).
exp.htmlexploit-ab3b8cf0af3f30d97084b8a727cb40f7a0b81cbadd53195eb840cca943ff810e.tar.gzstack-433533359.txtstack-asan-v8-sandbox-testing-linux-release-1490481.txt
- 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