e75c6ae03b77ce35ac369222cbfad68da9cc4e40 [M152] bluetooth: Fix reentrancy UAF during ShowPermissionPrompt
The bug at a glance
Browser-process heap use-after-free write: per the commit message, assigning prompt_ after ShowBluetoothScanningPrompt() returns writes into a BluetoothDeviceScanningPromptController already destroyed by a nested message loop. Rated high rather than critical because it requires navigator.bluetooth.requestLEScan() (an experimental Web Bluetooth scanning API, typically flag-gated in stock builds (inferred)), a fullscreen state to drop, and a timed frame detach.
A renderer-triggerable browser-process UAF is a sandbox-escape candidate: the attacker races an iframe removal against the fullscreen-exit nested run loop spun inside the prompt display. Defenders care about the general pattern of synchronous embedder UI calls that re-enter the message loop and free their caller.
Root cause
BluetoothDeviceScanningPromptController::ShowPermissionPrompt synchronously called delegate->ShowBluetoothScanningPrompt() and stored the return value into the member prompt_. On desktop the prompt drops fullscreen, which can spin a nested message loop that processes a pending FrameDetach IPC, destroying the requesting subframe’s RenderFrameHostImpl and the controller it owns; the subsequent prompt_ = … write hits freed memory. The fix takes a WeakPtr before the call and bails if the controller died or the event was already handled synchronously, cancels if no prompt was created, and makes AdvertisementClient callbacks default-invoke (PROMPT_CANCELED / WATCH_ADVERTISEMENTS_ABORTED) and null-checked so dropped callbacks don’t hang or double-run.
Key code
WeakPtr liveness check after reentrant prompt display
+ 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;
+ }
- prompt_ = delegate->ShowBluetoothScanningPrompt(
- &*render_frame_host_, std::move(prompt_event_handler));
Patch walkthrough
content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc— Core fix: captures weak_this before ShowBluetoothScanningPrompt(), returns if the controller was destroyed or the event already fired during the call, and cancels immediately on a null prompt instead of assigning into a possibly freed object.content/browser/bluetooth/advertisement_client.cc— Wraps the request callbacks with mojo::WrapCallbackWithDefaultInvokeIfNotRun so a client torn down mid-request still replies (PROMPT_CANCELED / WATCH_ADVERTISEMENTS_ABORTED) rather than dropping a Mojo callback.content/browser/bluetooth/advertisement_client.h— RunCallback now checks callback_ before running it, avoiding running an already-consumed OnceCallback on reentrant paths.content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc— Adds FrameDetachDuringShowBluetoothScanningPrompt, which removes the requesting iframe synchronously inside ShowBluetoothScanningPrompt and expects a ‘RenderFrame deleted’ error instead of a crash.
Background
navigator.bluetooth.requestLEScan() — Web Bluetooth scanning API that asks the browser to show a scanning permission prompt before delivering BLE advertisements.
Nested message loop — A run loop spun inside a synchronous call that can dispatch unrelated tasks/IPCs, letting objects higher on the stack be destroyed (reentrancy).
BluetoothDeviceScanningPromptController — Browser-side object owned via the frame’s Web Bluetooth service that shows the scanning prompt and routes its result.
Vulnerability window
- Reachability — Web content in a subframe calling requestLEScan(); the scanning API is experimental and likely flag-gated in stock builds (inferred), and the fullscreen-drop nested loop requires the tab to be fullscreen (user gesture) on desktop.
- Trigger — The parent removes the requesting iframe so its FrameDetach IPC is processed inside the nested loop spun while the prompt drops fullscreen.
- Primitive — Heap UAF write of the prompt unique_ptr into the freed BluetoothDeviceScanningPromptController in the browser process.
- Escalation — Reclaiming the freed allocation could turn the write into browser-process memory corruption and a sandbox escape (inferred).
Proof of concept
Conceptually: a fullscreen page embeds an iframe that calls requestLEScan(); concurrently the parent removes that iframe so the detach is handled during the nested message loop entered while the scanning prompt exits fullscreen, freeing the controller before prompt_ is assigned. The added browsertest models this by removing the iframe from inside ShowBluetoothScanningPrompt.
Exploitation
- Setup — Get the page fullscreen and an iframe positioned to request LE scanning (inferred ordering).
- Race/reentrancy — Queue the iframe-removal IPC so it lands inside the fullscreen-exit nested loop (inferred).
- Corruption — The freed controller’s prompt_ slot is written after return; heap grooming in the browser process would be needed to make this useful (inferred).
Detection & hunting
For defenders and detection engineers:
- weggli/grep: member assignment from a delegate->ShowPrompt(…) / ShowChooser(…) return value without a preceding GetWeakPtr() — Find browser code that stores a synchronously returned UI object into a member after an embedder call that may spin a run loop.
- ASan heap-use-after-free in BluetoothDeviceScanningPromptController::ShowPermissionPrompt — Crash triage signal on unpatched builds; check for this frame in fuzzer/ClusterFuzz reports.
Audit directions
- content/browser/bluetooth/ (BluetoothDeviceChooserController::ShowChooser and similar) — Check other chooser/prompt paths for the same assign-after-synchronous-UI pattern with no liveness check.
- Other device/permission prompts that exit fullscreen (USB, HID, Serial choosers, permission bubbles) — Any prompt that drops fullscreen can re-enter the loop; verify callers guard with WeakPtr before touching members.
- Mojo callbacks stored by per-frame service objects — Ensure stored RequestCallbacks are default-invoked or null-checked when the owner is torn down mid-request.