Medium watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper resource exposure in StreamsAPI
DescriptionImproper resource exposure in StreamsAPI
ComponentStreamsAPI
Sub-componentStreams
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker520516462
Fix commit9c98825aa251 (chromium/src) +129/-27
CWECWE-668 (Exposure to wrong sphere)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-05 (per vendor advisory)
Fix landed2026-07-14 (public git)
Disclosed2026-08-25
Patch gap42 days — fix public in source before the advisory

Background

Isolated worlds
Blink runs the page’s main world and extension content-script or other isolated worlds in one renderer, each with its own V8 context. A ScriptState identifies the world, through script_state->World().GetWorldId(), in which a binding call is executing.
Streams state is world-bound
a ReadableStream/WritableStream stores V8 values (chunks, stored_error_, promises, the underlying source and strategy) created in the context of the world that constructed it.
`wrapper_world_id_`
a new member, recorded from the constructing ScriptState in ReadableStream::ReadableStream(ScriptState*) (and its WritableStream counterpart, which is in the truncated part of the diff), used to check later callers.
`CHECK_EQ` hardening
the patch adds release-mode CHECKs, which crash the renderer on mismatch, and promotes several DCHECKs to CHECK, so cross-world misuse fails safely instead of continuing.

Root Cause Analysis

Before the patch, ReadableStream had a default constructor and stored no record of its creating world, and none of its public or internal operations (cancel, getReader, pipeThrough, pipeTo, tee, Serialize, the reader constructors, read, and so on) checked that the caller’s ScriptState belonged to the same world. If a stream object became reachable from a second world, operations invoked from that world would run the stream machinery with one world’s ScriptState while touching V8 values and promises created in the other world. This mixes the two contexts and can hand one world’s JavaScript objects to the other, which matches the advisory’s “improper resource exposure”. The diff does not show which object let a stream cross worlds. The changed WebTransport (incoming_stream.cc, send_stream.cc, and others) and File System Access (file_system_writable_file_stream.cc) files are construction sites updated to pass a ScriptState, but their hunks are truncated, so the specific trigger is (inferred).

The fix records wrapper_world_id_ at construction in every ReadableStream factory and CHECKs it against the current world at each entry point, including both ends of pipeThrough and PipeTo. Cross-world use becomes a deterministic renderer crash instead of a silent context mix.

Key insight
Stream objects were implicitly bound to their creating world’s V8 context, but the code never enforced that binding. The patch records the world ID at construction and CHECKs every operation against it.

Attack Path

  1. Obtain a stream in one world a ReadableStream or WritableStream is created in world A, for example the page’s main world or an isolated world. The concrete API that produced the shared stream is not shown (inferred).
  2. Reach it from world B the same C++ stream object becomes reachable from a different world (for instance through a platform object exposed to both worlds), so world B can invoke getReader(), pipeTo(), pipeThrough(), tee() or cancel() with its own ScriptState (inferred).
  3. Cross-context operation pre-patch, these methods ran without a world check, so reads, pipes and tees resolved promises and delivered chunks or stored errors created in world A into world B, or the reverse. This exposed one world’s JavaScript objects to the other (inferred).
  4. Post-fix the first entry point reached (for example ReadableStream::getReader or AcquireDefaultReader) hits CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId()) and the renderer crashes instead of mixing contexts.

Impact Assessment

