Medium watch CVSS 3.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Speech
DescriptionIncorrect reference resolution in Speech
ComponentSpeech
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker520481800
Fix commitfca5f84ab5c9 (chromium/src) +139/-35
CWECWE-706
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-05 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

Background

On-device Web Speech
the path where a page’s Web Speech API recognition session is served by the local speech recognition service (SODA) rather than a network engine, bound through a media::mojom::SpeechRecognitionContext pipe that the browser obtains from the embedder delegate.
`SpeechRecognitionManagerDelegate::BindSpeechRecognitionContext`
the content-layer embedder hook, called on the IO thread, that asks Chrome to bind a SpeechRecognitionContext receiver to the speech service owned by a particular profile.
Profile-keyed services
SpeechRecognitionServiceFactory (and CrosSpeechRecognitionServiceFactory on ChromeOS) return a separate SpeechRecognitionService per Profile, so which profile is passed decides which service instance (regular, Incognito, or another user’s) handles the session.
`GlobalRenderFrameHostId`
a process-plus-routing identifier for a frame that the browser can resolve back to a live RenderFrameHost (and therefore its BrowserContext) via RenderFrameHost::FromID.

Root Cause Analysis

Before the patch, ChromeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext took only a language string and, in its UI-thread task, chose the profile from global state: ProfileManager::GetLastUsedProfileIfLoaded() in ENABLE_BROWSER_SPEECH_SERVICE builds and ProfileManager::GetPrimaryUserProfile() on ChromeOS, never the profile of the frame that started recognition. In addition, the on-device branch in content/browser/speech/speech_recognition_manager_impl.cc cached a single browser-wide speech_recognition_context_ remote in SpeechRecognitionManagerImpl and only bound it when !speech_recognition_context_.is_bound(), so every later session from any profile reused whichever profile’s context had been bound first. Together these broke the invariant that a recognition session is handled by the speech service of the requesting frame’s own BrowserContext, which let Incognito or cross-profile sessions be routed to another profile’s service (as the commit message states).

The fix adds a GlobalRenderFrameHostId parameter to the interface, passes config.initial_context.global_id from both callers, resolves the RenderFrameHost on the UI thread and derives the Profile from rfh->GetBrowserContext(), and drops the request entirely if the id is null, the frame is gone, or no profile results. It also removes the cached speech_recognition_context_ member and binds a fresh local mojo::Remote<media::mojom::SpeechRecognitionContext> for each session. The enclosing function names for the speech_recognition_manager_impl.cc and soda_speech_recognition_engine_impl.cc hunks are not shown in the diff.

Key insight
The browser picked the speech service’s profile from ambient global state (“last used” / “primary” profile) and a cached singleton remote, not from the requesting frame. The fix binds each session’s context to the profile derived from the initiating frame’s GlobalRenderFrameHostId, and fails closed when that frame cannot be resolved.

Attack Path

  1. Two profiles active The user has more than one profile context open, for example a regular window and an Incognito window, in a build with ENABLE_SPEECH_SERVICE on a non-Android platform where on-device Web Speech is used.
  2. First session pins the context A page in one profile starts an on-device Web Speech session; pre-patch the delegate bound the context to the last-used or primary profile, and SpeechRecognitionManagerImpl cached that remote for all later sessions.
  3. Session from another profile A page in a different profile (such as Incognito) starts recognition through the normal Web Speech API; its BindWebSpeechRecognizer call went through the cached context and was served by the other profile’s SpeechRecognitionService.
  4. Cross-profile handling Audio and recognition state for the Incognito or second-profile session were processed in the wrong profile’s service, breaking profile isolation (privacy consequences beyond misrouting are (inferred)).

Impact Assessment

The flaw lets a speech recognition session from one profile, notably Incognito, be served by another profile’s on-device speech service, which undermines the separation Chrome keeps between profiles. Reaching it needs ordinary Web Speech API use (which normally goes through microphone permission) on a non-Android build where on-device recognition is active, and a multi-profile or Incognito setup. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. The advisory states no severity or in-the-wild exploitation beyond “Incorrect reference resolution in Speech”.

Changed Functions

