CVE-2026-79068
Overview
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
ScriptStateidentifies the world, throughscript_state->World().GetWorldId(), in which a binding call is executing. - Streams state is world-bound
- a
ReadableStream/WritableStreamstores 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
ScriptStateinReadableStream::ReadableStream(ScriptState*)(and itsWritableStreamcounterpart, 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 severalDCHECKs toCHECK, 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.
CHECKs every operation against it.Attack Path
- Obtain a stream in one world
a
ReadableStreamorWritableStreamis 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). - 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()orcancel()with its ownScriptState(inferred). - 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). - Post-fix
the first entry point reached (for example
ReadableStream::getReaderorAcquireDefaultReader) hitsCHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId())and the renderer crashes instead of mixing contexts.
Impact Assessment
(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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/streams/readable_stream.cc |
modified |
Files Changed
third_party/blink/renderer/core/streams/readable_stream.ccthird_party/blink/renderer/core/streams/readable_stream.hthird_party/blink/renderer/core/streams/readable_stream_byob_reader.ccthird_party/blink/renderer/core/streams/readable_stream_default_reader.ccthird_party/blink/renderer/core/streams/readable_stream_generic_reader.ccthird_party/blink/renderer/core/streams/writable_stream.ccthird_party/blink/renderer/core/streams/writable_stream.hthird_party/blink/renderer/core/streams/writable_stream_default_writer.ccthird_party/blink/renderer/modules/file_system_access/file_system_writable_file_stream.ccthird_party/blink/renderer/modules/file_system_access/file_system_writable_file_stream.hthird_party/blink/renderer/modules/webtransport/incoming_stream.ccthird_party/blink/renderer/modules/webtransport/outgoing_stream.ccthird_party/blink/renderer/modules/webtransport/receive_stream.ccthird_party/blink/renderer/modules/webtransport/send_stream.ccthird_party/blink/renderer/modules/webtransport/web_transport_receive_stream.ccthird_party/blink/renderer/modules/webtransport/web_transport_send_stream.cc
Audit Directions
- Platform objects exposing a single C++ stream to multiple worldsenumerate attributes and methods that return a cached
ReadableStream/WritableStream(for exampleResponse.body/Request.body, WebTransport stream getters,FileSystemWritableFileStream,Blob-backed streams, andTransformStream.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 objectslook for other
ScriptWrappables that storeTraceWrapperV8Reference/ScriptPromiseResolvervalues or callbacks from oneScriptStateand are later driven by a different caller’sScriptState(for exampleAbortSignalalgorithms, async iterators,ReadableStreamasync iteration viaIterationSourceBase, andMessagePort-based transferable streams). - Construction sites missing the new parameter or checkmake sure every
ReadableStream/WritableStreamsubclass and every C++-created stream (WebTransport, Fetch body loaders, encoders/decoders,CompressionStream) passes the correctScriptState. A stream built with a different world’s state would trip, or silently satisfy, the newCHECKs.
Patch
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)
Original Bug Report
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.hthird_party/blink/renderer/core/streams/writable_stream.ccthird_party/blink/renderer/core/streams/writable_stream_default_writer.ccthird_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):
- A page (running in DOMWrapperWorld A) and a victim extension content script (running in isolated DOMWrapperWorld B) obtain access to the same underlying C++
WritableStreamorReadableStreaminstance. - 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. - 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
readypromise. - 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). - 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.
- Modify
stored_error_to useWorldSafeV8Referenceinstead ofTraceWrapperV8Reference:WorldSafeV8Reference<v8::Value> stored_error_; - When retrieving the stored error, ensure it is accessed or cloned appropriately for the target
ScriptStateusingGetAcrossWorld(target_script_state)or by utilizingScriptValuewrappers 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.