6d93ae61d435db9c689b133f2dbfbb7211bd2b6b [M150-LTS] bluetooth: Fix reentrancy UAF during ShowPermissionPrompt
Triage note: Nested loop on fullscreen exit processes a FrameDetach IPC destroying the controller, so assigning prompt_ after ShowBluetoothScanningPrompt() returns is a UAF write; fix captures a WeakPtr and checks liveness/prompt_event_received_ (Fixed: 554558320). Web-reachable via navigator.bluetooth.requestLEScan.
The bug at a glance
Reentrancy heap-UAF write in the browser process, reachable from web content via navigator.bluetooth.requestLEScan() behind a user permission gesture. The free occurs when a synchronous nested message loop (dropping fullscreen) processes a FrameDetach IPC that destroys the requesting subframe’s RenderFrameHostImpl and the BluetoothDeviceScanningPromptController; the subsequent prompt_ = assignment writes to freed memory. High; backported to M150-LTS.
requestLEScan() synchronously calls the embedder’s ShowBluetoothScanningPrompt(). That call drops fullscreen, which spins a nested message loop; during it a queued iframe-removal FrameDetach IPC runs synchronously and deletes the controller. When ShowBluetoothScanningPrompt() returns, the original stack frame writes the returned prompt into this->prompt_, a use-after-free write on the freed BluetoothDeviceScanningPromptController.
Root cause
The vulnerable code assigned prompt_ = delegate->ShowBluetoothScanningPrompt(…) directly, assuming this stayed valid across the synchronous call. Because ShowBluetoothScanningPrompt() can spin a nested RunLoop (to exit fullscreen across related tabs) and dispatch a FrameDetach IPC that tears down the subframe’s RenderFrameHostImpl and the BluetoothDeviceScanningPromptController it owns, the write to prompt_ lands on freed memory. The fix captures auto weak_this = weak_ptr_factory_.GetWeakPtr() before the call and, on return, bails if (!weak_this || prompt_event_received_) before touching any member. It also handles a null prompt by calling OnBluetoothScanningPromptEvent(kCanceled) so the request cannot hang. Defense-in-depth in advertisement_client.cc wraps the RequestCallbacks with mojo::WrapCallbackWithDefaultInvokeIfNotRun and makes RunCallback() null-check callback_. The weak-pointer liveness check is the actual UAF fix.
Key code
Capture a WeakPtr before the reentrant prompt call and verify liveness before writing 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 (!prompt) {
OnBluetoothScanningPromptEvent(BluetoothScanningPrompt::Event::kCanceled);
return;
}
prompt_ = std::move(prompt);
Patch walkthrough
content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc— The core fix. Captures weak_this before calling ShowBluetoothScanningPrompt(), rejects reentrant destruction/handling via if (!weak_this || prompt_event_received_) return;, cancels cleanly on a null prompt, and only then assigns prompt_ = std::move(prompt).content/browser/bluetooth/advertisement_client.cc— Defense-in-depth: wraps both the requestLEScan and watchAdvertisements RequestCallbacks with mojo::WrapCallbackWithDefaultInvokeIfNotRun, defaulting to PROMPT_CANCELED / WATCH_ADVERTISEMENTS_ABORTED, so if the client is destroyed before responding the renderer promise still resolves.content/browser/bluetooth/advertisement_client.h— RunCallback() now guards with if (callback_) before std::move(callback_).Run(result), preventing a double-run / run-on-empty.content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc— Adds FrameDetachDuringShowBluetoothScanningPrompt regression test: navigates, adds an iframe, and from inside the fake delegate’s ShowBluetoothScanningPrompt synchronously removes the iframe, then asserts requestLEScan rejects with ‘RenderFrame deleted’.
Background
BluetoothDeviceScanningPromptController — Browser-side controller created per requestLEScan() call; owns render_frame_host_, a weak_ptr_factory_, the prompt_ pointer, and tracks prompt_event_received_. Owned by the requesting RenderFrameHostImpl, so frame detach destroys it.
ShowBluetoothScanningPrompt — BluetoothDelegate method that creates and shows the scanning prompt UI. Showing it drops fullscreen, which can spin a nested message loop.
requestLEScan() — navigator.bluetooth.requestLEScan() Web Bluetooth entry point that triggers the scanning permission prompt; the web-facing trigger.
FrameDetach reentrancy — A pending iframe-removal (FrameDetach) IPC dispatched inside the nested message loop synchronously destroys the subframe’s RenderFrameHostImpl and the controller.
base::WeakPtr liveness check — GetWeakPtr() captured before the reentrant call; testing !weak_this after return detects that this was freed during the call.
Vulnerability window
- Request — Web content in a subframe calls navigator.bluetooth.requestLEScan(); WebBluetoothServiceImpl creates a controller and calls ShowBluetoothScanningPrompt().
- Reentrancy — ShowBluetoothScanningPrompt() drops fullscreen, spinning a nested message loop that processes a queued FrameDetach IPC (e.g. iframe.remove()).
- Free — FrameDetach destroys the subframe’s RenderFrameHostImpl and the controller; this is now freed.
- UAF write — ShowBluetoothScanningPrompt() returns and the original stack frame executes prompt_ = … writing to the freed controller.
- Fixed — weak_this/prompt_event_received_ checks abort before the write; null prompt is canceled cleanly; dropped callbacks default-invoke.
Proof of concept
The diff includes a browsertest that reproduces the bug: it hooks the fake delegate so that, during ShowBluetoothScanningPrompt, the child iframe is synchronously removed (simulating the FrameDetach the real fullscreen-exit nested loop dispatches), then invokes requestLEScan from that child frame and expects rejection with ‘RenderFrame deleted’. Pre-fix the return path writes prompt_ on the freed controller. In the wild the nested loop comes from the real fullscreen drop; a controllable iframe-detach supplies the synchronous free. Heap grooming to turn the write into code execution is (inferred).
GetBluetoothDelegate()->set_show_bluetooth_scanning_prompt_callback(
base::BindLambdaForTesting([this]() {
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}`; } })()
)");
Exploitation
- Reach — From a subframe, after obtaining the Web Bluetooth permission gesture, call navigator.bluetooth.requestLEScan() so the browser invokes ShowBluetoothScanningPrompt().
- Reenter — Arrange the fullscreen-exit nested message loop to run while a FrameDetach for the requesting subframe is pending, e.g. remove the iframe at the right moment.
- Free — The nested loop dispatches FrameDetach, destroying the RenderFrameHostImpl and the controller (this).
- Corrupt (inferred) — Reallocate the freed controller with controlled bytes so the prompt_ = std::move(prompt) write lands in attacker-shaped memory; escalation from this write primitive is not shown by the patch.
Detection & hunting
For defenders and detection engineers:
- Member write after a reentrant/UI-blocking call — Flag any pattern member_ = delegate->ShowX(…) where ShowX can spin a nested message loop (fullscreen, modal, prompt). Require a captured WeakPtr checked before any post-call member access.
- ASAN heap-use-after-free write in BluetoothDeviceScanningPromptController — Watch for UAF writes to prompt_ following ShowBluetoothScanningPrompt during requestLEScan, correlated with a FrameDetach / fullscreen-exit on the stack.
- Nested message loop during Web Bluetooth prompts — Instrument/trace entry into nested RunLoops under ShowBluetoothScanningPrompt; a FrameDetach dispatched there is the reentrancy window.
- Callbacks that can be dropped without resolving the renderer promise — Look for Mojo RequestCallbacks not wrapped with WrapCallbackWithDefaultInvokeIfNotRun.
Audit directions
- Delegate calls that can spin nested loops — Audit all embedder UI calls (ShowBluetoothScanningPrompt, chooser, permission prompts) for post-return member access without a WeakPtr liveness check.
- Other Web Bluetooth controllers — Review BluetoothDeviceChooserController and related advertisement clients for the same assume-this-survives pattern.
- FrameDetach reentrancy sinks — Sweep content/browser for service objects owned by RenderFrameHostImpl that hold raw this across a call capable of synchronous frame teardown.
- Callback lifetime on early teardown — Verify every Web Bluetooth RequestCallback resolves on all teardown paths and that RunCallback-style helpers null-check before running.