CVE-2026-17804
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
SodaClientRegistrychrome/services/speech/speech_recognition_recognizer_impl.cc |
modified | |
ifchrome/services/speech/speech_recognition_recognizer_impl.cc |
modified |
Files Changed
chrome/services/speech/speech_recognition_recognizer_impl.ccchrome/services/speech/speech_recognition_recognizer_impl.hchrome/services/speech/speech_recognition_recognizer_impl_unittest.cc
Patch
From 4b8350a35853fc165f6c170d3cf4b9a2ef8c7833 Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Mon, 08 Jun 2026 14:11:39 -0700 Subject: [PATCH] Fix Use-After-Free in SpeechRecognitionRecognizerImpl This CL fixes a race condition where the SODA worker thread could invoke OnSodaResponse after SpeechRecognitionRecognizerImpl was destroyed. A thread-safe global registry (SodaClientRegistry) is introduced to generate a unique, non-pointer ID for each recognizer, ensuring callbacks are only invoked if the recognizer is still alive. Fixed: 515448947 Change-Id: I9d1d93d243641a3e359a843821d03f0857e7c3cc Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7868025 Reviewed-by: Frank Liberato <[email protected]> Commit-Queue: Evan Liu <[email protected]> Reviewed-by: Yiren Wang <[email protected]> Cr-Commit-Position: refs/heads/main@{#1643455} --- diff --git a/chrome/services/speech/speech_recognition_recognizer_impl.cc b/chrome/services/speech/speech_recognition_recognizer_impl.cc index 4b1e5614..96aac50 100644 --- a/chrome/services/speech/speech_recognition_recognizer_impl.cc +++ b/chrome/services/speech/speech_recognition_recognizer_impl.cc @@ -5,6 +5,7 @@ #include "chrome/services/speech/speech_recognition_recognizer_impl.h" #include <algorithm> +#include <cstdint> #include <string> #include <utility> @@ -14,8 +15,10 @@ #include "base/files/file_util.h" #include "base/functional/bind.h" #include "base/metrics/histogram_functions.h" +#include "base/no_destructor.h" #include "base/strings/strcat.h" #include "base/strings/string_util.h" +#include "base/synchronization/lock.h" #include "base/task/bind_post_task.h" #include "base/task/sequenced_task_runner.h" #include "base/task/task_runner.h" @@ -61,6 +64,56 @@ namespace { +struct SodaCallbacks { + SpeechRecognitionRecognizerImpl::OnRecognitionEventCallback recognition; + SpeechRecognitionRecognizerImpl::OnLanguageIdentificationEventCallback lang; + SpeechRecognitionRecognizerImpl::OnSpeechRecognitionStoppedCallback stop; +}; + +class SodaClientRegistry { + public: + static SodaClientRegistry* GetInstance() { + static base::NoDestructor<SodaClientRegistry> instance; + return instance.get(); + } + + uint32_t Register(SpeechRecognitionRecognizerImpl* recognizer) { + base::AutoLock auto_lock(lock_); + uint32_t id = ++next_id_; + registry_.insert_or_assign( + id, SodaCallbacks{recognizer->recognition_event_callback(), + recognizer->language_identification_event_callback(), + recognizer->speech_recognition_stopped_callback()}); + return id; + } + + void Unregister(uint32_t id) { + base::AutoLock auto_lock(lock_); + registry_.erase(id); + } + + bool GetCallbacks(uint32_t id, SodaCallbacks* out_callbacks) { + base::AutoLock auto_lock(lock_); + auto it = registry_.find(id); + if (it == registry_.end()) { + return false; + } + *out_callbacks = it->second; + return true; + } + + private: + friend class base::NoDestructor<SodaClientRegistry>; + SodaClientRegistry() = default; + + base::Lock lock_; + uint32_t next_id_ GUARDED_BY(lock_) = 0; + // A flat_map is used because the number of concurrent active SODA clients + // (N) is expected to be very small (typically just 1 or a few tabs). + // This provides better cache locality than std::map despite O(N) operations. + base::flat_map<uint32_t, SodaCallbacks> registry_ GUARDED_BY(lock_); +}; + // Callback executed by the SODA library on a speech recognition event. The // callback handle is a void pointer to the SpeechRecognitionRecognizerImpl that // owns the SODA instance. SpeechRecognitionRecognizerImpl owns the SodaClient @@ -77,6 +130,13 @@ return; } + SodaCallbacks callbacks; + uint32_t id = + static_cast<uint32_t>(reinterpret_cast<uintptr_t>(callback_handle)); + if (!SodaClientRegistry::GetInstance()->GetCallbacks(id, &callbacks)) { + return; + } + if (response.soda_type() == soda::chrome::SodaResponse::RECOGNITION) { const soda::chrome::SodaRecognitionResult& result = response.recognition_result(); @@ -94,9 +154,7 @@ } DCHECK(result.hypothesis_size()); - static_cast<SpeechRecognitionRecognizerImpl*>(callback_handle) - ->recognition_event_callback() - .Run(std::move(speech_recognition_result)); + callbacks.recognition.Run(std::move(speech_recognition_result)); } if (response.soda_type() == soda::chrome::SodaResponse::LANGID) { @@ -113,19 +171,14 @@ return; } - static_cast<SpeechRecognitionRecognizerImpl*>(callback_handle) - ->language_identification_event_callback() - .Run(std::string(event.language()), - static_cast<media::mojom::ConfidenceLevel>( - event.confidence_level()), - static_cast<media::mojom::AsrSwitchResult>( - event.asr_switch_result())); + callbacks.lang.Run( + std::string(event.language()), + static_cast<media::mojom::ConfidenceLevel>(event.confidence_level()), + static_cast<media::mojom::AsrSwitchResult>(event.asr_switch_result())); } if (response.soda_type() == soda::chrome::SodaResponse::STOP) { - static_cast<SpeechRecognitionRecognizerImpl*>(callback_handle) - ->speech_recognition_stopped_callback() - .Run(); + callbacks.stop.Run(); } } @@ -145,6 +198,10 @@ } // namespace SpeechRecognitionRecognizerImpl::~SpeechRecognitionRecognizerImpl() { + if (soda_client_id_ > 0) { + SodaClientRegistry::GetInstance()->Unregister(soda_client_id_); + } + base::UmaHistogramBoolean( base::StrCat({"Accessibility.LiveCaption.", primary_language_name_, ".SessionContainsRecognizedSpeech"}), @@ -304,6 +361,8 @@ if (speech_recognition_service_) { speech_recognition_service_->AddObserver(this); } + + soda_client_id_ = SodaClientRegistry::GetInstance()->Register(this); } void SpeechRecognitionRecognizerImpl::CreateSodaClient( @@ -613,7 +672,8 @@ config.soda_config = serialized.c_str(); config.soda_config_size = serialized.size(); config.callback = &OnSodaResponse; - config.callback_handle = this; + config.callback_handle = + reinterpret_cast<void*>(static_cast<uintptr_t>(soda_client_id_)); CHECK(soda_client_); soda_client_->Reset(config, sample_rate_, channel_count_); diff --git a/chrome/services/speech/speech_recognition_recognizer_impl.h b/chrome/services/speech/speech_recognition_recognizer_impl.h index d9200f8..f49fa9e 100644 --- a/chrome/services/speech/speech_recognition_recognizer_impl.h +++ b/chrome/services/speech/speech_recognition_recognizer_impl.h @@ -215,6 +215,8 @@ base::WeakPtr<SpeechRecognitionServiceImpl> speech_recognition_service_; + uint32_t soda_client_id_ = 0; + base::WeakPtrFactory<SpeechRecognitionRecognizerImpl> weak_factory_{this}; }; diff --git a/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc b/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc index 36fb1d3..194986e 100644 --- a/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc +++ b/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc @@ -4,7 +4,13 @@ #include "chrome/services/speech/speech_recognition_recognizer_impl.h"
Regression Test / PoC
diff --git a/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc b/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc
index 36fb1d3..194986e 100644
--- a/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc
+++ b/chrome/services/speech/speech_recognition_recognizer_impl_unittest.cc
@@ -4,7 +4,13 @@
#include "chrome/services/speech/speech_recognition_recognizer_impl.h"
+#include <vector>
+
+#include "base/barrier_closure.h"
#include "base/files/file_path.h"
+#include "base/functional/bind.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/task/thread_pool.h"
#include "base/test/task_environment.h"
#include "chrome/services/speech/soda/mock_soda_client.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -101,7 +107,8 @@
media_start_pts);
}
- base::test::SingleThreadTaskEnvironment task_environment_;
+ base::test::TaskEnvironment task_environment_{
+ base::test::TaskEnvironment::TimeSource::MOCK_TIME};
mojo::Receiver<media::mojom::SpeechRecognitionRecognizerClient> receiver_{
this};
base::flat_map<std::string, base::FilePath> config_paths_;
@@ -314,4 +321,57 @@
EXPECT_EQ("/fake/path", config->language_pack_directory());
}
+TEST_F(SpeechRecognitionRecognizerImplTest, SodaClientRegistryThreadSafety) {
+ CreateRecognizer(CreateOptions(), kPrimaryLanguageName);
+
+ SerializedSodaConfig saved_config;
+ EXPECT_CALL(*soda_client_, Reset(_, _, _))
+ .WillOnce(testing::SaveArg<0>(&saved_config));
+
+ recognizer_->OnLanguagePackInstalled(config_paths());
+
+ // Create a valid dummy response so the callback proceeds to fetching from the
+ // registry.
+ soda::chrome::SodaResponse response;
+ response.set_soda_type(soda::chrome::SodaResponse::RECOGNITION);
+ auto* result = response.mutable_recognition_result();
+ result->set_result_type(soda::chrome::SodaRecognitionResult::FINAL);
+ result->add_hypothesis("UAF test");
+ std::string serialized;
+ response.SerializeToString(&serialized);
+
+ constexpr int kNumThreads = 20;
+ base::WaitableEvent threads_started_event;
+ base::RepeatingClosure barrier = base::BarrierClosure(
+ kNumThreads, base::BindOnce(&base::WaitableEvent::Signal,
+ base::Unretained(&threads_started_event)));
+
+ // Launch background tasks to pound the callback.
+ for (int i = 0; i < kNumThreads; i++) {
+ base::ThreadPool::PostTask(
+ FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT},
+ base::BindOnce(
+ [](SerializedSodaConfig config, std::string serialized,
+ base::RepeatingClosure barrier) {
+ barrier.Run();
+ for (int j = 0; j < 100; j++) {
+ config.callback(serialized.c_str(), serialized.size(),
+ config.callback_handle);
+ }
+ },
+ saved_config, serialized, barrier));
+ }
+
+ threads_started_event.Wait();
+
+ // Clear the raw_ptr before destroying the recognizer to avoid dangling
+ // pointer warnings.
+ soda_client_ = nullptr;
+
+ // Concurrently destroy the recognizer.
+ recognizer_.reset();
+
+ task_environment_.RunUntilIdle();
+}
+
} // namespace speech
Original Bug Report
Potential Cross-thread Use-After-Free in SpeechRecognitionRecognizerImpl via SODA callbacks
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Use-After-Free exists in the Speech Recognition utility process when the SODA library worker thread invokes a callback after the SpeechRecognitionRecognizerImpl object has been destroyed. The vulnerability arises from passing a raw ’this’ pointer as a callback handle across a C ABI boundary.
Affected files:
chrome/services/speech/speech_recognition_recognizer_impl.ccchrome/services/speech/soda/soda_client_impl.ccchrome/services/speech/speech_recognition_recognizer_impl.hchrome/services/speech/soda/soda_async_impl.h
Estimated timestamp from git blame: Unknown (Google3 checkout)
The SpeechRecognitionRecognizerImpl class manages speech recognition sessions in the utility process. It interfaces with the Speech On-Device API (SODA) library, providing a raw this pointer as a callback_handle and a static function OnSodaResponse to be called by the library upon recognition events.
Technical Analysis
In ResetSoda(), the raw pointer is assigned to the library configuration:
// chrome/services/speech/speech_recognition_recognizer_impl.cc
config.callback = &OnSodaResponse;
config.callback_handle = this;
The SODA library invokes OnSodaResponse from internal worker threads. When a recognition session is ended (e.g., by a renderer disconnecting), the SpeechRecognitionRecognizerImpl destructor is called. This destructor initiates the SODA instance destruction via DeleteExtendedSodaAsync.
However, there is a potential race condition: if the library’s worker threads do not strictly join during the “Async” deletion call, a callback may be invoked after the recognizer object has been freed. The static OnSodaResponse function synchronously dereferences the dangling callback_handle on the worker thread:
// chrome/services/speech/speech_recognition_recognizer_impl.cc
void OnSodaResponse(const char* serialized_proto, int length, void* callback_handle) {
// ...
static_cast<SpeechRecognitionRecognizerImpl*>(callback_handle)
->recognition_event_callback() // Synchronous UAF on SODA thread
.Run(std::move(speech_recognition_result));
}
Returning the recognition_event_callback_ member by value triggers a copy constructor for the base::RepeatingCallback. This involves an atomic increment on the internal BindState. An attacker who can reclaim the freed memory via heap spraying could control the BindState pointer, leading to arbitrary code execution when the .Run() method is invoked.
Existing protections like base::WeakPtr and base::BindPostTaskToCurrentDefault are insufficient because the synchronous dereference of the callback_handle occurs before the task is ever posted to the main thread.
Potential Impact
A compromised renderer could potentially exploit this Use-After-Free to achieve Remote Code Execution (RCE) in the sandboxed Speech Recognition utility process.
Suggested Potential Steps to Trigger
- From a compromised renderer, establish a connection to the
SpeechRecognitionRecognizerinterface. - Start a speech recognition session and provide audio data to activate SODA worker threads.
- Quickly close the Mojo remote to trigger the deletion of the
SpeechRecognitionRecognizerImplobject. - Perform heap spraying in the utility process to attempt to reclaim the deallocated memory with attacker-controlled data before the SODA worker thread executes the callback.
Suggested Fix
Avoid passing raw pointers to external libraries that use independent threading models unless a synchronous, thread-joining shutdown is guaranteed. A robust fix would be to use a thread-safe registry or relay object. The callback_handle should point to a reference-counted relay that can be invalidated by the SpeechRecognitionRecognizerImpl during its destruction, ensuring that any subsequent callbacks from the SODA library can safely detect that the recognizer no longer exists.
Evaluated with Chrome root at commit: 29093e11cf509e3593f6229e4b1b075cca356049
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.