CVE-2026-7960
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/speech/chrome_speech_recognition_manager_delegate.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/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 714840a3c391efdd6720a87095b654c97661afbd Mon Sep 17 00:00:00 2001
From: Evan Liu <evliu@google.com>
Date: Mon, 30 Mar 2026 16:44:10 -0700
Subject: [PATCH] Fix TOCTOU permission bypass in speech recognition
A Time-of-Check Time-of-Use (TOCTOU) race condition in the speech
recognition startup flow allowed a compromised renderer to bypass
microphone permission prompts by detaching the requesting iframe.
This CL restricts unprompted access for null RenderFrameHosts to
browser-initiated requests only (e.g., Chrome OS Dictation),
denying permission if a renderer-initiated frame is missing.
Fixed: 497007825
Change-Id: Iaa3d7a249fe10537a87e9385e8f60fe7e176743c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7708519
Reviewed-by: Victor Miura <vmiura@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
Cr-Commit-Position: refs/heads/main@{#1607449}
---
diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
index 1d521ff..ecc5438 100644
--- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
+++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
@@ -14,6 +14,7 @@
#include "components/prefs/pref_service.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
+#include "content/public/browser/child_process_host.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/speech_recognition_manager.h"
@@ -24,6 +25,7 @@
#if BUILDFLAG(ENABLE_EXTENSIONS)
#include "chrome/browser/extensions/extension_service.h"
+#include "extensions/browser/process_map.h"
#include "extensions/browser/view_type_utils.h"
#include "extensions/common/mojom/view_type.mojom.h"
#endif
@@ -173,9 +175,23 @@
bool check_permission = false;
if (!render_frame_host) {
- // This happens for extensions. Manifest should be checked for permission.
- allowed = true;
- check_permission = false;
+ if (render_process_id == content::ChildProcessHost::kInvalidUniqueID) {
+ // This happens for browser-initiated requests (e.g. Chrome OS Dictation).
+ allowed = true;
+ } else {
+ bool is_extension = false;
+ #if BUILDFLAG(ENABLE_EXTENSIONS)
+ content::RenderProcessHost* render_process_host =
+ content::RenderProcessHost::FromID(render_process_id);
+ if (render_process_host) {
+ is_extension = extensions::ProcessMap::Get(
+ render_process_host->GetBrowserContext())
+ ->Contains(render_process_id);
+ }
+ #endif
+ // Allow if it's a valid extension; otherwise deny (frame destroyed/invalid).
+ allowed = is_extension;
+ }
content::GetIOThreadTaskRunner({})->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback), check_permission, allowed));
diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h
index d48ab374..501ceef 100644
--- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h
+++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h
@@ -17,6 +17,8 @@
: public content::SpeechRecognitionManagerDelegate,
public content::SpeechRecognitionEventListener {
public:
+ friend class ChromeSpeechRecognitionTest;
+
ChromeSpeechRecognitionManagerDelegate();
ChromeSpeechRecognitionManagerDelegate(
diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc
index 5eda7c6..cf21955 100644
--- a/chrome/browser/speech/speech_recognition_browsertest.cc
+++ b/chrome/browser/speech/speech_recognition_browsertest.cc
@@ -5,18 +5,28 @@
#include <memory>
#include "base/strings/utf_string_conversions.h"
+#include "base/task/bind_post_task.h"
+#include "base/test/test_future.h"
#include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h"
#include "chrome/browser/ui/browser.h"
#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"
#include "chrome/test/base/ui_test_utils.h"
+#include "content/public/browser/browser_task_traits.h"
+#include "content/public/browser/browser_thread.h"
#include "content/public/browser/web_contents_observer.h"
#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 "net/test/embedded_test_server/embedded_test_server.h"
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+#include "chrome/browser/extensions/extension_service.h"
+#include "chrome/browser/profiles/profile.h"
+#include "extensions/browser/process_map.h"
+#endif
+
using content::FakeSpeechRecognitionManager;
using content::WebContents;
@@ -32,6 +42,14 @@
~ChromeSpeechRecognitionTest() override = default;
+ static void CheckRenderFrameType(
+ base::OnceCallback<void(bool ask_user, bool is_allowed)> callback,
+ int render_process_id,
+ int render_frame_id) {
+ ChromeSpeechRecognitionManagerDelegate::CheckRenderFrameType(
+ std::move(callback), render_process_id, render_frame_id);
+ }
+
void SetUp() override {
// SpeechRecognition test specific SetUp.
fake_speech_recognition_manager_.set_should_send_fake_response(true);
@@ -140,4 +158,62 @@
EXPECT_TRUE(speech_contents_observer.web_contents_destroyed());
}
+// Tests the TOCTOU race condition where an iframe is detached between
+// StartRequestOnUI and CheckRenderFrameType, causing the RFH to be null.
+// The safe fallback logic should securely deny permission.
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest, TOCTOUPermissionBypass) {
+ base::test::TestFuture<bool /* ask_user */, bool /* is_allowed */> future;
+
+ int process_id = browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->GetID()
+ .GetUnsafeValue();
+
+ // Call CheckRenderFrameType directly on the UI thread with an invalid RFH ID
+ // but a valid renderer process ID to simulate a detached iframe.
+ CheckRenderFrameType(
+ base::BindPostTask(content::GetUIThreadTaskRunner({}),
+ future.GetCallback()),
+ process_id, -1);
+
+ // Wait for the callback and validate the safe logic.
+ // Get<0>() is ask_user, Get<1>() is is_allowed.
+ EXPECT_FALSE(future.Get<0>());
+ EXPECT_FALSE(future.Get<1>());
+}
+
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+// Verifies that extension background pages/service workers are successfully
+// granted permission despite having a null RenderFrameHost.
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest,
+ ExtensionBackgroundPageAllowed) {
+ base::test::TestFuture<bool /* ask_user */, bool /* is_allowed */> future;
+
+ int process_id = browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->GetID()
+ .GetUnsafeValue();
+
+ extensions::ProcessMap::Get(browser()->profile())
+ ->Insert("fake_extension_id", process_id);
+
+ // Call CheckRenderFrameType with a missing frame, which is typical for
+ // extension background pages or service workers.
+ CheckRenderFrameType(base::BindPostTask(content::GetUIThreadTaskRunner({}),
+ future.GetCallback()),
+ process_id, -1);
+
+ // For extensions, ask_user should be false (manifest checks apply instead)
+ // and is_allowed should be true.
+ EXPECT_FALSE(future.Get<0>());
+ EXPECT_TRUE(future.Get<1>());
+}
+#endif
+
} // namespace speech
Regression Test / PoC
diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc
index 5eda7c6..cf21955 100644
--- a/chrome/browser/speech/speech_recognition_browsertest.cc
+++ b/chrome/browser/speech/speech_recognition_browsertest.cc
@@ -5,18 +5,28 @@
#include <memory>
#include "base/strings/utf_string_conversions.h"
+#include "base/task/bind_post_task.h"
+#include "base/test/test_future.h"
#include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h"
#include "chrome/browser/ui/browser.h"
#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"
#include "chrome/test/base/ui_test_utils.h"
+#include "content/public/browser/browser_task_traits.h"
+#include "content/public/browser/browser_thread.h"
#include "content/public/browser/web_contents_observer.h"
#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 "net/test/embedded_test_server/embedded_test_server.h"
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+#include "chrome/browser/extensions/extension_service.h"
+#include "chrome/browser/profiles/profile.h"
+#include "extensions/browser/process_map.h"
+#endif
+
using content::FakeSpeechRecognitionManager;
using content::WebContents;
@@ -32,6 +42,14 @@
~ChromeSpeechRecognitionTest() override = default;
+ static void CheckRenderFrameType(
+ base::OnceCallback<void(bool ask_user, bool is_allowed)> callback,
+ int render_process_id,
+ int render_frame_id) {
+ ChromeSpeechRecognitionManagerDelegate::CheckRenderFrameType(
+ std::move(callback), render_process_id, render_frame_id);
+ }
+
void SetUp() override {
// SpeechRecognition test specific SetUp.
fake_speech_recognition_manager_.set_should_send_fake_response(true);
@@ -140,4 +158,62 @@
EXPECT_TRUE(speech_contents_observer.web_contents_destroyed());
}
+// Tests the TOCTOU race condition where an iframe is detached between
+// StartRequestOnUI and CheckRenderFrameType, causing the RFH to be null.
+// The safe fallback logic should securely deny permission.
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest, TOCTOUPermissionBypass) {
+ base::test::TestFuture<bool /* ask_user */, bool /* is_allowed */> future;
+
+ int process_id = browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->GetID()
+ .GetUnsafeValue();
+
+ // Call CheckRenderFrameType directly on the UI thread with an invalid RFH ID
+ // but a valid renderer process ID to simulate a detached iframe.
+ CheckRenderFrameType(
+ base::BindPostTask(content::GetUIThreadTaskRunner({}),
+ future.GetCallback()),
+ process_id, -1);
+
+ // Wait for the callback and validate the safe logic.
+ // Get<0>() is ask_user, Get<1>() is is_allowed.
+ EXPECT_FALSE(future.Get<0>());
+ EXPECT_FALSE(future.Get<1>());
+}
+
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+// Verifies that extension background pages/service workers are successfully
+// granted permission despite having a null RenderFrameHost.
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest,
+ ExtensionBackgroundPageAllowed) {
+ base::test::TestFuture<bool /* ask_user */, bool /* is_allowed */> future;
+
+ int process_id = browser()
+ ->tab_strip_model()
+ ->GetActiveWebContents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->GetID()
+ .GetUnsafeValue();
+
+ extensions::ProcessMap::Get(browser()->profile())
+ ->Insert("fake_extension_id", process_id);
+
+ // Call CheckRenderFrameType with a missing frame, which is typical for
+ // extension background pages or service workers.
+ CheckRenderFrameType(base::BindPostTask(content::GetUIThreadTaskRunner({}),
+ future.GetCallback()),
+ process_id, -1);
+
+ // For extensions, ask_user should be false (manifest checks apply instead)
+ // and is_allowed should be true.
+ EXPECT_FALSE(future.Get<0>());
+ EXPECT_TRUE(future.Get<1>());
+}
+#endif
+
} // namespace speech
Original Bug Report
Microphone permission bypass via TOCTOU in SpeechRecognitionManagerDelegate
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A Time-of-Check Time-of-Use (TOCTOU) race condition in the speech recognition startup flow allows a compromised renderer to potentially bypass the microphone permission prompt. By detaching a requesting iframe during the asynchronous startup process, the browser incorrectly grants access under the assumption that the request originated from a privileged extension. This results in silent, untracked audio capture from the user’s default microphone.
Affected files:
chrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/browser/speech/speech_recognition_manager_impl.cccontent/browser/speech/speech_recognition_dispatcher_host.cccontent/browser/speech/speech_recognizer_impl.cc
Estimated timestamp from git blame: 2021-07-14
Microphone permission bypass via TOCTOU between StartRequestOnUI and CheckRenderFrameType FromID checks
A potential Time-of-Check Time-of-Use (TOCTOU) race condition exists in the speech recognition implementation that could allow a compromised renderer to bypass the microphone permission prompt and silently capture user audio transcripts. This represents a critical capability bypass for microphone access.
Technical Summary
When a speech recognition request is initiated via media::mojom::SpeechRecognizer::Start(), the following asynchronous sequence occurs across thread hops:
- Initial Check (UI Thread):
SpeechRecognitionDispatcherHost::StartRequestOnUIis called on the UI thread. It performs aRenderFrameHost::FromIDcheck to ensure the frame exists and gathers context before returning to the IO thread. - Session Creation (IO Thread):
SpeechRecognitionManagerImpl::CreateSessionallocates a new session. It posts a task to the UI thread (FrameSessionTracker::CreateObserverForSession) to monitor the frame’s lifetime and clean up the session upon frame destruction. - Permission Check (UI Thread): The flow continues via
ChromeSpeechRecognitionManagerDelegate::CheckRenderFrameTypeback on the UI thread to determine if a permission prompt is required.
The Race Condition
A compromised renderer can initiate a request and then immediately detach the child iframe (e.g., via document.body.removeChild(iframe)). If the RenderFrameHost is destroyed on the UI thread after step 1 but before the checks in steps 2 and 3, two critical logic errors are triggered.
First, FrameSessionTracker::CreateObserverForSession (content/browser/speech/speech_recognition_manager_impl.cc:108) silently returns early because RenderFrameHost::FromID returns null. The session is now untracked and will not be automatically aborted.
Second, CheckRenderFrameType (chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc:175-183) implements flawed fallback logic:
if (!render_frame_host) {
// This happens for extensions. Manifest should be checked for permission.
allowed = true;
check_permission = false;
content::GetIOThreadTaskRunner({})->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback), check_permission, allowed));
return;
}
The code incorrectly assumes that a null RenderFrameHost implies the request originated from a privileged extension, granting permission by default (allowed = true, check_permission = false). In reality, a null value here indicates that the frame has been destroyed during the race window.
Exploitation and Impact
Upon returning to the IO thread, SpeechRecognitionManagerImpl::RecognitionAllowedCallback receives ask_user = false and is_allowed = true. It dispatches EVENT_START and entirely skips calling MakeMediaAccessRequest, which means the permission UI is bypassed and site settings are ignored.
When SessionStart is called (speech_recognition_manager_impl.cc:890), it finds an empty list of authorized devices (since the prompt was skipped) and falls back to the user’s default audio input device (kDefaultDeviceId). SpeechRecognizerImpl::CreateAudioCapturerSource (speech_recognizer_impl.cc:823) then binds directly to the audio service and begins silent microphone capture.
Because the session is untracked (step 2 failed), and SpeechRecognitionDispatcherHost is self-owned on the IO thread, the leaked session continues to transcribe audio indefinitely. The transcripts are returned to the renderer-controlled SpeechRecognitionSessionClient remote, which the attacker still controls from the main frame.
Suggested Steps to Reproduce
Note: These are potential steps, our tooling agent doesn’t yet have the ability to run code to confirm them.
- From a compromised renderer process, create a child
iframeand request themedia::mojom::SpeechRecognizerinterface via theBrowserInterfaceBroker. - Send a
Start()request withaudio_forwarderunset (forcinguse_microphone = true) and provide a renderer-controlledSpeechRecognitionSessionClientremote. - Immediately detach the child
iframe. - If the race is won (the detach lands on the UI thread after
StartRequestOnUIbut beforeCheckRenderFrameType),CheckRenderFrameTypewill encounter a nullRenderFrameHostand incorrectly grant access. - Observe that the default microphone is activated without any user prompt, and transcripts are received by the renderer’s client remote. Spawning multiple iframes and repeating this pattern can make the race condition highly reliable.
Suggested Fix
Do not rely solely on the absence of a RenderFrameHost to authorize extension requests in CheckRenderFrameType. Instead, explicitly check the requesting process’s context or origin to verify it is indeed a privileged extension before granting allowed = true. Additionally, if a request requires a valid RenderFrameHost and it becomes null during the startup flow, the session should be immediately aborted rather than proceeding with default permissions.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.