High watch ✔︎ verified CVSS 8.3 chrome UAF 🔧 Commit mapped 🧬 A nested callout frees the object, then it is used

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Bluetooth
DescriptionUse after free in Bluetooth
ComponentBluetooth
Sub-componentBluetooth
Bug ClassUAF
Action tierwatch — corruption primitive but gated (flag) — not default-reachable — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: Mechanism matches the commit. 'Default' is overstated: per the commit, the UAF needs the scanning prompt to exit fullscreen plus a concurrent iframe detach, on the requestLEScan path. The diff does not show that path enabled by default.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker554558320
Fix commit45aabdf6e9e6 (chromium/src) +97/-17
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-08-29 (per vendor advisory)
Fix landed2026-09-14 (public git)
Disclosed2026-09-22
Patch gap8 days — fix public in source before the advisory

Background

`navigator.bluetooth.requestLEScan()`
A Web Bluetooth API that a renderer calls to request low-energy advertisement scanning, which triggers the browser to show a scanning permission prompt.
`BluetoothDeviceScanningPromptController`
A browser-side, per-RenderFrameHostImpl object that drives the Bluetooth scanning prompt and stores the resulting prompt in its prompt_ member.
`ShowBluetoothScanningPrompt()`
The embedder BluetoothDelegate method that displays the desktop scanning prompt and may drop fullscreen, spinning a nested message loop before it returns.
Reentrancy UAF
A use-after-free in which a synchronous call spins a nested message loop that destroys the calling object, so control returns to code that then touches the freed object.

Root Cause Analysis

When a renderer called navigator.bluetooth.requestLEScan(), the browser synchronously invoked the embedder’s ShowBluetoothScanningPrompt() from within BluetoothDeviceScanningPromptController. Displaying the prompt drops fullscreen, which spins a nested message loop that can synchronously process a pending FrameDetach IPC, destroying the requesting subframe’s RenderFrameHostImpl and its owned BluetoothDeviceScanningPromptController. The vulnerable code violated the invariant that this remains valid across the delegate call: when ShowBluetoothScanningPrompt() returned, it assigned the result into prompt_, a heap-use-after-free write on the already-freed controller.

The fix captures a base::WeakPtr via weak_ptr_factory_.GetWeakPtr() before the call and returns early if !weak_this (or if prompt_event_received_ shows the event already fired) before touching prompt_. It also handles a null prompt by canceling immediately, and hardens RunCallback and the advertisement clients with mojo::WrapCallbackWithDefaultInvokeIfNotRun so aborted requests do not hang.

Key insight
The core mistake was assuming the BluetoothDeviceScanningPromptController object outlives the synchronous ShowBluetoothScanningPrompt() call, when that call can spin a nested message loop that frees the controller; the fix guards the post-call prompt_ write behind a base::WeakPtr liveness check.

Attack Path

  1. Set up a subframe An attacker page embeds a child iframe that will issue the Bluetooth request while remaining removable from the parent document.
  2. Request LE scanning The child frame calls navigator.bluetooth.requestLEScan(), causing the browser to construct a BluetoothDeviceScanningPromptController and call ShowBluetoothScanningPrompt().
  3. Trigger the nested loop Showing the prompt drops fullscreen and spins a nested message loop, giving the attacker a window to detach the child frame.
  4. Detach the frame A FrameDetach IPC (e.g. from removing the iframe) is processed synchronously in that loop, destroying the child RenderFrameHostImpl and freeing the BluetoothDeviceScanningPromptController.
  5. Trigger the freed write When ShowBluetoothScanningPrompt() returns, the controller assigns into prompt_, performing a use-after-free write on the freed object.

Impact Assessment

An attacker who controls a page with a removable subframe gains a use-after-free write in the privileged browser process, corrupting freed heap memory belonging to a BluetoothDeviceScanningPromptController. Reaching it requires the Web Bluetooth scanning prompt path to spin a nested message loop (such as dropping fullscreen) and a pending frame-detach IPC to be delivered during that window, making it a controllable but timing-dependent condition rather than a guaranteed hit.

Changed Functions

FunctionChangeNotes
device_id_
content/browser/bluetooth/advertisement_client.cc
modified
if
content/browser/bluetooth/advertisement_client.h
modified
if
content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc
modified
if
content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
modified
BindLambdaForTesting
content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
modified

Files Changed

  • content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
  • content/browser/bluetooth/advertisement_client.cc
  • content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc
  • content/browser/bluetooth/advertisement_client.h

Audit Directions

  • Synchronous embedder/delegate calls
    Audit every browser-side call into embedder UI (prompts, choosers, fullscreen changes) for use of this after the call returns, since such calls can spin nested message loops.
  • Post-reentrancy member writes
    Look for objects that store call results into member fields after a call that may run nested loops, and require a base::WeakPtr liveness guard before the write.
  • Frame-lifetime-bound controllers
    Review per-RenderFrameHostImpl service objects for assumptions that they outlive synchronous UI operations, since a FrameDetach IPC can destroy them mid-call.