FunctionChangeNotes
if
chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
modified
GetInstanceForTest
chrome/browser/speech/speech_recognition_service_factory.cc
modified
if
content/browser/speech/fake_speech_recognition_manager_delegate.cc
modified
FakeSpeechRecognitionManagerDelegate
content/browser/speech/fake_speech_recognition_manager_delegate.h
modified
SpeechRecognitionEventListener
content/public/browser/speech_recognition_manager_delegate.h
modified
SpeechRecognitionManagerDelegate
content/public/browser/speech_recognition_manager_delegate.h
modified
ChromeSpeechRecognitionTest
chrome/browser/speech/speech_recognition_browsertest.cc
modified
ChromeSpeechRecognitionTest
chrome/browser/speech/speech_recognition_browsertest.cc
modified
SpeechWebContentsObserver
chrome/browser/speech/speech_recognition_browsertest.cc
modified
if
chrome/browser/speech/speech_recognition_browsertest.cc
modified

Files Changed

  • chrome/browser/speech/chrome_speech_recognition_manager_delegate.h
  • chrome/browser/speech/speech_recognition_browsertest.cc
  • content/shell/browser/shell_speech_recognition_manager_delegate.cc
  • chrome/browser/speech/speech_recognition_service_factory.h
  • content/public/browser/speech_recognition_manager_delegate.h
  • content/browser/speech/soda_speech_recognition_engine_impl.cc
  • content/browser/speech/fake_speech_recognition_manager_delegate.h
  • content/browser/speech/fake_speech_recognition_manager_delegate.cc
  • chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
  • content/shell/browser/shell_speech_recognition_manager_delegate.h
  • content/browser/speech/speech_recognition_manager_impl.cc
  • chrome/browser/speech/speech_recognition_service_factory.cc
  • content/browser/speech/speech_recognition_manager_impl.h

Audit Directions

  • Ambient profile selection
    Look for browser code that reaches a ProfileKeyedServiceFactory through ProfileManager::GetLastUsedProfile*, GetPrimaryUserProfile, or similar globals while serving a request that came from a specific frame or worker; the profile should come from the requester’s BrowserContext.
  • Process-wide cached Mojo remotes
    Check singletons such as manager-level mojo::Remote members that are bound once and then shared across requests from different profiles, origins, or frames, since the first binder’s context leaks to every later caller.
  • IO-to-UI thread hops losing context
    When a request is posted from the IO thread to the UI thread, check that the requester identity (GlobalRenderFrameHostId, process id) travels with it and that the UI-side code fails closed when the frame no longer exists.
From fca5f84ab5c990a64af33fa9c295776f98ef3d8e Mon Sep 17 00:00:00 2001
From: Evan Liu <evliu@google.com>
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 <nasko@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
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_service_factory.cc b/chrome/browser/speech/speech_recognition_service_factory.cc
index 11b9e1f5..b837b43c 100644
--- a/chrome/browser/speech/speech_recognition_service_factory.cc
+++ b/chrome/browser/speech/speech_recognition_service_factory.cc
@@ -23,6 +23,12 @@
   return instance.get();
 }
 
+// static
+SpeechRecognitionServiceFactory*
+SpeechRecognitionServiceFactory::GetInstanceForTest() {
+  return GetInstance();
+}
+
 SpeechRecognitionServiceFactory::SpeechRecognitionServiceFactory()
     : ProfileKeyedServiceFactory(
           "SpeechRecognitionService",
diff --git a/chrome/browser/speech/speech_recognition_service_factory.h b/chrome/browser/speech/speech_recognition_service_factory.h
index e73fa12..bbc0846 100644
--- a/chrome/browser/speech/speech_recognition_service_factory.h
+++ b/chrome/browser/speech/speech_recognition_service_factory.h
@@ -20,6 +20,8 @@
  public:
   static speech::SpeechRecognitionService* GetForProfile(Profile* profile);
 
+  static SpeechRecognitionServiceFactory* GetInstanceForTest();
+
   static void EnsureFactoryBuilt();
 
  private:
diff --git a/content/browser/speech/fake_speech_recognition_manager_delegate.cc b/content/browser/speech/fake_speech_recognition_manager_delegate.cc
index 91f80e7..bacf6e1 100644
--- a/content/browser/speech/fake_speech_recognition_manager_delegate.cc
+++ b/content/browser/speech/fake_speech_recognition_manager_delegate.cc
@@ -106,7 +106,8 @@
 
 void FakeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext(
     mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver,
-    const std::string& language) {
+    const std::string& language,
+    const GlobalRenderFrameHostId& render_frame_host_id) {
   if (speech_service_) {
     speech_service_->BindSpeechRecognitionContext(std::move(receiver));
   }
diff --git a/content/browser/speech/fake_speech_recognition_manager_delegate.h b/content/browser/speech/fake_speech_recognition_manager_delegate.h
index 34948e967..0213c86 100644
--- a/content/browser/speech/fake_speech_recognition_manager_delegate.h
+++ b/content/browser/speech/fake_speech_recognition_manager_delegate.h
@@ -98,6 +98,8 @@
       this};
 };
 
