High watch CVSS 8.8 chrome Race Exploitability: Moderate (assessed) 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in V8
DescriptionRace in V8
ComponentV8
Sub-componentWASM
Bug ClassRace
ExploitabilityModerate (assessed by PatchGap · plausible)
Wasm streaming decoder double-fetched embedder bytes that may point into the V8 sandbox: it copied them but then re-read the original mutable view for length/content, so parse could diverge (double-fetch/TOCTOU corruption). Exploitation presupposes an attacker able to concurrently modify sandbox mem
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker433533359
Fix commit1eda9300e26e (v8/v8) +39/-23
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSeunghyun Lee (@0x10n)
Reported2025-07-23 (per vendor advisory)
Fix landed2025-07-25 (public git)
Disclosed2025-08-12
Patch gap18 days — fix public in source before the advisory

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.

Key insight
The single mistake was reading the attacker-controlled 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

  1. Supply sandbox-backed bytes The embedder (or attacker-influenced code) passes an OnBytesReceived bytes view whose backing memory lives inside the V8 sandbox.
  2. Race the copy against the read A concurrent thread mutates the sandbox memory between the moment OnBytesReceived copies into full_wire_bytes_ and the moment it re-reads bytes.size() / bytes.SubVector for decoding.
  3. Desynchronize length and data The decode loop bounds itself on the now-changed bytes.size() while state_->ReadBytes consumes from the altered view, so parsing runs on data inconsistent with what was validated and stored.
  4. Corrupt decoder state The mismatched length/content drives state_->ReadBytes and state_ = state_->Next(this) past intended boundaries, corrupting streaming decoder state and enabling memory-safety violations.

Impact Assessment

An attacker who can concurrently modify sandbox-resident streaming bytes gains a memory-corruption primitive inside the renderer’s V8 engine during WebAssembly streaming compilation. Exploitation requires the ability to supply and concurrently mutate the byte vector backing OnBytesReceived (memory pointing into the sandbox), turning a sandbox write into out-of-bounds behavior in trusted V8 code outside the sandbox.

Changed Functions

FunctionChangeNotes
for
src/wasm/streaming-decoder.cc
modified

Files Changed

  • src/wasm/streaming-decoder.cc

Audit Directions

  • Untrusted view double-read
    Search 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-original
    Flag 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 inputs
    Audit 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.
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(
Loading diff…

Original Bug Report

reported by se...@gmail.com

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 ReadableByteStreamController producer.

> 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).

View on issue tracker