The bug allowed stream data and JavaScript objects to cross the isolated-world boundary inside the renderer. This could let a page reach objects belonging to an extension content script, or the reverse, weakening the isolation between them (inferred). The vendor rates it medium-severity improper resource exposure, and the diff contains nothing about in-the-wild exploitation. The patch itself only adds world-identity CHECKs, so it demonstrates no memory corruption, and any escalation beyond cross-world object leakage is (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/streams/readable_stream.cc
modified

Files Changed

  • third_party/blink/renderer/core/streams/readable_stream.cc
  • third_party/blink/renderer/core/streams/readable_stream.h
  • third_party/blink/renderer/core/streams/readable_stream_byob_reader.cc
  • third_party/blink/renderer/core/streams/readable_stream_default_reader.cc
  • third_party/blink/renderer/core/streams/readable_stream_generic_reader.cc
  • third_party/blink/renderer/core/streams/writable_stream.cc
  • third_party/blink/renderer/core/streams/writable_stream.h
  • third_party/blink/renderer/core/streams/writable_stream_default_writer.cc
  • third_party/blink/renderer/modules/file_system_access/file_system_writable_file_stream.cc
  • third_party/blink/renderer/modules/file_system_access/file_system_writable_file_stream.h
  • third_party/blink/renderer/modules/webtransport/incoming_stream.cc
  • third_party/blink/renderer/modules/webtransport/outgoing_stream.cc
  • third_party/blink/renderer/modules/webtransport/receive_stream.cc
  • third_party/blink/renderer/modules/webtransport/send_stream.cc
  • third_party/blink/renderer/modules/webtransport/web_transport_receive_stream.cc
  • third_party/blink/renderer/modules/webtransport/web_transport_send_stream.cc

Audit Directions

  • Platform objects exposing a single C++ stream to multiple worlds
    enumerate attributes and methods that return a cached ReadableStream/WritableStream (for example Response.body/Request.body, WebTransport stream getters, FileSystemWritableFileStream, Blob-backed streams, and TransformStream.readable/writable) on objects whose wrappers exist in more than one world, and check where the stream was created relative to the caller.
  • World-bound V8 state in other GC'd objects
    look for other ScriptWrappables that store TraceWrapperV8Reference/ScriptPromiseResolver values or callbacks from one ScriptState and are later driven by a different caller’s ScriptState (for example AbortSignal algorithms, async iterators, ReadableStream async iteration via IterationSourceBase, and MessagePort-based transferable streams).
  • Construction sites missing the new parameter or check
    make sure every ReadableStream/WritableStream subclass and every C++-created stream (WebTransport, Fetch body loaders, encoders/decoders, CompressionStream) passes the correct ScriptState. A stream built with a different world’s state would trip, or silently satisfy, the new CHECKs.
diff --git a/third_party/blink/renderer/core/streams/readable_stream.cc b/third_party/blink/renderer/core/streams/readable_stream.cc
index 868aa34..e82ad0fe 100644
--- a/third_party/blink/renderer/core/streams/readable_stream.cc
+++ b/third_party/blink/renderer/core/streams/readable_stream.cc
@@ -39,6 +39,7 @@
 #include "third_party/blink/renderer/core/streams/writable_stream_default_controller.h"
 #include "third_party/blink/renderer/core/streams/writable_stream_default_writer.h"
 #include "third_party/blink/renderer/core/streams/writable_stream_transferring_optimizer.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
 #include "third_party/blink/renderer/platform/bindings/exception_code.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/bindings/script_state.h"
@@ -287,7 +288,7 @@
                                        ScriptValue underlying_source,
                                        ScriptValue strategy,
                                        ExceptionState& exception_state) {
-  auto* stream = MakeGarbageCollected<ReadableStream>();
+  auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
   stream->InitInternal(script_state, underlying_source, strategy, false,
                        exception_state);
   if (exception_state.HadException()) {
@@ -315,7 +316,7 @@
     std::unique_ptr<ReadableStreamTransferringOptimizer> optimizer) {
   V8DoNotRunMicrotasksScope microtasks_scope(script_state);
 
-  auto* stream = MakeGarbageCollected<ReadableStream>();
+  auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
   stream->InitWithCountQueueingStrategy(
       script_state, underlying_source, high_water_mark,
       allow_per_chunk_transferring, std::move(optimizer), IGNORE_EXCEPTION);
@@ -362,7 +363,7 @@
   DCHECK_GE(high_water_mark, 0);
 
   // 4. Let stream be a new ReadableStream.
-  auto* stream = MakeGarbageCollected<ReadableStream>();
+  auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
 
   // 5. Perform ! InitializeReadableStream(stream).
   Initialize(stream);
@@ -393,7 +394,7 @@
     ExceptionState& exception_state) {
   // https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
   // 1. Let stream be a new ReadableStream.
-  auto* stream = MakeGarbageCollected<ReadableStream>();
+  auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
 
   // 2. Perform ! InitializeReadableStream(stream).
   Initialize(stream);
@@ -420,7 +421,7 @@
     UnderlyingByteSourceBase* underlying_byte_source) {
   // https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
   // 1. Let stream be a new ReadableStream.
-  auto* stream = MakeGarbageCollected<ReadableStream>();
+  auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
 
   // Construction of the byte stream cannot fail because the trivial start
   // algorithm will not throw.
@@ -436,6 +437,7 @@
     ReadableStream* stream,
     UnderlyingByteSourceBase* underlying_byte_source,
     ExceptionState& exception_state) {
+  CHECK(stream);
   auto* pull_algorithm =
       MakeGarbageCollected<PullAlgorithm>(underlying_byte_source);
   auto* cancel_algorithm =
@@ -461,6 +463,7 @@
                                     StreamAlgorithm* pull_algorithm,
                                     StreamAlgorithm* cancel_algorithm,
                                     ExceptionState& exception_state) {
+  CHECK(stream);
   // Step 2 and 4 of
   // https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
   // 2. Perform ! InitializeReadableStream(stream).
@@ -476,7 +479,8 @@
   }
 }
 
