CVE-2026-103623
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.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/media_stream_track_processor_test.ccthird_party/blink/renderer/modules/breakout_box/frame_queue_transferring_optimizer.cc
Patch
From 8e3884cd029d8f453af29aeef5d92026b29989a8 Mon Sep 17 00:00:00 2001 From: Thomas Guilbert <[email protected]> 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 <[email protected]> Commit-Queue: Thomas Guilbert <[email protected]> Auto-Submit: Thomas Guilbert <[email protected]> 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();
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 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();
Original Bug Report
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
- Start
poc.py(only http(s) pages enter the back/forward cache, which the page uses). - 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)