CVE-2026-79103
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/speech/chrome_speech_recognition_manager_delegate.cc |
modified | |
ChromeSpeechRecognitionTestchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
ChromeSpeechRecognitionTestchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
SpeechWebContentsObserverchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
ifchrome/browser/speech/speech_recognition_browsertest.cc |
modified |
Files Changed
chrome/browser/speech/chrome_speech_recognition_manager_delegate.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.hchrome/browser/speech/speech_recognition_browsertest.cc
Patch
From fca5f84ab5c990a64af33fa9c295776f98ef3d8e Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Wed, 08 Jul 2026 00:51:32 -0700 Subject: [PATCH] Route on-device Web Speech to the requesting frame's profile On-device Web Speech previously resolved the SpeechRecognitionService using the last-used profile instead of the initiating frame's profile, and cached a single browser-wide SpeechRecognitionContext. This allowed Incognito or cross-profile sessions to be misrouted. This CL: - Plumbs the initiating frame's GlobalRenderFrameHostId to BindSpeechRecognitionContext to resolve the correct profile. - Binds a fresh SpeechRecognitionContext per session instead of caching a singleton remote. Fixed: 520481800 Change-Id: Ice1f15335752179eeb762a085dced5879974036d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8021607 Reviewed-by: Nasko Oskov <[email protected]> Commit-Queue: Evan Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1658558} --- diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc index 30df13cf..460ea89 100644 --- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc +++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc @@ -127,21 +127,39 @@ void ChromeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> recognition_receiver, - const std::string& language) { + const std::string& language, + const content::GlobalRenderFrameHostId& render_frame_host_id) { #if BUILDFLAG(ENABLE_SPEECH_SERVICE) DCHECK_CURRENTLY_ON(BrowserThread::IO); content::GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce( - [](const std::string& language, + [](const content::GlobalRenderFrameHostId& render_frame_host_id, + const std::string& language, mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver) { + if (!render_frame_host_id) { + return; + } + + content::RenderFrameHost* rfh = + content::RenderFrameHost::FromID(render_frame_host_id); + if (!rfh) { + // The frame was destroyed before we could bind. Drop the + // request to avoid routing to an incorrect profile. + return; + } + + Profile* profile = + Profile::FromBrowserContext(rfh->GetBrowserContext()); + if (!profile) { + return; + } + #if BUILDFLAG(ENABLE_BROWSER_SPEECH_SERVICE) - auto* profile = ProfileManager::GetLastUsedProfileIfLoaded(); auto* factory = SpeechRecognitionServiceFactory::GetForProfile(profile); #elif BUILDFLAG(IS_CHROMEOS) - auto* profile = ProfileManager::GetPrimaryUserProfile(); auto* factory = CrosSpeechRecognitionServiceFactory::GetForProfile(profile); #else @@ -150,13 +168,12 @@ if (factory) { factory->BindSpeechRecognitionContext(std::move(receiver)); } - // Reset the SODA uninstall timer when used by the Web Speech API. - if (profile) { - SodaInstaller::GetInstance()->SetUninstallTimer( - g_browser_process->local_state(), language); - } + // Reset the SODA uninstall timer when used by the Web Speech + // API. + SodaInstaller::GetInstance()->SetUninstallTimer( + g_browser_process->local_state(), language); }, - language, std::move(recognition_receiver))); + render_frame_host_id, language, std::move(recognition_receiver))); #endif // BUILDFLAG(ENABLE_SPEECH_SERVICE) } #endif // !BUILDFLAG(IS_ANDROID) diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h index 5aec171..3b4f9b1 100644 --- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h +++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h @@ -61,7 +61,8 @@ // This will bind to the Speech Recognition Service if available. void BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) override; + const std::string& language, + const content::GlobalRenderFrameHostId& render_frame_host_id) override; #endif // !BUILDFLAG(IS_ANDROID) private: diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc index 2284ce9..2204468 100644 --- a/chrome/browser/speech/speech_recognition_browsertest.cc +++ b/chrome/browser/speech/speech_recognition_browsertest.cc @@ -6,9 +6,17 @@ #include "base/strings/utf_string_conversions.h" #include "base/task/bind_post_task.h" +#include "base/test/scoped_feature_list.h" #include "base/test/test_future.h" #include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h" +#include "chrome/browser/speech/fake_speech_recognition_service.h" +#include "chrome/browser/speech/speech_recognition_service_factory.h" #include "chrome/browser/ui/browser.h" + +#if BUILDFLAG(IS_CHROMEOS) +#include "ash/constants/ash_features.h" +#include "chrome/browser/speech/cros_speech_recognition_service_factory.h" +#endif #include "chrome/browser/ui/browser_commands.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" #include "chrome/test/base/in_process_browser_test.h" @@ -21,6 +29,7 @@ #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/fake_speech_recognition_manager.h" +#include "media/mojo/mojom/speech_recognition.mojom.h" #include "net/test/embedded_test_server/embedded_test_server.h" #if BUILDFLAG(ENABLE_EXTENSIONS) @@ -36,7 +45,12 @@ class ChromeSpeechRecognitionTest : public InProcessBrowserTest { public: - ChromeSpeechRecognitionTest() = default; + ChromeSpeechRecognitionTest() { +#if BUILDFLAG(IS_CHROMEOS) + scoped_feature_list_.InitAndEnableFeature( + ash::features::kOnDeviceSpeechRecognition); +#endif + } ChromeSpeechRecognitionTest(const ChromeSpeechRecognitionTest&) = delete; ChromeSpeechRecognitionTest& operator=(const ChromeSpeechRecognitionTest&) = @@ -71,6 +85,10 @@ protected: ChromeSpeechRecognitionManagerDelegate delegate_; content::FakeSpeechRecognitionManager fake_speech_recognition_manager_; + +#if BUILDFLAG(IS_CHROMEOS) + base::test::ScopedFeatureList scoped_feature_list_; +#endif }; class SpeechWebContentsObserver : public content::WebContentsObserver { @@ -217,4 +235,58 @@ } #endif +#if !BUILDFLAG(IS_ANDROID) +IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest, + IncognitoRoutesToIncognitoService) { + Browser* incognito_browser = CreateIncognitoBrowser(); + ASSERT_TRUE(incognito_browser); + + struct TestContext { + base::OnceClosure quit_closure; + bool created = false; + }; + auto context = std::make_unique<TestContext>(); + base::RunLoop run_loop; + context->quit_closure = run_loop.QuitClosure(); + TestContext* context_ptr = context.get(); + + auto testing_factory = base::BindRepeating( + [](TestContext* ctx, + content::BrowserContext* context) -> std::unique_ptr<KeyedService> { + ctx->created = true; + if (ctx->quit_closure) { + std::move(ctx->quit_closure).Run(); + } + return std::make_unique<FakeSpeechRecognitionService>(); + }, + base::Unretained(context_ptr)); + +#if BUILDFLAG(IS_CHROMEOS) + CrosSpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory( + incognito_browser->profile(), std::move(testing_factory)); +#else + SpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory( + incognito_browser->profile(), std::move(testing_factory)); +#endif + + WebContents* web_contents = + incognito_browser->tab_strip_model()->GetActiveWebContents();
Regression Test / PoC
diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc
index 2284ce9..2204468 100644
--- a/chrome/browser/speech/speech_recognition_browsertest.cc
+++ b/chrome/browser/speech/speech_recognition_browsertest.cc
@@ -6,9 +6,17 @@
#include "base/strings/utf_string_conversions.h"
#include "base/task/bind_post_task.h"
+#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h"
+#include "chrome/browser/speech/fake_speech_recognition_service.h"
+#include "chrome/browser/speech/speech_recognition_service_factory.h"
#include "chrome/browser/ui/browser.h"
+
+#if BUILDFLAG(IS_CHROMEOS)
+#include "ash/constants/ash_features.h"
+#include "chrome/browser/speech/cros_speech_recognition_service_factory.h"
+#endif
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/test/base/in_process_browser_test.h"
@@ -21,6 +29,7 @@
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/fake_speech_recognition_manager.h"
+#include "media/mojo/mojom/speech_recognition.mojom.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#if BUILDFLAG(ENABLE_EXTENSIONS)
@@ -36,7 +45,12 @@
class ChromeSpeechRecognitionTest : public InProcessBrowserTest {
public:
- ChromeSpeechRecognitionTest() = default;
+ ChromeSpeechRecognitionTest() {
+#if BUILDFLAG(IS_CHROMEOS)
+ scoped_feature_list_.InitAndEnableFeature(
+ ash::features::kOnDeviceSpeechRecognition);
+#endif
+ }
ChromeSpeechRecognitionTest(const ChromeSpeechRecognitionTest&) = delete;
ChromeSpeechRecognitionTest& operator=(const ChromeSpeechRecognitionTest&) =
@@ -71,6 +85,10 @@
protected:
ChromeSpeechRecognitionManagerDelegate delegate_;
content::FakeSpeechRecognitionManager fake_speech_recognition_manager_;
+
+#if BUILDFLAG(IS_CHROMEOS)
+ base::test::ScopedFeatureList scoped_feature_list_;
+#endif
};
class SpeechWebContentsObserver : public content::WebContentsObserver {
@@ -217,4 +235,58 @@
}
#endif
+#if !BUILDFLAG(IS_ANDROID)
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest,
+ IncognitoRoutesToIncognitoService) {
+ Browser* incognito_browser = CreateIncognitoBrowser();
+ ASSERT_TRUE(incognito_browser);
+
+ struct TestContext {
+ base::OnceClosure quit_closure;
+ bool created = false;
+ };
+ auto context = std::make_unique<TestContext>();
+ base::RunLoop run_loop;
+ context->quit_closure = run_loop.QuitClosure();
+ TestContext* context_ptr = context.get();
+
+ auto testing_factory = base::BindRepeating(
+ [](TestContext* ctx,
+ content::BrowserContext* context) -> std::unique_ptr<KeyedService> {
+ ctx->created = true;
+ if (ctx->quit_closure) {
+ std::move(ctx->quit_closure).Run();
+ }
+ return std::make_unique<FakeSpeechRecognitionService>();
+ },
+ base::Unretained(context_ptr));
+
+#if BUILDFLAG(IS_CHROMEOS)
+ CrosSpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory(
+ incognito_browser->profile(), std::move(testing_factory));
+#else
+ SpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory(
+ incognito_browser->profile(), std::move(testing_factory));
+#endif
+
+ WebContents* web_contents =
+ incognito_browser->tab_strip_model()->GetActiveWebContents();
+ ASSERT_TRUE(web_contents);
+ content::RenderFrameHost* rfh = web_contents->GetPrimaryMainFrame();
+ content::GlobalRenderFrameHostId rfh_id = rfh->GetGlobalId();
+
+ mojo::Remote<media::mojom::SpeechRecognitionContext> remote;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(&content::SpeechRecognitionManagerDelegate::
+ BindSpeechRecognitionContext,
+ base::Unretained(&delegate_),
+ remote.BindNewPipeAndPassReceiver(), "en-US", rfh_id));
+
+ run_loop.Run();
+
+ EXPECT_TRUE(context_ptr->created);
+}
+#endif // !BUILDFLAG(IS_ANDROID)
+
} // namespace speech
Original Bug Report
Cross-profile/incognito isolation bypass in on-device Web Speech recognition
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 vulnerability exists where on-device Web Speech recognition can route Mojo pipes from one profile to the SODA utility process of a different profile. This is caused by the browser-process singleton caching a single per-profile remote and the delegate resolving the wrong profile using a fallback method. If successfully exploited, this could allow a compromised renderer to bypass incognito or cross-profile isolation.
Affected files:
content/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/browser/speech/speech_recognition_manager_impl.h
Estimated timestamp from git blame: 2024-08-01
Summary
A potential cross-profile and incognito isolation bypass exists in the on-device Web Speech recognition pathway. Two compounding defects can cause renderer-owned Mojo pipes from one profile’s browser context to be misrouted to the kSpeechRecognition utility process of a different profile.
Root Causes
-
Process-wide singleton caches a per-profile Mojo Remote: The
SpeechRecognitionManagerImplclass is a browser-process singleton. It contains a membermojo::Remote<media::mojom::SpeechRecognitionContext> speech_recognition_context_(content/browser/speech/speech_recognition_manager_impl.h), which is bound once behind anis_bound()guard and never reset:// content/browser/speech/speech_recognition_manager_impl.cc if (!speech_recognition_context_.is_bound()) { ... speech_recognition_mgr_delegate->BindSpeechRecognitionContext( std::move(speech_recognition_context_receiver), config.language); }Because it is bound only on the first request and subsequently reused, all future on-device Web Speech requests browser-wide from any profile will reuse this single cached Remote.
-
Delegate resolves the wrong profile context: Even on the initial bind, the delegate does not receive or use the originating frame’s
BrowserContext. Instead, it resolves the profile using a fallback method:// chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc auto* profile = ProfileManager::GetLastUsedProfileIfLoaded(); auto* factory = SpeechRecognitionServiceFactory::GetForProfile(profile); factory->BindSpeechRecognitionContext(std::move(receiver));ProfileManager::GetLastUsedProfileIfLoaded()returns the profile corresponding to the most recently activated on-disk window, which does not necessarily match the requesting frame’s profile and will never return an Off-The-Record (Incognito) profile.
Potential Attack Steps
Note: These are potential steps as we have not executed functional proof-of-concept exploit code:
- SODA (Speech On-Device API) is installed on the user’s system.
- A compromised renderer running in Profile B (e.g., an Incognito window or a secondary profile) invokes
media::mojom::SpeechRecognizer::Startwithon_device=trueover theSpeechRecognitionDispatcherHostinterface. - The browser process receives the request and, in
SpeechRecognitionManagerImpl::CreateSession, triggers the first-time bind ofspeech_recognition_context_if it is currently unbound. - The delegate resolves the profile using
ProfileManager::GetLastUsedProfileIfLoaded(), which returns Profile A (the last active regular profile). - The Mojo pipes (
session_receiver,client_remote,audio_forwarder) originating from Profile B are forwarded directly into Profile A’s SODA utility process. - Any subsequent on-device Web Speech requests from any profile reuse the cached
speech_recognition_context_, permanently routing their sessions into Profile A’s utility process.
Impact
A compromised renderer in one profile can bypass the cross-profile and incognito boundaries to interact with another profile’s utility process. Standalone impact is limited, but if chained with a separate memory corruption primitive within the SODA utility process, it could elevate a single-profile compromise to a cross-profile or regular-profile compromise from an Incognito session.
Suggested Fix
- Pass the initiating frame’s
BrowserContextor its identifier down through theSpeechRecognitionSessionConfigduring session creation. - Update
SpeechRecognitionManagerImplto maintain a map ofSpeechRecognitionContextremotes keyed byBrowserContext(or manage the remote lifecycle within the delegate). - Update the delegate’s
BindSpeechRecognitionContextmethod signature to accept theBrowserContextand bind to the correct profile-keyed service instance rather than falling back toGetLastUsedProfileIfLoaded().
Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf
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.