CVE-2026-17816
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
MockSpeechRecognitionSessionClientcontent/browser/speech/speech_recognition_browsertest.cc |
modified | |
ifcontent/browser/speech/speech_recognition_browsertest.cc |
modified | |
BindNewPipeAndPassRemotecontent/browser/speech/speech_recognition_browsertest.cc |
modified | |
SpeechRecognitionBrowserTestcontent/browser/speech/speech_recognition_browsertest.cc |
modified |
Files Changed
content/browser/speech/speech_recognition_browsertest.cc
Patch
From 9b99392cdb6dee18952535c61e054faeda5c67f8 Mon Sep 17 00:00:00 2001
From: Evan Liu <evliu@google.com>
Date: Mon, 15 Jun 2026 21:56:49 -0700
Subject: [PATCH] [Web Speech] Enforce visibility checks in browser process on Android
Previously, Web Speech API recognition sessions on Android relied solely
on the Blink renderer to abort when the page became hidden. A
compromised renderer could bypass this client-side check to silently
capture microphone audio in the background.
This change adds defense-in-depth enforcement to the browser process:
1. SpeechRecognitionDispatcherHost now rejects new session start requests if the associated WebContents is hidden.
2. FrameSessionTracker now acts as a WebContentsObserver to
automatically terminate active sessions when the tab transitions to a
hidden state.
Tests simulating a compromised renderer via direct Mojo binding have
been added to verify both the start-request rejection and the
active-session termination behaviors.
Fixed: 517429672
Change-Id: I8d33630c387c76a01c3703331ab08bc0e70104bf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7911041
Reviewed-by: Bo Liu <boliu@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
Cr-Commit-Position: refs/heads/main@{#1647320}
---
diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc
index f2e97ce..4c23229 100644
--- a/content/browser/speech/speech_recognition_browsertest.cc
+++ b/content/browser/speech/speech_recognition_browsertest.cc
@@ -24,6 +24,7 @@
#include "base/task/single_thread_task_runner.h"
#include "build/build_config.h"
#include "content/browser/speech/network_speech_recognition_engine_impl.h"
+#include "content/browser/speech/speech_recognition_dispatcher_host.h"
#include "content/browser/speech/speech_recognition_manager_impl.h"
#include "content/browser/speech/speech_recognizer_impl.h"
#include "content/public/browser/browser_task_traits.h"
@@ -188,6 +189,103 @@
return msg_string;
}
+class MockSpeechRecognitionSessionClient
+ : public media::mojom::SpeechRecognitionSessionClient {
+ public:
+ MockSpeechRecognitionSessionClient() = default;
+ ~MockSpeechRecognitionSessionClient() override = default;
+
+ void ResultRetrieved(std::vector<media::mojom::WebSpeechRecognitionResultPtr>
+ results) override {}
+
+ void ErrorOccurred(media::mojom::SpeechRecognitionErrorPtr error) override {
+ event_occurred_ = true;
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void Started() override {
+ started_occurred_ = true;
+ event_occurred_ = true;
+ if (started_closure_) {
+ std::move(started_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void AudioStarted() override {}
+ void SoundStarted() override {}
+ void SoundEnded() override {}
+ void AudioEnded() override {}
+
+ void Ended() override {
+ ended_occurred_ = true;
+ event_occurred_ = true;
+ if (ended_closure_) {
+ std::move(ended_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void WaitForStarted() {
+ if (started_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ started_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void WaitForEnded() {
+ if (ended_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ ended_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void WaitForEvent() {
+ if (event_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ event_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void OnDisconnected() {
+ event_occurred_ = true;
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ mojo::PendingRemote<media::mojom::SpeechRecognitionSessionClient>
+ BindNewPipeAndPassRemote() {
+ auto remote = receiver_.BindNewPipeAndPassRemote();
+ receiver_.set_disconnect_handler(
+ base::BindOnce(&MockSpeechRecognitionSessionClient::OnDisconnected,
+ base::Unretained(this)));
+ return remote;
+ }
+
+ private:
+ mojo::Receiver<media::mojom::SpeechRecognitionSessionClient> receiver_{this};
+ base::OnceClosure started_closure_;
+ base::OnceClosure ended_closure_;
+ base::OnceClosure event_closure_;
+
+ bool started_occurred_ = false;
+ bool ended_occurred_ = false;
+ bool event_occurred_ = false;
+};
+
} // namespace
class SpeechRecognitionBrowserTest : public ContentBrowserTest {
@@ -438,6 +536,94 @@
global_id));
}
+#if BUILDFLAG(IS_ANDROID)
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ CompromisedRendererVisibilityBypass) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ mojo::Remote<media::mojom::SpeechRecognizer> speech_recognizer;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ &SpeechRecognitionDispatcherHost::Create,
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId(),
+ speech_recognizer.BindNewPipeAndPassReceiver()));
+
+ MockSpeechRecognitionSessionClient client;
+ media::mojom::StartSpeechRecognitionRequestParamsPtr params =
+ media::mojom::StartSpeechRecognitionRequestParams::New();
+ params->client = client.BindNewPipeAndPassRemote();
+ mojo::Remote<media::mojom::SpeechRecognitionSession> session_remote;
+ params->session_receiver = session_remote.BindNewPipeAndPassReceiver();
+
+ speech_recognizer->Start(std::move(params));
+
+ // Wait for the session to be fully started and tracked by the manager.
+ client.WaitForStarted();
+
+ // Verify the session is tracked.
+ EXPECT_EQ(1,
+ SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+
+ // Hide the WebContents to simulate the user switching tabs or backgrounding
+ // Chrome.
+ shell()->web_contents()->WasHidden();
+
+ // Wait for the browser process to abort the session and signal the client.
+ client.WaitForEvent();
+
+ // Without the fix, the session tracker count would NOT be 0 because
+ // SpeechRecognitionManagerImpl did not observe visibility changes.
+ // The test asserts it is 0 to ensure the secure behavior is enforced.
+ EXPECT_EQ(0,
+ SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+}
+
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ CompromisedRendererStartWhileHiddenBypass) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
Regression Test / PoC
diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc
index f2e97ce..4c23229 100644
--- a/content/browser/speech/speech_recognition_browsertest.cc
+++ b/content/browser/speech/speech_recognition_browsertest.cc
@@ -24,6 +24,7 @@
#include "base/task/single_thread_task_runner.h"
#include "build/build_config.h"
#include "content/browser/speech/network_speech_recognition_engine_impl.h"
+#include "content/browser/speech/speech_recognition_dispatcher_host.h"
#include "content/browser/speech/speech_recognition_manager_impl.h"
#include "content/browser/speech/speech_recognizer_impl.h"
#include "content/public/browser/browser_task_traits.h"
@@ -188,6 +189,103 @@
return msg_string;
}
+class MockSpeechRecognitionSessionClient
+ : public media::mojom::SpeechRecognitionSessionClient {
+ public:
+ MockSpeechRecognitionSessionClient() = default;
+ ~MockSpeechRecognitionSessionClient() override = default;
+
+ void ResultRetrieved(std::vector<media::mojom::WebSpeechRecognitionResultPtr>
+ results) override {}
+
+ void ErrorOccurred(media::mojom::SpeechRecognitionErrorPtr error) override {
+ event_occurred_ = true;
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void Started() override {
+ started_occurred_ = true;
+ event_occurred_ = true;
+ if (started_closure_) {
+ std::move(started_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void AudioStarted() override {}
+ void SoundStarted() override {}
+ void SoundEnded() override {}
+ void AudioEnded() override {}
+
+ void Ended() override {
+ ended_occurred_ = true;
+ event_occurred_ = true;
+ if (ended_closure_) {
+ std::move(ended_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ void WaitForStarted() {
+ if (started_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ started_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void WaitForEnded() {
+ if (ended_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ ended_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void WaitForEvent() {
+ if (event_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ event_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ void OnDisconnected() {
+ event_occurred_ = true;
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
+ mojo::PendingRemote<media::mojom::SpeechRecognitionSessionClient>
+ BindNewPipeAndPassRemote() {
+ auto remote = receiver_.BindNewPipeAndPassRemote();
+ receiver_.set_disconnect_handler(
+ base::BindOnce(&MockSpeechRecognitionSessionClient::OnDisconnected,
+ base::Unretained(this)));
+ return remote;
+ }
+
+ private:
+ mojo::Receiver<media::mojom::SpeechRecognitionSessionClient> receiver_{this};
+ base::OnceClosure started_closure_;
+ base::OnceClosure ended_closure_;
+ base::OnceClosure event_closure_;
+
+ bool started_occurred_ = false;
+ bool ended_occurred_ = false;
+ bool event_occurred_ = false;
+};
+
} // namespace
class SpeechRecognitionBrowserTest : public ContentBrowserTest {
@@ -438,6 +536,94 @@
global_id));
}
+#if BUILDFLAG(IS_ANDROID)
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ CompromisedRendererVisibilityBypass) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ mojo::Remote<media::mojom::SpeechRecognizer> speech_recognizer;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ &SpeechRecognitionDispatcherHost::Create,
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId(),
+ speech_recognizer.BindNewPipeAndPassReceiver()));
+
+ MockSpeechRecognitionSessionClient client;
+ media::mojom::StartSpeechRecognitionRequestParamsPtr params =
+ media::mojom::StartSpeechRecognitionRequestParams::New();
+ params->client = client.BindNewPipeAndPassRemote();
+ mojo::Remote<media::mojom::SpeechRecognitionSession> session_remote;
+ params->session_receiver = session_remote.BindNewPipeAndPassReceiver();
+
+ speech_recognizer->Start(std::move(params));
+
+ // Wait for the session to be fully started and tracked by the manager.
+ client.WaitForStarted();
+
+ // Verify the session is tracked.
+ EXPECT_EQ(1,
+ SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+
+ // Hide the WebContents to simulate the user switching tabs or backgrounding
+ // Chrome.
+ shell()->web_contents()->WasHidden();
+
+ // Wait for the browser process to abort the session and signal the client.
+ client.WaitForEvent();
+
+ // Without the fix, the session tracker count would NOT be 0 because
+ // SpeechRecognitionManagerImpl did not observe visibility changes.
+ // The test asserts it is 0 to ensure the secure behavior is enforced.
+ EXPECT_EQ(0,
+ SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+}
+
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ CompromisedRendererStartWhileHiddenBypass) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ // Hide the WebContents to simulate the user switching tabs or backgrounding
+ // Chrome.
+ shell()->web_contents()->WasHidden();
+
+ mojo::Remote<media::mojom::SpeechRecognizer> speech_recognizer;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ &SpeechRecognitionDispatcherHost::Create,
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId(),
+ speech_recognizer.BindNewPipeAndPassReceiver()));
+
+ MockSpeechRecognitionSessionClient client;
+ media::mojom::StartSpeechRecognitionRequestParamsPtr params =
+ media::mojom::StartSpeechRecognitionRequestParams::New();
+ params->client = client.BindNewPipeAndPassRemote();
+ mojo::Remote<media::mojom::SpeechRecognitionSession> session_remote;
+ params->session_receiver = session_remote.BindNewPipeAndPassReceiver();
+
+ speech_recognizer->Start(std::move(params));
+
+ // Wait for the session to either start (vulnerable) or error out/end
+ // (secure).
+ client.WaitForEvent();
+
+ // Without the fix, the session tracker count would NOT be 0
+ // even though the page is hidden, because SpeechRecognitionManagerImpl
+ // did not observe visibility changes during Start.
+ // The test asserts it is 0 to ensure the secure behavior is enforced.
+ EXPECT_EQ(0,
+ SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ shell()->web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+}
+#endif
+
#if !BUILDFLAG(IS_FUCHSIA)
IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
OnDeviceWebSpeechRecognition) {
Original Bug Report
Android Web Speech: Potential lack of browser-side visibility validation allows background mic capture
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: On Chrome for Android, active Web Speech recognition sessions are intended to abort when a page becomes hidden. However, this restriction is only enforced on the renderer side in Blink, which can be bypassed by a compromised renderer to capture microphone audio in the background.
Affected files:
content/browser/speech/speech_recognition_dispatcher_host.ccthird_party/blink/renderer/modules/speech/speech_recognition.cccontent/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/browser/speech/speech_recognizer_impl_android.cc
Estimated timestamp from git blame: 2014-07-24
Summary
We have identified a potential logic and permission usage-restriction bypass in Chrome for Android’s Web Speech API implementation. While Blink includes a client-side mechanism to abort active speech recognition sessions when a page transitions to a hidden state on Android, the browser process performs no corresponding visibility validation. A compromised renderer can bypass this check, allowing a hidden tab to potentially capture microphone audio and receive live speech transcripts.
Please note that these findings are based on a static analysis of the codebase, as we currently lack the tooling to execute code or run a working Proof of Concept (PoC) on a live target.
Affected Files and Components
third_party/blink/renderer/modules/speech/speech_recognition.cccontent/browser/speech/speech_recognition_dispatcher_host.cccontent/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/browser/speech/speech_recognizer_impl_android.cc
Detailed Analysis and Root Cause
On Android, Blink is designed to abort an active Web Speech recognition session when the page becomes hidden (for example, when the user switches tabs or backgrounds Chrome):
// third_party/blink/renderer/modules/speech/speech_recognition.cc
void SpeechRecognition::PageVisibilityChanged() {
#if BUILDFLAG(IS_ANDROID)
if (!GetPage()->IsPageVisible())
abort();
#endif
}
When abort() is called, Blink sends a SpeechRecognitionSession::Abort message over Mojo. However, this renderer-side call is the only mechanism that terminates the browser-process recognition session when the page transitions to a hidden state on Android.
The browser process does not perform any visibility validation of its own for Web Speech recognition:
SpeechRecognitionDispatcherHost(content/browser/speech/speech_recognition_dispatcher_host.cc) resolves theRenderFrameHost, captures the origin, and checks permissions, but does not consult or check page visibility during the session lifecycle or start.SpeechRecognitionManagerImpl(content/browser/speech/speech_recognition_manager_impl.cc) andChromeSpeechRecognitionManagerDelegate(chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc) check for prerendering and view types, but do not verify whether the requesting frame/tab is currently hidden.FrameSessionTracker(content/browser/speech/speech_recognition_manager_impl.cc) only aborts the session upon document destruction (DocumentUserDatalifecycle) and does not observe visibility changes.SpeechRecognizerImplAndroid(content/browser/speech/speech_recognizer_impl_android.cc) does not implement any visibility-based teardown.
In contrast, other similar media or speech output paths (such as the Text-to-Speech controller in content/browser/speech/tts_controller_impl.cc) observe visibility changes browser-side and stop playback when hidden.
Potential Exploit Scenario
Under a compromised-renderer threat model (where an attacker has achieved execution control in a sandboxed renderer process):
- The user visits an attacker-controlled origin and grants microphone permission for the Web Speech API.
- The compromised renderer direct-binds the
media::mojom::SpeechRecognizerMojo interface and initiates a speech recognition session. - The user switches tabs or backgrounds Chrome, causing the tab’s
WebContentsto transition to a hidden state. - An uncompromised Blink instance would normally trigger
PageVisibilityChanged()and send anAbortIPC. The compromised renderer suppresses this IPC. - Because there are no browser-side checks or observers for visibility in the speech recognition path, the session remains active. The Android system’s speech recognizer continues capturing audio, and the browser process continues to stream captured speech transcripts back to the hidden compromised renderer.
- Additionally, because the browser-side start request paths lack visibility checks, a compromised renderer can also start a brand-new recognition session while hidden.
Suggested Remediation
Implement defensive browser-side checks. On Android, SpeechRecognitionDispatcherHost or SpeechRecognitionManagerImpl should track page visibility (for example, by implementing WebContentsObserver or checking RenderFrameHost::GetVisibilityState()) and automatically terminate active sessions when the corresponding page becomes hidden, mirroring the design pattern used in the TTS controller.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.