Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in Speech
DescriptionRace in Speech
ComponentSpeech
Bug ClassRace
Tracker497007825
Fix commit714840a3c391 (chromium/src) +97/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc
modified
IN_PROC_BROWSER_TEST_F
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 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
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 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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • content/browser/speech/speech_recognition_manager_impl.cc
  • content/browser/speech/speech_recognition_dispatcher_host.cc
  • content/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:

  1. Initial Check (UI Thread): SpeechRecognitionDispatcherHost::StartRequestOnUI is called on the UI thread. It performs a RenderFrameHost::FromID check to ensure the frame exists and gathers context before returning to the IO thread.
  2. Session Creation (IO Thread): SpeechRecognitionManagerImpl::CreateSession allocates 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.
  3. Permission Check (UI Thread): The flow continues via ChromeSpeechRecognitionManagerDelegate::CheckRenderFrameType back 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.

  1. From a compromised renderer process, create a child iframe and request the media::mojom::SpeechRecognizer interface via the BrowserInterfaceBroker.
  2. Send a Start() request with audio_forwarder unset (forcing use_microphone = true) and provide a renderer-controlled SpeechRecognitionSessionClient remote.
  3. Immediately detach the child iframe.
  4. If the race is won (the detach lands on the UI thread after StartRequestOnUI but before CheckRenderFrameType), CheckRenderFrameType will encounter a null RenderFrameHost and incorrectly grant access.
  5. 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.

View on issue tracker