From 45aabdf6e9e620f6ac0d2d720814f6236658ff1e Mon Sep 17 00:00:00 2001
From: Alvin Ji <[email protected]>
Date: Mon, 14 Sep 2026 16:22:32 -0700
Subject: [PATCH] bluetooth: Fix reentrancy UAF during ShowPermissionPrompt

When a renderer requests Bluetooth scanning via
navigator.bluetooth.requestLEScan(), the browser service synchronously
calls the embedder delegate's ShowBluetoothScanningPrompt() method.
Displaying the desktop scanning prompt drops fullscreen, which can spin
a nested message loop while exiting fullscreen mode across related tabs.

During this nested message loop, pending IPCs (such as an iframe
removal FrameDetach IPC) can be processed synchronously, destroying the
requesting subframe's RenderFrameHostImpl and its associated service
objects, including BluetoothDeviceScanningPromptController. When
ShowBluetoothScanningPrompt() returns, assigning the prompt into
prompt_ causes a heap-use-after-free write on the deleted controller.

This CL captures a base::WeakPtr to the controller before calling
ShowBluetoothScanningPrompt() and verifies liveness before accessing
member fields.

TAG=agy
CONV=3182e656-edea-4b6d-aa62-f521ce8320bc

Fixed: 554558320
Change-Id: Ib1c051149c0bb4e4f1f9fdefb0a72db96573c3f0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8310829
Commit-Queue: Alvin Ji <[email protected]>
Reviewed-by: Matt Reynolds <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1697624}
---
diff --git a/content/browser/bluetooth/advertisement_client.cc b/content/browser/bluetooth/advertisement_client.cc
index 7e2bf39d..04938c6 100644
--- a/content/browser/bluetooth/advertisement_client.cc
+++ b/content/browser/bluetooth/advertisement_client.cc
@@ -12,6 +12,7 @@
 #include "content/browser/bluetooth/bluetooth_blocklist.h"
 #include "content/browser/bluetooth/bluetooth_metrics.h"
 #include "content/browser/web_contents/web_contents_impl.h"
+#include "mojo/public/cpp/bindings/callback_helpers.h"
 
 namespace content {
 
@@ -49,9 +50,12 @@
         client_remote,
     blink::WebBluetoothDeviceId device_id,
     RequestCallback callback)
-    : AdvertisementClient(service,
-                          std::move(client_remote),
-                          std::move(callback)),
+    : AdvertisementClient(
+          service,
+          std::move(client_remote),
+          mojo::WrapCallbackWithDefaultInvokeIfNotRun(
+              std::move(callback),
+              blink::mojom::WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED)),
       device_id_(device_id) {
   DCHECK(device_id_.IsValid());
 }
@@ -91,9 +95,12 @@
         client_remote,
     blink::mojom::WebBluetoothRequestLEScanOptionsPtr options,
     RequestCallback callback)
-    : AdvertisementClient(service,
-                          std::move(client_remote),
-                          std::move(callback)),
+    : AdvertisementClient(
+          service,
+          std::move(client_remote),
+          mojo::WrapCallbackWithDefaultInvokeIfNotRun(
+              std::move(callback),
+              blink::mojom::WebBluetoothResult::PROMPT_CANCELED)),
       options_(std::move(options)) {
   DCHECK(options_->filters.has_value() || options_->accept_all_advertisements);
 }
diff --git a/content/browser/bluetooth/advertisement_client.h b/content/browser/bluetooth/advertisement_client.h
index 2d9afa1..976de0ae 100644
--- a/content/browser/bluetooth/advertisement_client.h
+++ b/content/browser/bluetooth/advertisement_client.h
@@ -29,7 +29,9 @@
   bool is_connected() { return client_remote_.is_connected(); }
 
   void RunCallback(blink::mojom::WebBluetoothResult result) {
-    std::move(callback_).Run(result);
+    if (callback_) {
+      std::move(callback_).Run(result);
+    }
   }
 
  protected:
diff --git a/content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc b/content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc
index 590dd6f..6e455106 100644
--- a/content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc
+++ b/content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc
@@ -34,13 +34,34 @@
                               OnBluetoothScanningPromptEvent,
                           weak_ptr_factory_.GetWeakPtr());
 
