CVE-2026-95348
Overview
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-
RenderFrameHostImplobject that drives the Bluetooth scanning prompt and stores the resulting prompt in itsprompt_member. - `ShowBluetoothScanningPrompt()`
- The embedder
BluetoothDelegatemethod 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.
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
- Set up a subframe An attacker page embeds a child iframe that will issue the Bluetooth request while remaining removable from the parent document.
- Request LE scanning
The child frame calls
navigator.bluetooth.requestLEScan(), causing the browser to construct aBluetoothDeviceScanningPromptControllerand callShowBluetoothScanningPrompt(). - 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.
- Detach the frame
A
FrameDetachIPC (e.g. from removing the iframe) is processed synchronously in that loop, destroying the childRenderFrameHostImpland freeing theBluetoothDeviceScanningPromptController. - Trigger the freed write
When
ShowBluetoothScanningPrompt()returns, the controller assigns intoprompt_, performing a use-after-free write on the freed object.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
device_id_content/browser/bluetooth/advertisement_client.cc |
modified | |
ifcontent/browser/bluetooth/advertisement_client.h |
modified | |
ifcontent/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc |
modified | |
ifcontent/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc |
modified | |
BindLambdaForTestingcontent/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc |
modified |
Files Changed
content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cccontent/browser/bluetooth/advertisement_client.cccontent/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cccontent/browser/bluetooth/advertisement_client.h
Audit Directions
- Synchronous embedder/delegate callsAudit every browser-side call into embedder UI (prompts, choosers, fullscreen changes) for use of
thisafter the call returns, since such calls can spin nested message loops. - Post-reentrancy member writesLook for objects that store call results into member fields after a call that may run nested loops, and require a
base::WeakPtrliveness guard before the write. - Frame-lifetime-bound controllersReview per-
RenderFrameHostImplservice objects for assumptions that they outlive synchronous UI operations, since aFrameDetachIPC can destroy them mid-call.
Patch
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
Regression Test / PoC
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