-ReadableStream::ReadableStream() = default;
+ReadableStream::ReadableStream(ScriptState* script_state)
+    : wrapper_world_id_(script_state->World().GetWorldId()) {}
 
 ReadableStream::~ReadableStream() = default;
 
@@ -489,6 +493,7 @@
 ScriptPromise<IDLUndefined> ReadableStream::cancel(
     ScriptState* script_state,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   return cancel(script_state,
                 ScriptValue(script_state->GetIsolate(),
                             v8::Undefined(script_state->GetIsolate())),
@@ -499,6 +504,7 @@
     ScriptState* script_state,
     ScriptValue reason,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-cancel
   // 2. If ! IsReadableStreamLocked(this) is true, return a promise rejected
   //    with a TypeError exception.
@@ -514,6 +520,7 @@
 V8ReadableStreamReader* ReadableStream::getReader(
     ScriptState* script_state,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-get-reader
   // 1. If options["mode"] does not exist, return ?
   // AcquireReadableStreamDefaultReader(this).
@@ -528,6 +535,7 @@
     ScriptState* script_state,
     const ReadableStreamGetReaderOptions* options,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-get-reader
   if (options->hasMode()) {
     DCHECK_EQ(options->mode(), V8ReadableStreamReaderMode::Enum::kByob);
@@ -548,6 +556,7 @@
 ReadableStreamDefaultReader* ReadableStream::GetDefaultReaderForTesting(
     ScriptState* script_state,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   auto* result = getReader(script_state, exception_state);
   if (!result)
     return nullptr;
@@ -557,6 +566,7 @@
 ReadableStreamBYOBReader* ReadableStream::GetBYOBReaderForTesting(
     ScriptState* script_state,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   auto* options = ReadableStreamGetReaderOptions::Create();
   options->setMode(V8ReadableStreamReaderMode::Enum::kByob);
   auto* result = getReader(script_state, options, exception_state);
@@ -568,6 +578,7 @@
 ReadableStream* ReadableStream::pipeThrough(ScriptState* script_state,
                                             ReadableWritablePair* transform,
                                             ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   return pipeThrough(script_state, transform, StreamPipeOptions::Create(),
                      exception_state);
 }
@@ -577,12 +588,17 @@
                                             ReadableWritablePair* transform,
                                             const StreamPipeOptions* options,
                                             ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-pipe-through
-  DCHECK(transform->hasReadable());
+  CHECK(transform->hasReadable());
   ReadableStream* readable_stream = transform->readable();
 
-  DCHECK(transform->hasWritable());
+  CHECK(transform->hasWritable());
   WritableStream* writable_stream = transform->writable();
+  CHECK_EQ(writable_stream->GetWrapperWorldId(),
+           script_state->World().GetWorldId());
+  CHECK_EQ(readable_stream->wrapper_world_id_,
+           script_state->World().GetWorldId());
 
   // 1. If ! IsReadableStreamLocked(this) is true, throw a TypeError exception.
   if (IsLocked(this)) {
@@ -617,6 +633,7 @@
     ScriptState* script_state,
     WritableStream* destination,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   return pipeTo(script_state, destination, StreamPipeOptions::Create(),
                 exception_state);
 }
@@ -626,6 +643,7 @@
     WritableStream* destination,
     const StreamPipeOptions* options,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-pipe-to
   // 1. If ! IsReadableStreamLocked(this) is true, return a promise rejected
   //    with a TypeError exception.
@@ -653,6 +671,7 @@
 HeapVector<Member<ReadableStream>> ReadableStream::tee(
     ScriptState* script_state,
     ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   return CallTeeAndReturnBranchArray(script_state, this, false,
                                      exception_state);
 }
@@ -778,6 +797,8 @@
     ScriptState* script_state,
     ReadableStream* stream,
     ExceptionState& exception_state) {
+  CHECK(stream);
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#acquire-readable-stream-reader
   // 1. Let reader by a new ReadableStreamDefaultReader.
   // 2. Perform ? SetUpReadableStreamReader(reader, stream).
@@ -795,6 +816,8 @@
     ScriptState* script_state,
     ReadableStream* stream,
     ExceptionState& exception_state) {
+  CHECK(stream);
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#acquire-readable-stream-byob-reader
   // 1. Let reader be a new ReadableStreamBYOBReader.
   // 2. Perform ? SetUpBYOBReader(reader, stream).
@@ -826,6 +849,7 @@
                          ReadableStream** branch2,
                          bool clone_for_branch2,
                          ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   auto* engine = MakeGarbageCollected<TeeEngine>();
   engine->Start(script_state, this, clone_for_branch2, exception_state);
   if (exception_state.HadException()) {
@@ -842,6 +866,7 @@
                                    ReadableStream** branch1,
                                    ReadableStream** branch2,
                                    ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   auto* engine = MakeGarbageCollected<ByteStreamTeeEngine>();
   engine->Start(script_state, this, exception_state);
   if (exception_state.HadException()) {
@@ -855,6 +880,7 @@
 }
 
 void ReadableStream::LockAndDisturb(ScriptState* script_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   if (reader_) {
     return;
   }
@@ -871,6 +897,7 @@
 
 void ReadableStream::CloseStream(ScriptState* script_state,
                                  ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#readablestream-close
   // 1. If stream.[[controller]] implements ReadableByteStreamController,
   if (auto* readable_byte_stream_controller =
@@ -908,6 +935,7 @@
 void ReadableStream::Serialize(ScriptState* script_state,
                                MessagePort* port,
                                ExceptionState& exception_state) {
+  CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#rs-transfer
   // 1. If ! IsReadableStreamLocked(value) is true, throw a "DataCloneError"
   //    DOMException.
@@ -977,6 +1005,11 @@
     WritableStream* destination,
     PipeOptions* pipe_options,
     ExceptionState& exception_state) {
+  CHECK_EQ(readable->wrapper_world_id_, script_state->World().GetWorldId());
+  CHECK(destination);
+  CHECK_EQ(destination->GetWrapperWorldId(),
+           script_state->World().GetWorldId());
+
   auto* engine = MakeGarbageCollected<PipeToEngine>(script_state, pipe_options);
   return engine->Start(readable, destination, exception_state);
 }
@@ -1005,6 +1038,7 @@
 void ReadableStream::AddReadIntoRequest(ScriptState* script_state,
                                         ReadableStream* stream,
                                         ReadIntoRequest* readRequest) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-add-read-into-request
   // 1. Assert: stream.[[reader]] implements ReadableStreamBYOBReader.
   DCHECK(stream->reader_->IsBYOBReader());
@@ -1019,6 +1053,7 @@
 void ReadableStream::AddReadRequest(ScriptState* script_state,
                                     ReadableStream* stream,
                                     ReadRequest* read_request) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-add-read-request
   // 1. Assert: ! IsReadableStreamDefaultReader(stream.[[reader]]) is true.
   DCHECK(stream->reader_->IsDefaultReader());
@@ -1037,6 +1072,8 @@
     ScriptState* script_state,
     ReadableStream* stream,
     v8::Local<v8::Value> reason) {
+  CHECK(stream);
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#readable-stream-cancel
   // 1. Set stream.[[disturbed]] to true.
   stream->is_disturbed_ = true;
@@ -1099,6 +1136,7 @@
 }
 
 void ReadableStream::Close(ScriptState* script_state, ReadableStream* stream) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-close
   // 1. Assert: stream.[[state]] is "readable".
   CHECK_EQ(stream->state_, kReadable);
@@ -1140,6 +1178,7 @@
 void ReadableStream::Error(ScriptState* script_state,
                            ReadableStream* stream,
                            v8::Local<v8::Value> e) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-error
   // 1. Assert: stream.[[state]] is "readable".
   CHECK_EQ(stream->state_, kReadable);
@@ -1189,6 +1228,7 @@
                                             DOMArrayBufferView* chunk,
                                             bool done,
                                             ExceptionState& exception_state) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-fulfill-read-into-request
   // 1. Assert: ! ReadableStreamHasBYOBReader(stream) is true.
   DCHECK(HasBYOBReader(stream));
@@ -1215,6 +1255,7 @@
                                         v8::Local<v8::Value> chunk,
                                         bool done,
                                         ExceptionState& exception_state) {
+  CHECK(stream);
   // https://streams.spec.whatwg.org/#readable-stream-fulfill-read-request
   // 1. Assert: ! ReadableStreamHasDefaultReader(stream) is true.
   DCHECK(HasDefaultReader(stream));
diff --git a/third_party/blink/renderer/core/streams/readable_stream.h b/third_party/blink/renderer/core/streams/readable_stream.h
index cb1cfdce..7b2dcea 100644
--- a/third_party/blink/renderer/core/streams/readable_stream.h
+++ b/third_party/blink/renderer/core/streams/readable_stream.h
@@ -6,6 +6,7 @@
 #define THIRD_PARTY_BLINK_RENDERER_CORE_STREAMS_READABLE_STREAM_H_
 
 #include <stdint.h>
+
 #include <memory>
 
 #include "third_party/blink/renderer/bindings/core/v8/async_iterable.h"
@@ -16,6 +17,7 @@
 #include "third_party/blink/renderer/core/core_export.h"
 #include "third_party/blink/renderer/core/streams/readable_stream_default_reader.h"
 #include "third_party/blink/renderer/core/streams/transferable_streams.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
 #include "third_party/blink/renderer/platform/bindings/script_wrappable.h"
 #include "third_party/blink/renderer/platform/bindings/trace_wrapper_v8_reference.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
@@ -121,7 +123,7 @@
                              StreamAlgorithm* cancel_algorithm,
                              ExceptionState&);
 
-  ReadableStream();
+  explicit ReadableStream(ScriptState* script_state);
 
   ~ReadableStream() override;
 
@@ -273,6 +275,8 @@
     return readable_stream_controller_.Get();
   }
 
+  int32_t GetWrapperWorldId() const { return wrapper_world_id_; }
+
   v8::Local<v8::Value> GetStoredError(v8::Isolate*) const;
 
   std::unique_ptr<ReadableStreamTransferringOptimizer>
@@ -367,6 +371,10 @@
   TraceWrapperV8Reference<v8::Value> stored_error_;
   std::unique_ptr<ReadableStreamTransferringOptimizer> transferring_optimizer_;
 
+  // Stores the isolated world the stream was created in.
+  // It is used to CHECK that it is not used from a different world.
+  int32_t wrapper_world_id_;
+
   // ValueAsyncIterable<ReadableStream> overrides:
   using IterationSourceBase =
       ValueAsyncIterable<ReadableStream,
diff --git a/third_party/blink/renderer/core/streams/readable_stream_byob_reader.cc b/third_party/blink/renderer/core/streams/readable_stream_byob_reader.cc
index 91e72a0e..4a73dfa7 100644
--- a/third_party/blink/renderer/core/streams/readable_stream_byob_reader.cc
+++ b/third_party/blink/renderer/core/streams/readable_stream_byob_reader.cc
@@ -80,6 +80,7 @@
     ScriptState* script_state,
     ReadableStream* stream,
     ExceptionState& exception_state) {
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
   SetUpBYOBReader(script_state, this, stream, exception_state);
 }
 
@@ -159,6 +160,8 @@
         "read from its previous owner stream");
     return EmptyPromise();
   }
+  CHECK_EQ(owner_readable_stream_->wrapper_world_id_,
+           script_state->World().GetWorldId());
 
   // 8. Let promise be a new promise.
   auto* resolver =
@@ -198,7 +201,8 @@
   ReadableStream* stream = reader->owner_readable_stream_;
 
   // 2. Assert: stream is not undefined.
-  DCHECK(stream);
+  CHECK(stream);
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
 
   // 3. Set stream.[[disturbed]] to true.
   stream->is_disturbed_ = true;
diff --git a/third_party/blink/renderer/core/streams/readable_stream_default_reader.cc b/third_party/blink/renderer/core/streams/readable_stream_default_reader.cc
index 3e5213f..85d7345 100644
--- a/third_party/blink/renderer/core/streams/readable_stream_default_reader.cc
+++ b/third_party/blink/renderer/core/streams/readable_stream_default_reader.cc
@@ -71,6 +71,7 @@
     ExceptionState& exception_state)
     : ActiveScriptWrappable<ReadableStreamDefaultReader>({}),
       ExecutionContextClient(ExecutionContext::From(script_state)) {
+  CHECK_EQ(stream->wrapper_world_id_, script_state->World().GetWorldId());
   // https://streams.spec.whatwg.org/#default-reader-constructor
... (file diff truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential cross-world object leak in Blink Streams implementation via stored_error_

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential cross-world object leak in Blink’s Streams implementation (WritableStream and ReadableStream) allows a context in one DOMWrapperWorld to obtain a direct V8 reference to an object from another world. This occurs because the stream’s error state (stored_error_) is held as a raw, non-world-safe V8 reference and returned to other worlds through promise rejections. This can potentially bypass DOMWrapperWorld isolation, allowing cross-context code execution.

Affected files:

  • third_party/blink/renderer/core/streams/writable_stream.h
  • third_party/blink/renderer/core/streams/writable_stream.cc
  • third_party/blink/renderer/core/streams/writable_stream_default_writer.cc
  • third_party/blink/renderer/modules/serial/serial_port.cc

Estimated timestamp from git blame: 2019-07-09

Root Cause Analysis

In Blink’s WritableStream (third_party/blink/renderer/core/streams/writable_stream.h:282) and ReadableStream (third_party/blink/renderer/core/streams/readable_stream.h:367) implementations, the stored_error_ field stores the stream’s error state as a raw, non-world-safe V8 reference:

TraceWrapperV8Reference<v8::Value> stored_error_;

When a stream is aborted or errored, this field is populated with the raw V8 value provided by the caller. When the error is later propagated to stream writers or readers, it is read raw using GetStoredError(isolate) and passed directly to resolver rejections.

For example, in WritableStreamDefaultWriter::EnsureReadyPromiseRejected (third_party/blink/renderer/core/streams/writable_stream_default_writer.cc:308):

writer->ready_resolver_->Reject(error);

Because ready_resolver_->Reject(v8::Local<v8::Value>) delegates to Reject<IDLAny>, it resolves/rejects the promise using ToV8Traits<IDLAny>::ToV8. For raw v8::Local<v8::Value> types, this traits class returns the reference verbatim without cloning or performing a world boundary check (to_v8_traits.h:96-101). Consequently, a promise in one DOMWrapperWorld (e.g., the main world) can be rejected with a raw V8 object originating from another DOMWrapperWorld (e.g., an isolated world).

Similarly, when the writer’s ready promise is already settled, EnsureReadyPromiseRejected overwrites the resolver with a new one created in the active caller’s context (writable_stream_default_writer.cc:304-305). When a script in a different world accesses writer.ready, it receives this cross-world promise verbatim via ToV8Traits<IDLPromise<T>>::ToV8 (script_promise.h:74-80).

Potential Attack Scenario

Below are the potential/conceptual steps an attacker might follow to trigger this boundary bypass (note that these are suggested steps, as our tooling does not have the ability to execute code or verify a functional proof of concept):

  1. A page (running in DOMWrapperWorld A) and a victim extension content script (running in isolated DOMWrapperWorld B) obtain access to the same underlying C++ WritableStream or ReadableStream instance.
  2. The content script in World B aborts or errors the stream, passing a local object (such as an Error or DOMException wrapper) as the reason. This raw World B reference is stored in the stream’s C++ stored_error_ field.
  3. The page in World A gets a writer on the shared stream and attempts to read or write to it, or registers a handler on the writer’s ready promise.
  4. Due to the lack of world-safety checking on stored_error_, the main-world promise is rejected with the raw V8 object originating from World B (or the page receives a raw reference to World B’s promise).
  5. The page catches the rejection and accesses the leaked object’s constructor chain (e.g., e.constructor.constructor('return globalThis')()) to obtain a reference to World B’s global context, potentially allowing the execution of arbitrary JavaScript inside the extension’s privileged context.

Suggested Remediation

To prevent raw V8 references from crossing world boundaries, the stream’s error state should be stored and retrieved in a world-safe manner.

  1. Modify stored_error_ to use WorldSafeV8Reference instead of TraceWrapperV8Reference:
    WorldSafeV8Reference<v8::Value> stored_error_;
    
  2. When retrieving the stored error, ensure it is accessed or cloned appropriately for the target ScriptState using GetAcrossWorld(target_script_state) or by utilizing ScriptValue wrappers when rejecting resolvers.

Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker