Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in MediaStream
DescriptionUse after free in MediaStream
ComponentMediaStream
Sub-componentBlink>MediaStream
Bug ClassUAF
Tracker565742180
Fix commit8e3884cd029d (chromium/src) +53/-3
CISA KEVNot listed
Creditedxinyang
Disclosed2026-10-01

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.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/media_stream_track_processor_test.cc
  • third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
From 8e3884cd029d8f453af29aeef5d92026b29989a8 Mon Sep 17 00:00:00 2001
From: Thomas Guilbert <tguilbert@chromium.org>
Date: Fri, 25 Sep 2026 11:00:18 -0700
Subject: [PATCH] Check IsContextDestroyed() before optimizing stream transfer

If a transferred `MediaStreamTrackProcessor.readable` stream is
deserialized after the destination `ExecutionContext` has already been
destroyed, `TransferredFrameQueueUnderlyingSource` never receives
`ContextDestroyed()`, so `ClearTransferredSource()` never runs on the
host `FrameQueueUnderlyingSource`. The host then continues forwarding
frames to the destroyed worker's heap.

Check `context->IsContextDestroyed()` in
`FrameQueueTransferringOptimizer::PerformInProcessOptimization()` and
bail out early instead of creating the transferred source.

TAG=agy
CONV=1b80eb30-30b9-4cf7-9c53-ec3723597325

Fixed: 565742180
Change-Id: I2850bc62ff38cbe12781f0c886e8b3f8bb94347b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8463165
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Commit-Queue: Thomas Guilbert <tguilbert@chromium.org>
Auto-Submit: Thomas Guilbert <tguilbert@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1704944}
---
diff --git a/third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc b/third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
index f5425f7..480dc3b2 100644
--- a/third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
+++ b/third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
@@ -38,13 +38,25 @@
 FrameQueueTransferringOptimizer<NativeFrameType>::PerformInProcessOptimization(
     ScriptState* script_state) {
   ExecutionContext* context = ExecutionContext::From(script_state);
-  scoped_refptr<base::SingleThreadTaskRunner> current_runner =
-      context->GetTaskRunner(TaskType::kInternalMediaRealTime);
-
   auto host = host_.Lock();
   if (!host)
     return nullptr;
 
+  // A source created on a destroyed context would never get
+  // `ContextDestroyed()`, so the host would keep forwarding frames to a dead
+  // heap. Treat this like a transferred source whose context was destroyed.
+  if (!context || context->IsContextDestroyed()) {
+    if (transferred_source_destroyed_callback_) {
+      std::move(transferred_source_destroyed_callback_).Run();
+    }
+    PostCrossThreadTask(
+        *host_runner_, FROM_HERE,
+        CrossThreadBindOnce(&FrameQueueHost::Close, std::move(host)));
+    return nullptr;
+  }
+  scoped_refptr<base::SingleThreadTaskRunner> current_runner =
+      context->GetTaskRunner(TaskType::kInternalMediaRealTime);
+
   base::TimeTicks time_origin;
   bool is_cross_origin_isolated = false;
   Performance* performance = GetPerformanceFromExecutionContext(context);
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 029dce4..1591573d 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
@@ -7,6 +7,7 @@
 #include "base/run_loop.h"
 #include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
+#include "base/test/test_future.h"
 #include "build/build_config.h"
 #include "media/base/video_frame.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -22,6 +23,7 @@
 #include "third_party/blink/renderer/core/streams/readable_stream_default_reader.h"
 #include "third_party/blink/renderer/core/streams/writable_stream.h"
 #include "third_party/blink/renderer/core/streams/writable_stream_default_writer.h"
+#include "third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.h"
 #include "third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h"
 #include "third_party/blink/renderer/modules/breakout_box/media_stream_track_generator.h"
 #include "third_party/blink/renderer/modules/breakout_box/metrics.h"
@@ -559,6 +561,42 @@
   EXPECT_EQ(source_a->DiscardedFrames(), 1u);
 }
 
+TEST_F(MediaStreamTrackProcessorTest,
+       OptimizationAfterContextDestroyedDoesNotTransfer) {
+  V8TestingScope host_scope;
+  ScriptState* host_script_state = host_scope.GetScriptState();
+  ExceptionState& exception_state = host_scope.GetExceptionState();
+  PushableMediaStreamVideoSource* pushable_video_source =
+      CreatePushableVideoSource();
+  MediaStreamTrackProcessor* track_processor =
+      MediaStreamTrackProcessor::Create(
+          host_script_state,
+          CreateVideoMediaStreamTrack(host_scope.GetExecutionContext(),
+                                      pushable_video_source),
+          exception_state);
+
+  MediaStreamVideoTrack* video_track =
+      MediaStreamVideoTrack::From(track_processor->InputTrack()->Component());
+  ReadableStream* readable = track_processor->readable(host_script_state);
+  EXPECT_EQ(video_track->CountSinks(), 1u);
+  auto optimizer = readable->TakeTransferringOptimizer();
+
+  V8TestingScope dest_scope;
+  dest_scope.GetExecutionContext()->NotifyContextDestroyed();
+
+  EXPECT_EQ(
+      optimizer->PerformInProcessOptimization(dest_scope.GetScriptState()),
+      nullptr);
+
+  base::test::TestFuture<void> flush_host_runner;
+  host_scope.GetExecutionContext()
+      ->GetTaskRunner(TaskType::kInternalMediaRealTime)
+      ->PostTask(FROM_HERE, flush_host_runner.GetCallback());
+  EXPECT_TRUE(flush_host_runner.Wait());
+  EXPECT_TRUE(readable->IsClosed());
+  EXPECT_EQ(video_track->CountSinks(), 0u);
+}
+
 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 029dce4..1591573d 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
@@ -7,6 +7,7 @@
 #include "base/run_loop.h"
 #include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
+#include "base/test/test_future.h"
 #include "build/build_config.h"
 #include "media/base/video_frame.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -22,6 +23,7 @@
 #include "third_party/blink/renderer/core/streams/readable_stream_default_reader.h"
 #include "third_party/blink/renderer/core/streams/writable_stream.h"
 #include "third_party/blink/renderer/core/streams/writable_stream_default_writer.h"
+#include "third_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.h"
 #include "third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h"
 #include "third_party/blink/renderer/modules/breakout_box/media_stream_track_generator.h"
 #include "third_party/blink/renderer/modules/breakout_box/metrics.h"
@@ -559,6 +561,42 @@
   EXPECT_EQ(source_a->DiscardedFrames(), 1u);
 }
 
+TEST_F(MediaStreamTrackProcessorTest,
+       OptimizationAfterContextDestroyedDoesNotTransfer) {
+  V8TestingScope host_scope;
+  ScriptState* host_script_state = host_scope.GetScriptState();
+  ExceptionState& exception_state = host_scope.GetExceptionState();
+  PushableMediaStreamVideoSource* pushable_video_source =
+      CreatePushableVideoSource();
+  MediaStreamTrackProcessor* track_processor =
+      MediaStreamTrackProcessor::Create(
+          host_script_state,
+          CreateVideoMediaStreamTrack(host_scope.GetExecutionContext(),
+                                      pushable_video_source),
+          exception_state);
+
+  MediaStreamVideoTrack* video_track =
+      MediaStreamVideoTrack::From(track_processor->InputTrack()->Component());
+  ReadableStream* readable = track_processor->readable(host_script_state);
+  EXPECT_EQ(video_track->CountSinks(), 1u);
+  auto optimizer = readable->TakeTransferringOptimizer();
+
+  V8TestingScope dest_scope;
+  dest_scope.GetExecutionContext()->NotifyContextDestroyed();
+
+  EXPECT_EQ(
+      optimizer->PerformInProcessOptimization(dest_scope.GetScriptState()),
+      nullptr);
+
+  base::test::TestFuture<void> flush_host_runner;
+  host_scope.GetExecutionContext()
+      ->GetTaskRunner(TaskType::kInternalMediaRealTime)
+      ->PostTask(FROM_HERE, flush_host_runner.GetCallback());
+  EXPECT_TRUE(flush_host_runner.Wait());
+  EXPECT_TRUE(readable->IsClosed());
+  EXPECT_EQ(video_track->CountSinks(), 0u);
+}
+
 TEST_F(MediaStreamTrackProcessorTest, AudioStats) {
   V8TestingScope v8_scope;
   ScriptState* script_state = v8_scope.GetScriptState();
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Use-after-free in FrameQueueUnderlyingSource::QueueFrame

Summary

A page transfers MediaStreamTrackProcessor.readable to a dedicated worker and makes the worker deserialize it after the worker’s global scope has been destroyed. The new transferred source never receives ContextDestroyed(), so the host FrameQueueUnderlyingSource::QueueFrame() keeps forwarding frames to it after the worker has freed its Oilpan heap. Renderer IO thread, zero interaction, no permission.

Environment

  • Chrome for Testing 154.0.8037.57 (Stable), 155.0.8059.12 (Beta), 156.0.8072.0 (Canary); code unchanged on main b7c07f35773a
  • Ubuntu 24.04, x86-64
  • Evidence build: ASan, Chromium 155.0.8038.0

Reproduction

python3 poc.py 8000
chrome --user-data-dir=/tmp/poc-profile --no-first-run http://127.0.0.1:8000/poc.html
  1. Start poc.py (only http(s) pages enter the back/forward cache, which the page uses).
  2. Launch Chrome with a fresh profile. Zero interaction beyond loading the page; the renderer crashes within about 12 s.

Rate: Stable 4/4, Beta 3/3, Canary 5/5.

Evidence

Renderer, thread Chrome_ChildIOT. Full reports: asan.txt, asan-dcheck.txt (DCHECK build, same bug, no DCHECK fires).

use-after-poison READ of size 8
  #0 FrameQueueUnderlyingSource::QueueFrame   frame_queue_underlying_source.cc:303
  #1 FrameQueueUnderlyingSource::QueueFrame   frame_queue_underlying_source.cc:240
  #2 MediaStreamVideoTrackUnderlyingSource::OnFrameFromTrack

Frame #1 is the host forwarding to the transferred source; #0 touches that source’s lock_/discarded_frames_ on the freed worker heap.

Vulnerable code

third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc

239  if (transferred_source_) {
240    transferred_source_->QueueFrame(std::move(media_frame));   [1]
302  base::AutoLock locker(lock_);                               [2]
303  discarded_frames_++;

[1] The host calls into the transferred source. [2] The callee uses its own members, whose heap the terminated worker has freed. PerformInProcessOptimization() creates the source without checking IsContextDestroyed(), so ClearTransferredSource() never runs.

Impact

Any web page, no interaction or permission. Renderer use-after-free. Reporter’s estimate: High (S1).

Discovery and validation

Found by AI-assisted source review and dynamic testing (Claude).

Attachments: poc.py, asan.txt, asan-dcheck.txt, report.md (full write-up)

View on issue tracker
Links in the report