diff --git a/base/BUILD.gn b/base/BUILD.gn index 2fea06e3d7628..4f87900fc2cc9 100644 --- a/base/BUILD.gn +++ b/base/BUILD.gn @@ -3596,6 +3596,7 @@ test("base_unittests") { "functional/bind_unittest.cc", "functional/callback_helpers_unittest.cc", "functional/callback_unittest.cc", + "functional/once_callback_race_unittest.cc", "functional/concurrent_callbacks_unittest.cc", "functional/concurrent_closures_unittest.cc", "functional/function_ref_unittest.cc", diff --git a/base/functional/once_callback_race_unittest.cc b/base/functional/once_callback_race_unittest.cc new file mode 100644 index 0000000000000..3c66b73bbc0a7 --- /dev/null +++ b/base/functional/once_callback_race_unittest.cc @@ -0,0 +1,147 @@ +// 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 for cross-thread data race on +// ChromeSpeechRecognitionClient::on_ready_callback_. +// +// This test replicates the exact unsynchronized access pattern present in +// chrome/renderer/media/chrome_speech_recognition_client.cc: +// - SetOnReadyCallback() (called from the media thread by +// AudioRendererImpl::Initialize, audio_renderer_impl.cc:417) move-assigns +// into the OnceCallback member with no lock. +// - OnRecognizerBound() (called on the renderer main thread as the +// BindRecognizer Mojo reply, chrome_speech_recognition_client.cc:128) +// reads and consumes the same member with no lock. +// +// base::OnceCallback wraps a scoped_refptr. Concurrent +// move-assign on one thread and move-consume on another can free the +// BindStateBase while the other thread holds a stale raw pointer to it, +// producing heap-use-after-free / double-free on the BindStateBase (which +// contains the polymorphic_invoke_ indirect-call target). +// +// Under TSAN this is flagged immediately as a data race. +// Under ASAN this manifests as heap-use-after-free when the race is won. + +#include +#include + +#include "base/functional/bind.h" +#include "base/functional/callback.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +// Mirrors the relevant subset of ChromeSpeechRecognitionClient. +class SpeechClientLike { + public: + using OnReadyCallback = base::OnceCallback; + + // Mirrors ChromeSpeechRecognitionClient::SetOnReadyCallback + // (chrome_speech_recognition_client.cc:103-110). Called on the media thread. + void SetOnReadyCallback(OnReadyCallback callback) { + on_ready_callback_ = std::move(callback); // unsynchronized write + + // Mirrors lines 108-109: a second concurrent-consume path on the media + // thread once the recognizer is bound. + if (is_recognizer_bound_.load() && on_ready_callback_) { + std::move(on_ready_callback_).Run(); + } + } + + // Mirrors ChromeSpeechRecognitionClient::OnRecognizerBound + // (chrome_speech_recognition_client.cc:119-130). Called on the main thread. + void OnRecognizerBound() { + is_recognizer_bound_.store(true); + + if (on_ready_callback_) { // unsynchronized read + std::move(on_ready_callback_).Run(); // unsynchronized consume + } + } + + private: + // chrome_speech_recognition_client.h:108 -- no GUARDED_BY, no lock. + OnReadyCallback on_ready_callback_; + // The real code guards this with is_recognizer_bound_lock_; using atomic + // here is equivalent for the purpose of isolating the on_ready_callback_ + // race (the lock in the real code does NOT cover on_ready_callback_). + std::atomic is_recognizer_bound_{false}; +}; + +void NoOp() {} + +} // namespace + +// Two long-lived threads hammer the unsynchronized paths on a single object, +// mirroring the media thread (writer) racing the renderer main thread (reader). +// With ASAN this produces heap-use-after-free on internal::BindStateBase when +// the race is won; with TSAN it is flagged immediately. +TEST(ChromeSpeechRecognitionClientRace, OnReadyCallbackCrossThreadRace) { + constexpr int kIterations = 5000000; + + SpeechClientLike client; + std::atomic go{false}; + + // Writer: media thread repeatedly re-assigning the callback (each + // move-assign Releases the previous BindStateBase, refcount->0, freed). + std::thread writer([&]() { + while (!go.load(std::memory_order_acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + client.SetOnReadyCallback(base::BindOnce(&NoOp)); + } + }); + + // Reader: main thread repeatedly receiving the BindRecognizer reply and + // consuming the callback. + std::thread reader([&]() { + while (!go.load(std::memory_order_acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + client.OnRecognizerBound(); + } + }); + + go.store(true, std::memory_order_release); + writer.join(); + reader.join(); +} + +// Variant that isolates the line-105 (move-assign) vs line-128/129 +// (check + consume) race, which is the path that yields heap-use-after-free +// on the released BindStateBase rather than the null-dereference produced by +// the dual-consume race above. This corresponds to the report's primary +// scenario: AudioRendererImpl::Initialize() runs on the media thread *before* +// the recognizer is bound, so SetOnReadyCallback() only performs the +// move-assign and does not itself consume. +TEST(ChromeSpeechRecognitionClientRace, MoveAssignVsConsumeUAF) { + constexpr int kIterations = 20000000; + + base::OnceCallback on_ready_callback; // shared, no lock + std::atomic go{false}; + + // Writer: media thread doing on_ready_callback_ = std::move(cb). + std::thread writer([&]() { + while (!go.load(std::memory_order_acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + // Each move-assign Releases the previous BindStateBase (refcount->0). + on_ready_callback = base::BindOnce(&NoOp); + } + }); + + // Reader: main thread doing if (cb) std::move(cb).Run(). + std::thread reader([&]() { + while (!go.load(std::memory_order_acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + if (on_ready_callback) { + std::move(on_ready_callback).Run(); + } + } + }); + + go.store(true, std::memory_order_release); + writer.join(); + reader.join(); +}