-  if (auto* delegate = GetContentClient()->browser()->GetBluetoothDelegate()) {
-    // non-active RFHs can't show UI elements like prompts to the user.
-    if (!render_frame_host_->IsActive())
-      return;
-    prompt_ = delegate->ShowBluetoothScanningPrompt(
-        &*render_frame_host_, std::move(prompt_event_handler));
+  // Non-active RFHs can't show UI elements like prompts to the user.
+  if (!render_frame_host_->IsActive()) {
+    return;
   }
+
+  auto* delegate = GetContentClient()->browser()->GetBluetoothDelegate();
+  if (!delegate) {
+    return;
+  }
+
+  // The delegate's prompt implementation may spin a nested message loop (e.g.
+  // to drop fullscreen), during which the frame may be detached and the
+  // controller destroyed. In addition, the prompt event handler could be
+  // invoked synchronously. Check that the controller is still alive and that
+  // the event hasn't already been handled before assigning `prompt_`.
+  auto weak_this = weak_ptr_factory_.GetWeakPtr();
+  auto prompt = delegate->ShowBluetoothScanningPrompt(
+      &*render_frame_host_, std::move(prompt_event_handler));
+  if (!weak_this || prompt_event_received_) {
+    return;
+  }
+  // If the delegate fails to create a prompt or prompt UI is unsupported,
+  // cancel immediately so the request does not hang.
+  if (!prompt) {
+    OnBluetoothScanningPromptEvent(BluetoothScanningPrompt::Event::kCanceled);
+    return;
+  }
+  prompt_ = std::move(prompt);
 }
 
 void BluetoothDeviceScanningPromptController::OnBluetoothScanningPromptEvent(
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
index 9b07c02..8b7b334 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
@@ -166,14 +166,19 @@
     return std::make_unique<FakeBluetoothChooser>(event_handler,
                                                   device_to_select_);
   }
+  void set_show_bluetooth_scanning_prompt_callback(base::OnceClosure callback) {
+    show_bluetooth_scanning_prompt_callback_ = std::move(callback);
+  }
+
   std::unique_ptr<BluetoothScanningPrompt> ShowBluetoothScanningPrompt(
       RenderFrameHost* frame,
       const BluetoothScanningPrompt::EventHandler& event_handler) override {
     showed_bluetooth_scanning_prompt_ = true;
     DCHECK_EQ(frame->GetLifecycleState(),
               RenderFrameHost::LifecycleState::kActive);
-    if (quit_on_scanning_prompt_)
-      std::move(quit_on_scanning_prompt_).Run();
+    if (show_bluetooth_scanning_prompt_callback_) {
+      std::move(show_bluetooth_scanning_prompt_callback_).Run();
+    }
     auto prompt =
         std::make_unique<FakeBluetoothScanningPrompt>(std::move(event_handler));
     prompt_ = prompt.get();
@@ -203,7 +208,7 @@
     if (showed_bluetooth_scanning_prompt_)
       return;
     base::RunLoop run_loop;
-    quit_on_scanning_prompt_ = run_loop.QuitClosure();
+    show_bluetooth_scanning_prompt_callback_ = run_loop.QuitClosure();
     run_loop.Run();
   }
   void RunBluetoothScanningPromptEventCallback(
@@ -223,8 +228,8 @@
  private:
   std::string device_to_select_;
   base::OnceClosure run_bluetooth_chooser_callback_;
+  base::OnceClosure show_bluetooth_scanning_prompt_callback_;
   raw_ptr<FakeBluetoothScanningPrompt, DanglingUntriaged> prompt_ = nullptr;
-  base::OnceClosure quit_on_scanning_prompt_;
   bool showed_bluetooth_scanning_prompt_ = false;
   bool checked_allow_web_bluetooth_ = false;
   bool block_globally_disabled_ = false;
@@ -729,4 +734,49 @@
               EvalJsResult::ErrorIs(testing::HasSubstr("RenderFrame deleted")));
 }
 
+IN_PROC_BROWSER_TEST_F(WebBluetoothServiceImplBrowserTest,
+                       FrameDetachDuringShowBluetoothScanningPrompt) {
+  EXPECT_CALL(*adapter(), AddObserver(_)).Times(testing::AnyNumber());
+  EXPECT_CALL(*adapter(), RemoveObserver(_)).Times(testing::AnyNumber());
+
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("/simple_page.html")));
+
+  // Add an iframe and wait for it to load.
+  EXPECT_TRUE(ExecJs(GetWebContents(), R"(
+    new Promise(resolve => {
+      let iframe = document.createElement('iframe');
+      iframe.src = '/simple_page.html';
+      iframe.onload = resolve;
+      document.body.appendChild(iframe);
+    });
+  )"));
+
+  RenderFrameHost* child_rfh =
+      ChildFrameAt(GetWebContents()->GetPrimaryMainFrame(), 0);
+  ASSERT_TRUE(child_rfh);
+
+  GetBluetoothDelegate()->set_show_bluetooth_scanning_prompt_callback(
+      base::BindLambdaForTesting([this]() {
+        // Synchronously detach the iframe during ShowBluetoothScanningPrompt.
+        EXPECT_TRUE(ExecJs(GetWebContents(),
+                           "document.querySelector('iframe').remove();"));
+      }));
+
+  auto result = content::EvalJs(child_rfh, R"(
+    (async() => {
+      try {
+        await navigator.bluetooth.requestLEScan(
+            {acceptAllAdvertisements: true});
+        return "";
+      } catch(e) {
+        return `${e.name}: ${e.message}`;
+      }
+    })()
+  )");
+  EXPECT_THAT(result,
+              EvalJsResult::ErrorIs(testing::HasSubstr("RenderFrame deleted")));
+  EXPECT_TRUE(GetBluetoothDelegate()->showed_bluetooth_scanning_prompt());
+}
+
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
index 9b07c02..8b7b334 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc
@@ -166,14 +166,19 @@
     return std::make_unique<FakeBluetoothChooser>(event_handler,
                                                   device_to_select_);
   }
