Medium watch CVSS 5.3 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in Speech
DescriptionRace in Speech
ComponentSpeech
Bug ClassRace
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.
Tracker497007825
Fix commit714840a3c391 (chromium/src) +97/-3
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-27 (per vendor advisory)
Fix landed2026-03-30 (public git)
Disclosed2026-05-05
Patch gap36 days — fix public in source before the advisory

Background

Speech recognition permission flow
before the Web Speech API starts capturing the microphone, Chrome’s ChromeSpeechRecognitionManagerDelegate checks the requesting frame’s type to decide whether to prompt the user, allow, or deny.
`CheckRenderFrameType`
the delegate method (named in the commit and regression test) that, given a render_process_id/render_frame_id, looks up the RenderFrameHost and returns (check_permission/ask_user, allowed) via a callback.
TOCTOU (Time-of-Check to Time-of-Use)
a race where state checked at one moment changes before use; here the RenderFrameHost can be destroyed (iframe detached) between the request start and the type check, making the lookup return null.
`ChildProcessHost::kInvalidUniqueID`
the sentinel process id used for browser-initiated requests (e.g. Chrome OS Dictation) that have no renderer process behind them.

Root Cause Analysis

CheckRenderFrameType (enclosing function per the commit/test; the @@ -173 header does not name it) handled a null render_frame_host by unconditionally setting allowed = true and check_permission = false, on the assumption (per the removed comment “This happens for extensions. Manifest should be checked for permission.”) that a missing frame meant an extension background context. But a null RFH also occurs when a normal renderer-initiated request’s frame is destroyed between StartRequestOnUI and this check — a TOCTOU window. A compromised renderer could start speech recognition and then detach the requesting iframe so that by the time CheckRenderFrameType runs the RFH is null, causing the code to grant microphone access with no prompt, bypassing the permission UI entirely.

The fix distinguishes the null-RFH cases: if render_process_id == content::ChildProcessHost::kInvalidUniqueID it is a genuine browser-initiated request and remains allowed; otherwise it looks up the RenderProcessHost and only allows if extensions::ProcessMap::Contains(render_process_id) confirms a real extension process, denying (allowed = is_extension, false) when a renderer-initiated frame is simply missing. This tightens the fallback so a detached-frame race fails closed.

Key insight
A null RenderFrameHost was treated as “trusted extension → allow without prompting,” but it also arises when a renderer detaches the requesting frame mid-flow; the fix restricts unprompted allow to browser-initiated requests (invalid process id) and verified extension processes, denying any other missing-frame case.

Attack Path

  1. Start a speech request A compromised/misbehaving renderer initiates Web Speech API recognition from an iframe, kicking off the asynchronous permission flow (StartRequestOnUI).
  2. Detach the frame Before CheckRenderFrameType runs on the UI thread, the renderer detaches the requesting iframe, destroying its RenderFrameHost.
  3. Null RFH, unconditional allow Pre-patch, CheckRenderFrameType sees render_frame_host == null, takes the extension-assumption branch, and returns allowed = true, check_permission = false.
  4. Permission prompt bypass Microphone access is granted without the user ever being prompted, letting the page capture audio it should not have been allowed to (the regression test TOCTOUPermissionBypass asserts the fixed path now returns ask_user=false, is_allowed=false).

Impact Assessment

A compromised renderer could bypass the microphone permission prompt for speech recognition by racing the destruction of the requesting iframe so the browser’s frame-type check sees a null frame and defaults to allow. The precondition is the ability to start a speech request and detach the frame within the check window; impact is unauthorized microphone/audio access without user consent. The vendor advisory rates this medium (race in Speech) and does not report exploitation in the wild.

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

Audit Directions

  • Null-object "fail open" fallbacks
    Audit permission/security checks whose null-lookup branch grants access on an assumption about why the object is missing; confirm the missing case cannot also arise from an attacker-triggered teardown and fails closed.
  • TOCTOU across thread hops
    Review flows that validate a RenderFrameHost/process on a different task or thread than where the request started; re-validate liveness at the point of use and distinguish browser- vs renderer-initiated requests.
  • Extension-vs-renderer disambiguation
    Look for code that infers “extension context” from a missing frame rather than consulting ProcessMap/process type, since renderer requests can masquerade as the same null-frame state.
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