CVE-2026-106315
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc |
modified | |
TestVideoFrameQueueUnderlyingSourcethird_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc |
modified |
Files Changed
third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.ccthird_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.ccthird_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
Patch
From b24e47c54ef42f407ae1b574f975e64211cbf532 Mon Sep 17 00:00:00 2001
From: Thomas Guilbert <tguilbert@chromium.org>
Date: Fri, 11 Sep 2026 01:35:59 -0700
Subject: [PATCH] Drop stream transfer if destination was already cleared
Transferring a Breakout Box stream posts a task to the host to call
`TransferSource()`. If the destination Worker terminates immediately,
its teardown calls `ClearTransferredSource()` before this task runs.
Because `transferred_source_` is still null, `ClearTransferredSource()`
does nothing.
When the host finally runs `TransferSource()`, it installs a pointer to
an already-destroyed Worker object. Incoming frames on the IO thread are
then forwarded to this invalid object.
This CL adds a `transferred_source_cleared_` flag guarded by `lock_`.
`ClearTransferredSource()` sets the flag to true. `TransferSource()`
checks this flag and drops the transferred source if it is already set.
Both methods take `lock_`, so both execution orderings are handled
safely.
TAG=agy
CONV=6f06f338-93eb-4473-8153-573d65259566
Bug: 497652727
Change-Id: I897fa1366e6fc3f5d817ea33dd9f64fe27a59e19
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8382280
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Commit-Queue: Guido Urdaneta <guidou@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1696032}
---
diff --git a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
index 8fdedf8..727877a 100644
--- a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
+++ b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
@@ -342,8 +342,10 @@
DCHECK(realm_task_runner_->RunsTasksInCurrentSequence());
UpdateRealmInfo(time_origin, is_cross_origin_isolated);
base::AutoLock locker(lock_);
- DCHECK(!transferred_source_);
- transferred_source_ = std::move(transferred_source);
+ if (!transferred_source_cleared_) {
+ DCHECK(!transferred_source_);
+ transferred_source_ = std::move(transferred_source);
+ }
CloseController();
frame_queue_handle_.Invalidate();
}
@@ -351,6 +353,7 @@
template <typename NativeFrameType>
void FrameQueueUnderlyingSource<NativeFrameType>::ClearTransferredSource() {
base::AutoLock locker(lock_);
+ transferred_source_cleared_ = true;
if (transferred_source_) {
discarded_frames_ += transferred_source_->DiscardedAndQueuedFrames();
total_frames_ += transferred_source_->TotalFrames();
diff --git a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
index a764a2d..8664eaf 100644
--- a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
+++ b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
@@ -167,6 +167,7 @@
// transferred stream.
CrossThreadPersistent<FrameQueueUnderlyingSource<NativeFrameType>>
transferred_source_ GUARDED_BY(lock_);
+ bool transferred_source_cleared_ GUARDED_BY(lock_) = false;
int num_pending_pulls_ GUARDED_BY(lock_) = 0;
uint64_t total_frames_ GUARDED_BY(lock_) = 0;
uint64_t discarded_frames_ GUARDED_BY(lock_) = 0;
diff --git a/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc b/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
index e98c708..029dce4 100644
--- a/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
+++ b/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
@@ -98,6 +98,7 @@
class TestVideoFrameQueueUnderlyingSource
: public VideoFrameQueueUnderlyingSource {
public:
+ using VideoFrameQueueUnderlyingSource::ClearTransferredSource;
using VideoFrameQueueUnderlyingSource::TransferSource;
using VideoFrameQueueUnderlyingSource::VideoFrameQueueUnderlyingSource;
@@ -511,6 +512,53 @@
EXPECT_EQ(source_b->TotalFrames(), 2u);
}
+TEST_F(MediaStreamTrackProcessorTest, ClearTransferredSourceStopsTransfer) {
+ V8TestingScope v8_scope;
+ ScriptState* script_state = v8_scope.GetScriptState();
+
+ auto* source_a = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+ auto* source_b = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+
+ scoped_refptr<media::VideoFrame> frame =
+ media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
+
+ source_a->TransferSource(source_b);
+ source_a->QueueFrame(frame);
+ EXPECT_EQ(source_b->TotalFrames(), 1u);
+
+ // Clearing the transferred source stops forwarding.
+ source_a->ClearTransferredSource();
+ source_a->QueueFrame(frame);
+ EXPECT_EQ(source_b->TotalFrames(), 1u);
+}
+
+TEST_F(MediaStreamTrackProcessorTest, TransferAfterClearDoesNotTransfer) {
+ V8TestingScope v8_scope;
+ ScriptState* script_state = v8_scope.GetScriptState();
+
+ auto* source_a = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+ auto* source_b = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+
+ // ClearTransferredSource called before TransferSource (simulates Worker
+ // ContextDestroyed running before Main processes TransferSource).
+ source_a->ClearTransferredSource();
+
+ source_a->TransferSource(source_b);
+
+ scoped_refptr<media::VideoFrame> frame =
+ media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
+ source_a->QueueFrame(frame);
+
+ // Frame should not be forwarded to source B because the destination was
+ // already cleared.
+ EXPECT_EQ(source_b->TotalFrames(), 0u);
+ EXPECT_EQ(source_a->DiscardedFrames(), 1u);
+}
+
TEST_F(MediaStreamTrackProcessorTest, AudioStats) {
V8TestingScope v8_scope;
ScriptState* script_state = v8_scope.GetScriptState();
Regression Test / PoC
diff --git a/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc b/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
index e98c708..029dce4 100644
--- a/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
+++ b/third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
@@ -98,6 +98,7 @@
class TestVideoFrameQueueUnderlyingSource
: public VideoFrameQueueUnderlyingSource {
public:
+ using VideoFrameQueueUnderlyingSource::ClearTransferredSource;
using VideoFrameQueueUnderlyingSource::TransferSource;
using VideoFrameQueueUnderlyingSource::VideoFrameQueueUnderlyingSource;
@@ -511,6 +512,53 @@
EXPECT_EQ(source_b->TotalFrames(), 2u);
}
+TEST_F(MediaStreamTrackProcessorTest, ClearTransferredSourceStopsTransfer) {
+ V8TestingScope v8_scope;
+ ScriptState* script_state = v8_scope.GetScriptState();
+
+ auto* source_a = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+ auto* source_b = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+
+ scoped_refptr<media::VideoFrame> frame =
+ media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
+
+ source_a->TransferSource(source_b);
+ source_a->QueueFrame(frame);
+ EXPECT_EQ(source_b->TotalFrames(), 1u);
+
+ // Clearing the transferred source stops forwarding.
+ source_a->ClearTransferredSource();
+ source_a->QueueFrame(frame);
+ EXPECT_EQ(source_b->TotalFrames(), 1u);
+}
+
+TEST_F(MediaStreamTrackProcessorTest, TransferAfterClearDoesNotTransfer) {
+ V8TestingScope v8_scope;
+ ScriptState* script_state = v8_scope.GetScriptState();
+
+ auto* source_a = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+ auto* source_b = MakeGarbageCollected<TestVideoFrameQueueUnderlyingSource>(
+ script_state, 10u, "device_id", 10u, std::nullopt);
+
+ // ClearTransferredSource called before TransferSource (simulates Worker
+ // ContextDestroyed running before Main processes TransferSource).
+ source_a->ClearTransferredSource();
+
+ source_a->TransferSource(source_b);
+
+ scoped_refptr<media::VideoFrame> frame =
+ media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
+ source_a->QueueFrame(frame);
+
+ // Frame should not be forwarded to source B because the destination was
+ // already cleared.
+ EXPECT_EQ(source_b->TotalFrames(), 0u);
+ EXPECT_EQ(source_a->DiscardedFrames(), 1u);
+}
+
TEST_F(MediaStreamTrackProcessorTest, AudioStats) {
V8TestingScope v8_scope;
ScriptState* script_state = v8_scope.GetScriptState();
Original Bug Report
UAF in Breakout Box FrameQueueUnderlyingSource via Worker termination race
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential Use-After-Free exists in Breakout Box when a MediaStreamTrack is transferred to a Worker that is immediately terminated. An ordering inversion allows a CrossThreadPersistent handle to be set after the Worker’s teardown mitigation has run, leading to a data race between the IO thread and the Worker’s Oilpan heap termination. This allows the IO thread to dereference a freed object, potentially leading to remote code execution in the renderer.
Affected files:
third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.ccthird_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.ccthird_party/blink/renderer/modules/breakout_box/transferred_frame_queue_underlying_source.ccthird_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source.cc
Estimated timestamp from git blame: 2022-08-31
Vulnerability Details
When a Breakout Box stream (like a MediaStreamTrackProcessor’s readable stream) is transferred to a Web Worker, a race condition can occur during Worker termination that bypasses lifecycle mitigations and leads to a Use-After-Free (UAF).
- Transfer and Callback Serialization: When the stream is deserialized on the Worker thread,
FrameQueueTransferringOptimizer::PerformInProcessOptimizationallocates aTransferredFrameQueueUnderlyingSourceon the Worker’s Oilpan (cppgc) heap. It then usesPostCrossThreadTaskto send aconnect_host_callbackback to the Main thread to link the host to this new source. - Mitigation Bypass: If the Worker is terminated immediately and the Main thread is artificially blocked (e.g., via a busy loop), the
connect_host_callbacksits in the Main thread’s task queue. The Worker thread begins shutdown, which synchronously firesContextDestroyed(). This callsClearTransferredSource()on the host object to safely detach the stream. However, because theconnect_host_callbackhasn’t run yet,transferred_source_is still null, rendering this teardown mitigation completely ineffective. - Late Attachment: The Main thread unblocks, processes its task queue, and executes the delayed
connect_host_callback(callingTransferSource()). This sets the host’stransferred_source_to aCrossThreadPersistenthandle pointing to the Worker’s Oilpan object—an object belonging to a heap that is currently in the process of being destroyed. - Data Race on IO Thread: The Worker thread continues its termination sequence and calls
cppgc::HeapBase::Terminate(). This function severs all cross-thread roots by non-atomically zeroing out theraw_pointers insideCrossThreadPersistenthandles. Crucially, this happens without acquiring the host’slock_. - Use-After-Free: Concurrently, the underlying media track delivers a frame to the IO thread, triggering
QueueFrame(). The IO thread acquireshost->lock_and readstransferred_source_. Due to the lack of synchronization withHeapBase::Terminate(),CrossThreadPersistent::Get()can load a non-null pointer just before the Worker zeroes it out. The IO thread then callstransferred_source_->QueueFrame(...). Because the Oilpan heap sweep has already freed the target object, this results in a UAF. Since the memory is managed by Oilpan, it is not protected by MiraclePtr (BackupRefPtr).
Potential Steps to Trigger
Note: These are suggested steps to theoretically trigger the vulnerability; our tooling agent does not currently have the capability to run and verify a live proof-of-concept.
- Create a video track (e.g., from a
<canvas>capture) and wrap it in aMediaStreamTrackProcessor. - Call
postMessageto transfer the processor’sreadablestream to a Web Worker. - Immediately call
worker.terminate()from the Main thread. - Execute a brief busy-loop (
while (Date.now() < start + X) {}) on the Main thread to ensure the Worker’sContextDestroyedruns before the Main thread processes theconnect_host_callback. - Flood the video track with frames (e.g., via
requestAnimationFramedrawing to the canvas) to force the IO thread to callQueueFrame()at the exact moment the Worker’scppgcheap is terminating. - The IO thread dereferences the freed
TransferredFrameQueueUnderlyingSourceobject, where an attacker who has groomed the Worker’s heap could potentially control the vtable/mutex state for Remote Code Execution.
Suggested Fix
CrossThreadPersistent handles are documented as non-thread-safe against concurrent GC clearing, meaning host->lock_ alone cannot protect the handle if the target heap terminates.
The safest fix is to prevent TransferSource from attaching the handle if the target context is already tearing down.
- Add a boolean flag (e.g.,
is_transfer_aborted_) toFrameQueueUnderlyingSource, guarded bylock_. - In
ClearTransferredSource()(which runs duringContextDestroyed), setis_transfer_aborted_ = true;inside the lock. - Modify
TransferSource()to check this flag while holding the lock. Ifis_transfer_aborted_is true, drop the incomingTransferredFrameQueueUnderlyingSourceand do not assign it totransferred_source_.
This ensures that if the Worker dies before the connection is fully established, the delayed callback won’t accidentally wire up a dangling pointer to a dying heap.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.