+  void set_show_bluetooth_scanning_prompt_callback(base::OnceClosure callback) {
+    show_bluetooth_scanning_prompt_callback_ = std::move(callback);
+  }
+
   std::unique_ptr<BluetoothScanningPrompt> ShowBluetoothScanningPrompt(
       RenderFrameHost* frame,
       const BluetoothScanningPrompt::EventHandler& event_handler) override {
     showed_bluetooth_scanning_prompt_ = true;
     DCHECK_EQ(frame->GetLifecycleState(),
               RenderFrameHost::LifecycleState::kActive);
-    if (quit_on_scanning_prompt_)
-      std::move(quit_on_scanning_prompt_).Run();
+    if (show_bluetooth_scanning_prompt_callback_) {
+      std::move(show_bluetooth_scanning_prompt_callback_).Run();
+    }
     auto prompt =
         std::make_unique<FakeBluetoothScanningPrompt>(std::move(event_handler));
     prompt_ = prompt.get();
@@ -203,7 +208,7 @@
     if (showed_bluetooth_scanning_prompt_)
       return;
     base::RunLoop run_loop;
-    quit_on_scanning_prompt_ = run_loop.QuitClosure();
+    show_bluetooth_scanning_prompt_callback_ = run_loop.QuitClosure();
     run_loop.Run();
   }
   void RunBluetoothScanningPromptEventCallback(
@@ -223,8 +228,8 @@
  private:
   std::string device_to_select_;
   base::OnceClosure run_bluetooth_chooser_callback_;
+  base::OnceClosure show_bluetooth_scanning_prompt_callback_;
   raw_ptr<FakeBluetoothScanningPrompt, DanglingUntriaged> prompt_ = nullptr;
-  base::OnceClosure quit_on_scanning_prompt_;
   bool showed_bluetooth_scanning_prompt_ = false;
   bool checked_allow_web_bluetooth_ = false;
   bool block_globally_disabled_ = false;
@@ -729,4 +734,49 @@
               EvalJsResult::ErrorIs(testing::HasSubstr("RenderFrame deleted")));
 }
 
+IN_PROC_BROWSER_TEST_F(WebBluetoothServiceImplBrowserTest,
+                       FrameDetachDuringShowBluetoothScanningPrompt) {
+  EXPECT_CALL(*adapter(), AddObserver(_)).Times(testing::AnyNumber());
+  EXPECT_CALL(*adapter(), RemoveObserver(_)).Times(testing::AnyNumber());
+
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("/simple_page.html")));
+
+  // Add an iframe and wait for it to load.
+  EXPECT_TRUE(ExecJs(GetWebContents(), R"(
+    new Promise(resolve => {
+      let iframe = document.createElement('iframe');
+      iframe.src = '/simple_page.html';
+      iframe.onload = resolve;
+      document.body.appendChild(iframe);
+    });
+  )"));
+
+  RenderFrameHost* child_rfh =
+      ChildFrameAt(GetWebContents()->GetPrimaryMainFrame(), 0);
+  ASSERT_TRUE(child_rfh);
+
+  GetBluetoothDelegate()->set_show_bluetooth_scanning_prompt_callback(
+      base::BindLambdaForTesting([this]() {
+        // Synchronously detach the iframe during ShowBluetoothScanningPrompt.
+        EXPECT_TRUE(ExecJs(GetWebContents(),
+                           "document.querySelector('iframe').remove();"));
+      }));
+
+  auto result = content::EvalJs(child_rfh, R"(
+    (async() => {
+      try {
+        await navigator.bluetooth.requestLEScan(
+            {acceptAllAdvertisements: true});
+        return "";
+      } catch(e) {
+        return `${e.name}: ${e.message}`;
+      }
+    })()
+  )");
+  EXPECT_THAT(result,
+              EvalJsResult::ErrorIs(testing::HasSubstr("RenderFrame deleted")));
+  EXPECT_TRUE(GetBluetoothDelegate()->showed_bluetooth_scanning_prompt());
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.