+struct GlobalRenderFrameHostId;
+
 class FakeSpeechRecognitionManagerDelegate
     : public SpeechRecognitionManagerDelegate {
  public:
@@ -115,7 +117,8 @@
   SpeechRecognitionEventListener* GetEventListener() override;
   void BindSpeechRecognitionContext(
       mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver,
-      const std::string& language) override;
+      const std::string& language,
+      const GlobalRenderFrameHostId& render_frame_host_id) override;
 
   void Reset(MockOnDeviceWebSpeechRecognitionService* service);
 
diff --git a/content/browser/speech/soda_speech_recognition_engine_impl.cc b/content/browser/speech/soda_speech_recognition_engine_impl.cc
index 01560e8..0b7cf22 100644
--- a/content/browser/speech/soda_speech_recognition_engine_impl.cc
+++ b/content/browser/speech/soda_speech_recognition_engine_impl.cc
@@ -97,7 +97,8 @@
                          weak_factory_.GetWeakPtr())));
 
   speech_recognition_mgr_delegate->BindSpeechRecognitionContext(
-      std::move(speech_recognition_context_receiver), config_.language);
+      std::move(speech_recognition_context_receiver), config_.language,
+      config_.initial_context.global_id);
 
   speech_recognition_context_.set_disconnect_handler(
       base::BindPostTaskToCurrentDefault(base::BindOnce(
diff --git a/content/browser/speech/speech_recognition_manager_impl.cc b/content/browser/speech/speech_recognition_manager_impl.cc
index f8497cc..6de11cc 100644
--- a/content/browser/speech/speech_recognition_manager_impl.cc
+++ b/content/browser/speech/speech_recognition_manager_impl.cc
@@ -713,20 +713,17 @@
     // directly to the speech recognition process and speech recognition events
     // will be returned directly to the renderer, bypassing the browser
     // entirely.
-    if (!speech_recognition_context_.is_bound()) {
-      raw_ptr<SpeechRecognitionManagerDelegate>
-          speech_recognition_mgr_delegate =
-              SpeechRecognitionManagerImpl::GetInstance()
-                  ? SpeechRecognitionManagerImpl::GetInstance()->delegate()
-                  : nullptr;
+    raw_ptr<SpeechRecognitionManagerDelegate> speech_recognition_mgr_delegate =
+        SpeechRecognitionManagerImpl::GetInstance()
+            ? SpeechRecognitionManagerImpl::GetInstance()->delegate()
+            : nullptr;
 
-      CHECK(speech_recognition_mgr_delegate);
-      mojo::PendingReceiver<media::mojom::SpeechRecognitionContext>
-          speech_recognition_context_receiver =
-              speech_recognition_context_.BindNewPipeAndPassReceiver();
-      speech_recognition_mgr_delegate->BindSpeechRecognitionContext(
-          std::move(speech_recognition_context_receiver), config.language);
-    }
+    CHECK(speech_recognition_mgr_delegate);
+    mojo::Remote<media::mojom::SpeechRecognitionContext>
+        speech_recognition_context;
+    speech_recognition_mgr_delegate->BindSpeechRecognitionContext(
+        speech_recognition_context.BindNewPipeAndPassReceiver(),
+        config.language, config.initial_context.global_id);
 
     media::mojom::SpeechRecognitionOptionsPtr options =
         media::mojom::SpeechRecognitionOptions::New();
@@ -739,7 +736,7 @@
     options->recognition_context = config.recognition_context;
     options->allow_multi_language = false;
 
-    speech_recognition_context_->BindWebSpeechRecognizer(
+    speech_recognition_context->BindWebSpeechRecognizer(
         std::move(session_receiver), std::move(client_remote),
         std::move(audio_forwarder_config.value().audio_forwarder),
         audio_forwarder_config.value().channel_count,
diff --git a/content/browser/speech/speech_recognition_manager_impl.h b/content/browser/speech/speech_recognition_manager_impl.h
index b8ff69e6..50161dc 100644
--- a/content/browser/speech/speech_recognition_manager_impl.h
+++ b/content/browser/speech/speech_recognition_manager_impl.h
@@ -229,9 +229,6 @@
   std::unique_ptr<SpeechRecognitionManagerDelegate> delegate_;
   const int requester_id_;
 
-  mojo::Remote<media::mojom::SpeechRecognitionContext>
-      speech_recognition_context_;
-
   // Used for posting asynchronous tasks (on the IO thread) without worrying
   // about this class being destroyed in the meanwhile (due to browser shutdown)
   // since tasks pending on a destroyed WeakPtr are automatically discarded.
diff --git a/content/public/browser/speech_recognition_manager_delegate.h b/content/public/browser/speech_recognition_manager_delegate.h
index f17629d..415e284 100644
--- a/content/public/browser/speech_recognition_manager_delegate.h
+++ b/content/public/browser/speech_recognition_manager_delegate.h
@@ -17,6 +17,8 @@
 
 class SpeechRecognitionEventListener;
 
+struct GlobalRenderFrameHostId;
+
 // Allows embedders to display the current state of recognition, for getting the
 // user's permission and for fetching optional request information.
 class SpeechRecognitionManagerDelegate {
@@ -40,7 +42,8 @@
   // This is called on the IO thread.
   virtual void BindSpeechRecognitionContext(
       mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver,
-      const std::string& language) = 0;
+      const std::string& language,
+      const GlobalRenderFrameHostId& render_frame_host_id) = 0;
 #endif  // !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID)
 };
 
diff --git a/content/shell/browser/shell_speech_recognition_manager_delegate.cc b/content/shell/browser/shell_speech_recognition_manager_delegate.cc
index f2515503..30bee9e5 100644
--- a/content/shell/browser/shell_speech_recognition_manager_delegate.cc
+++ b/content/shell/browser/shell_speech_recognition_manager_delegate.cc
@@ -30,7 +30,8 @@
 #if !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID)
 void ShellSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext(
     mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver,
-    const std::string& language) {}
+    const std::string& language,
+    const GlobalRenderFrameHostId& render_frame_host_id) {}
 #endif  // !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID)
 
 }  // namespace content
diff --git a/content/shell/browser/shell_speech_recognition_manager_delegate.h b/content/shell/browser/shell_speech_recognition_manager_delegate.h
index 549327e..cf75a13 100644
--- a/content/shell/browser/shell_speech_recognition_manager_delegate.h
+++ b/content/shell/browser/shell_speech_recognition_manager_delegate.h
@@ -11,6 +11,8 @@
 
 namespace content {
 
+struct GlobalRenderFrameHostId;
+
 // This is content_shell's delegate used by the speech recognition manager to
 // check for permission to record audio. For content_shell, we always authorize
 // speech recognition (see crbug.com/237119).
@@ -36,7 +38,8 @@
   // It is empty in this delegate.
   void BindSpeechRecognitionContext(
       mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver,
-      const std::string& language) override;
+      const std::string& language,
+      const GlobalRenderFrameHostId& render_frame_host_id) override;
 #endif  //! BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID)
 };
 
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();
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
  • content/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

  1. Process-wide singleton caches a per-profile Mojo Remote: The SpeechRecognitionManagerImpl class is a browser-process singleton. It contains a member mojo::Remote<media::mojom::SpeechRecognitionContext> speech_recognition_context_ (content/browser/speech/speech_recognition_manager_impl.h), which is bound once behind an is_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.

  2. 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:

  1. SODA (Speech On-Device API) is installed on the user’s system.
  2. A compromised renderer running in Profile B (e.g., an Incognito window or a secondary profile) invokes media::mojom::SpeechRecognizer::Start with on_device=true over the SpeechRecognitionDispatcherHost interface.
  3. The browser process receives the request and, in SpeechRecognitionManagerImpl::CreateSession, triggers the first-time bind of speech_recognition_context_ if it is currently unbound.
  4. The delegate resolves the profile using ProfileManager::GetLastUsedProfileIfLoaded(), which returns Profile A (the last active regular profile).
  5. The Mojo pipes (session_receiver, client_remote, audio_forwarder) originating from Profile B are forwarded directly into Profile A’s SODA utility process.
  6. 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

  1. Pass the initiating frame’s BrowserContext or its identifier down through the SpeechRecognitionSessionConfig during session creation.
  2. Update SpeechRecognitionManagerImpl to maintain a map of SpeechRecognitionContext remotes keyed by BrowserContext (or manage the remote lifecycle within the delegate).
  3. Update the delegate’s BindSpeechRecognitionContext method signature to accept the BrowserContext and bind to the correct profile-keyed service instance rather than falling back to GetLastUsedProfileIfLoaded().

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.

View on issue tracker