Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Modularization
DescriptionUse after free in Modularization
ComponentModularization
Sub-componentAndroid, Fuchsia, Linux, Mac, Windows, ChromeOS
Bug ClassUAF
Tracker497652727
Fix commitb24e47c54ef4 (chromium/src) +54/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
modified
TestVideoFrameQueueUnderlyingSource
third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
modified
TEST_F
third_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.cc
  • third_party/blink/renderer/modules/breakout_box/media_stream_track_processor_test.cc
  • third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
From b24e47c54ef42f407ae1b574f975e64211cbf532 Mon Sep 17 00:00:00 2001
From: Thomas Guilbert <[email protected]>
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 <[email protected]>
Commit-Queue: Guido Urdaneta <[email protected]>
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();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
  • third_party/blink/renderer/modules/breakout_box/transferred_frame_queue_underlying_source.cc
  • third_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).

  1. Transfer and Callback Serialization: When the stream is deserialized on the Worker thread, FrameQueueTransferringOptimizer::PerformInProcessOptimization allocates a TransferredFrameQueueUnderlyingSource on the Worker’s Oilpan (cppgc) heap. It then uses PostCrossThreadTask to send a connect_host_callback back to the Main thread to link the host to this new source.
  2. Mitigation Bypass: If the Worker is terminated immediately and the Main thread is artificially blocked (e.g., via a busy loop), the connect_host_callback sits in the Main thread’s task queue. The Worker thread begins shutdown, which synchronously fires ContextDestroyed(). This calls ClearTransferredSource() on the host object to safely detach the stream. However, because the connect_host_callback hasn’t run yet, transferred_source_ is still null, rendering this teardown mitigation completely ineffective.
  3. Late Attachment: The Main thread unblocks, processes its task queue, and executes the delayed connect_host_callback (calling TransferSource()). This sets the host’s transferred_source_ to a CrossThreadPersistent handle pointing to the Worker’s Oilpan object—an object belonging to a heap that is currently in the process of being destroyed.
  4. 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 the raw_ pointers inside CrossThreadPersistent handles. Crucially, this happens without acquiring the host’s lock_.
  5. Use-After-Free: Concurrently, the underlying media track delivers a frame to the IO thread, triggering QueueFrame(). The IO thread acquires host->lock_ and reads transferred_source_. Due to the lack of synchronization with HeapBase::Terminate(), CrossThreadPersistent::Get() can load a non-null pointer just before the Worker zeroes it out. The IO thread then calls transferred_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.

  1. Create a video track (e.g., from a <canvas> capture) and wrap it in a MediaStreamTrackProcessor.
  2. Call postMessage to transfer the processor’s readable stream to a Web Worker.
  3. Immediately call worker.terminate() from the Main thread.
  4. Execute a brief busy-loop (while (Date.now() < start + X) {}) on the Main thread to ensure the Worker’s ContextDestroyed runs before the Main thread processes the connect_host_callback.
  5. Flood the video track with frames (e.g., via requestAnimationFrame drawing to the canvas) to force the IO thread to call QueueFrame() at the exact moment the Worker’s cppgc heap is terminating.
  6. The IO thread dereferences the freed TransferredFrameQueueUnderlyingSource object, 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.

  1. Add a boolean flag (e.g., is_transfer_aborted_) to FrameQueueUnderlyingSource, guarded by lock_.
  2. In ClearTransferredSource() (which runs during ContextDestroyed), set is_transfer_aborted_ = true; inside the lock.
  3. Modify TransferSource() to check this flag while holding the lock. If is_transfer_aborted_ is true, drop the incoming TransferredFrameQueueUnderlyingSource and do not assign it to transferred_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.

View on issue tracker