Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Speech
DescriptionIncorrect reference resolution in Speech
ComponentSpeech
Bug ClassLogic Error
Tracker520481800
Fix commitfca5f84ab5c9 (chromium/src) +139/-35
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
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.cc
  • chrome/browser/speech/chrome_speech_recognition_manager_delegate.h
  • chrome/browser/speech/speech_recognition_browsertest.cc
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_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();
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