8c794651ada98327a3dded058c155f7bc4b32b4a [M155] bluetooth: Fix reentrancy UAF during ShowPermissionPrompt
Triage note: Web-reachable via navigator.bluetooth.requestLEScan() (user-gesture gated); browser-process heap-UAF write. Fixed:554558320. Backports M153/M154/M155.
The bug at a glance
This is a browser-process heap use-after-free write reachable from web content through the Web Bluetooth API, which normally warrants a high/critical rating: corruption in the privileged browser process is not sandboxed and can lead to full remote code execution outside any renderer sandbox. Two factors soften the practical severity and keep it at high/8.0 rather than critical. First, navigator.bluetooth.requestLEScan() is gated behind an explicit user gesture and the Web Bluetooth permission/policy surface, so the attacker cannot trigger the vulnerable code path silently and must social-engineer the victim into initiating a scan. Second, the trigger is a reentrancy race: it depends on a FrameDetach IPC being pending and processed inside the nested message loop opened while the desktop scanning prompt drops fullscreen, so the window in which the requesting subframe is destroyed mid-call is narrow and timing-dependent (inferred; the diff proves the race exists and the test forces it deterministically, but real-world exploitation depends on winning it). The combination of browser-process write primitive against gated, race-dependent reachability lands at CVSS 8.0.
The bug lives at the seam between an asynchronous IPC lifecycle (frames can be detached at almost any time) and a synchronous embedder callout that is assumed to be atomic. BluetoothDeviceScanningPromptController treated delegate->ShowBluetoothScanningPrompt() as a plain function call whose return value it could safely store into a member field. In reality the desktop implementation of that prompt drops fullscreen, and dropping fullscreen spins a nested RunLoop; that nested loop is a reentrancy point where the browser pumps queued mojo/IPC messages, including a FrameDetach for the very iframe that asked for the scan. Detaching the frame tears down its RenderFrameHostImpl and every service object it owns, including the controller itself. The interesting angle is that this is dead by the time the ostensibly-simple assignment prompt_ = ... runs: the write target is freed browser-process heap. The fix is the canonical Chromium pattern for ‘a synchronous call I made might have deleted me’ — grab a WeakPtr before the call and re-validate after.
Root cause
The vulnerable sequence begins when a renderer calls navigator.bluetooth.requestLEScan(). The browser-side WebBluetoothServiceImpl constructs a BluetoothDeviceScanningPromptController for the requesting RenderFrameHostImpl. In the pre-patch constructor body (bluetooth_device_scanning_prompt_controller.cc) the controller fetches the embedder BluetoothDelegate and, for an active RFH, calls delegate->ShowBluetoothScanningPrompt(&*render_frame_host_, std::move(prompt_event_handler)) and assigns the returned std::unique_ptr directly into its prompt_ member: prompt_ = delegate->ShowBluetoothScanningPrompt(...). The commit message states that the desktop implementation of ShowBluetoothScanningPrompt drops fullscreen, and exiting fullscreen across related tabs can spin a nested message loop (inferred from the commit message; the platform prompt/fullscreen code is not in this diff). While that nested loop runs, the browser processes other pending IPCs. If a FrameDetach IPC for the requesting subframe is pending — because the parent document removed the iframe, e.g. iframe.remove() — it is dispatched synchronously inside the loop. FrameDetach destroys the subframe’s RenderFrameHostImpl, which in turn destroys the objects it owns, including this BluetoothDeviceScanningPromptController. When the nested loop unwinds and ShowBluetoothScanningPrompt() finally returns, control resumes inside the destroyed controller’s constructor frame; the statement prompt_ = <returned unique_ptr> writes the pointer into the prompt_ member slot of an object whose backing memory has already been freed — a heap use-after-free write in the browser process. A secondary reentrancy hazard is also closed: the prompt event handler (bound via weak_ptr_factory_.GetWeakPtr() to OnBluetoothScanningPromptEvent) could be invoked synchronously during the call, so even a live controller could have already handled the event before the assignment. The fix captures auto weak_this = weak_ptr_factory_.GetWeakPtr(); immediately before the call, moves the result into a local prompt, and after the call checks if (!weak_this || prompt_event_received_) return; — bailing out without touching any member if the controller was deleted (weak_this invalidated) or the event already fired. It also adds a null-prompt guard: if the delegate returns no prompt (UI unsupported/failed), it calls OnBluetoothScanningPromptEvent(kCanceled) so the request does not hang, and only then does prompt_ = std::move(prompt);. Because the assignment is now guarded by the WeakPtr liveness check, the write to the freed controller can no longer occur.
Key code
content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc — before assigns the delegate result straight into the member; after captures a WeakPtr, re-checks liveness and prior event handling, and guards the null-prompt case before writing prompt_.
// BEFORE (vulnerable): assignment happens on a possibly-freed `this`.
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));
}
// AFTER (fixed): re-validate `this` after the reentrant call.
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 (!prompt) {
OnBluetoothScanningPromptEvent(BluetoothScanningPrompt::Event::kCanceled);
return;
}
prompt_ = std::move(prompt);
Patch walkthrough
content/browser/bluetooth/bluetooth_device_scanning_prompt_controller.cc— The core fix. Restructures the constructor logic so ShowBluetoothScanningPrompt()’s result goes into a localpromptrather than directly into the member. A WeakPtr (weak_this) is taken before the call; after the call returns from the potential nested message loop,if (!weak_this || prompt_event_received_) return;bails out without touching any member if the controller was destroyed by a mid-call FrameDetach or if the event was already handled synchronously. Aif (!prompt)branch cancels cleanly when the delegate produces no prompt, avoiding a hung request. Only after all guards passes does it assignprompt_ = std::move(prompt);, eliminating the UAF write.content/browser/bluetooth/advertisement_client.cc— Defense-in-depth on the request callbacks. The ScanClient and WatchAdvertisementsClient constructors now wrap the RequestCallback with mojo::WrapCallbackWithDefaultInvokeIfNotRun, supplying a default result (PROMPT_CANCELED for scans, WATCH_ADVERTISEMENTS_ABORTED for watch) so that if the client is torn down without the callback ever running, the mojo response is still fulfilled instead of being dropped. This dovetails with the new cancel-on-null-prompt path and prevents the renderer promise from hanging when the flow is aborted mid-request.content/browser/bluetooth/advertisement_client.h— Hardens RunCallback() to guard the OnceCallback before invoking it:if (callback_) { std::move(callback_).Run(result); }. Because the callback may now be consumed early by the default-invoke wrapper (or otherwise already moved-from during teardown), running an empty OnceCallback would crash; the null check makes RunCallback idempotent-safe against double or post-teardown invocation.content/browser/bluetooth/web_bluetooth_service_impl_browsertest.cc— Adds the regression test FrameDetachDuringShowBluetoothScanningPrompt and refactors the fake delegate. The old single-purpose quit_on_scanning_prompt_ closure is generalized into show_bluetooth_scanning_prompt_callback_ with a setter, letting the test inject arbitrary code that runs synchronously inside ShowBluetoothScanningPrompt(). The test detaches the requesting iframe from within that callback, reproducing the reentrancy that destroys the controller mid-call, and asserts the request rejects with ‘RenderFrame deleted’ rather than crashing under ASan.
Background
navigator.bluetooth.requestLEScan() — Web Bluetooth API that starts scanning for BLE advertisements. It requires a transient user activation (gesture) and is subject to the Web Bluetooth permission/policy gate, so a page cannot begin a scan without user involvement. The call reaches the browser process’s WebBluetoothServiceImpl, which drives the scanning permission prompt.
BluetoothDeviceScanningPromptController — Browser-process, per-request object owned (transitively) by the requesting RenderFrameHostImpl. It bridges the WebBluetoothServiceImpl to the embedder’s BluetoothDelegate, calls ShowBluetoothScanningPrompt(), stores the returned prompt in prompt_, and routes prompt events back via OnBluetoothScanningPromptEvent. Because it is owned by the RFH, it is destroyed when the frame is detached.
BluetoothDelegate::ShowBluetoothScanningPrompt() — An embedder (chrome/) callout that displays the desktop scanning prompt UI and returns a unique_ptr. Per the commit message, the desktop implementation drops fullscreen as a side effect, and exiting fullscreen across related tabs can spin a nested message loop — turning what looks like a synchronous, atomic call into a reentrancy point (inferred; that implementation is not in this diff).
FrameDetach IPC and RFH lifetime — When script removes an iframe (iframe.remove()), the renderer sends a FrameDetach IPC that causes the browser to destroy the subframe’s RenderFrameHostImpl and all objects it owns. This can be dispatched during any nested message loop, so any browser code that spins a loop while holding a raw this owned by an RFH must assume this may be deleted across the loop.
base::WeakPtr re-validation pattern — The idiomatic Chromium guard for ‘a call I make might delete me’: capture weak_ptr_factory_.GetWeakPtr() before the potentially-reentrant call and test the WeakPtr afterward. If it is null, this was destroyed during the call and no member may be touched. The fix applies exactly this pattern around ShowBluetoothScanningPrompt().
mojo::WrapCallbackWithDefaultInvokeIfNotRun — A mojo helper that wraps a callback so a default value is delivered if the callback is dropped without being run (e.g. because the owning object was destroyed). Used here to ensure the renderer’s requestLEScan/watchAdvertisements promise always resolves or rejects instead of hanging when the flow aborts mid-request.
Vulnerability window
- Trigger — A page with user activation calls navigator.bluetooth.requestLEScan(). The browser builds a BluetoothDeviceScanningPromptController for the active requesting (sub)frame and enters its constructor.
- Reentrancy — The controller calls delegate->ShowBluetoothScanningPrompt(). The desktop prompt drops fullscreen, spinning a nested message loop (inferred from commit message) that pumps pending IPCs.
- Destruction — A pending FrameDetach IPC for the requesting subframe is dispatched inside the nested loop, destroying its RenderFrameHostImpl and the BluetoothDeviceScanningPromptController —
thisis now freed. - UAF write — The nested loop unwinds; ShowBluetoothScanningPrompt() returns. The statement prompt_ = writes into the freed controller’s member — a browser-process heap use-after-free write.
- Fix — Trunk commit 45aabdf6e9e620f6ac0d2d720814f6236658ff1e (Cr-Commit-Position main@{#1697624}) captures a WeakPtr before the call and re-validates after; a regression browsertest reproduces the detach. Fixed: 554558320.
- Backport — Cherry-picked to the M155 branch (8059) as this commit 8c794651ad, Bug: 562325861,554558320, landed via the chrome-cherry-picker auto-submit on 2026-09-16.
Proof of concept
A working deterministic reproducer is embedded in the diff as the browsertest FrameDetachDuringShowBluetoothScanningPrompt. It loads a page, appends a same-origin iframe, and installs a fake-delegate callback that fires synchronously inside ShowBluetoothScanningPrompt(). Inside that callback it removes the iframe from the parent document (document.querySelector(‘iframe’).remove()), which synchronously detaches the child frame and destroys the requesting RenderFrameHostImpl and its scanning-prompt controller — exactly the reentrancy the real desktop fullscreen-drop nested loop would allow. Meanwhile the child frame runs navigator.bluetooth.requestLEScan({acceptAllAdvertisements:true}); the request rejects with ‘RenderFrame deleted’ and, pre-fix, the assignment into prompt_ on the freed controller is a UAF write caught by ASan. In production the injected synchronous removal is replaced by the real trigger: a pending FrameDetach must be processed during the nested message loop opened while the desktop prompt drops fullscreen. That is a genuine race — the attacker cannot guarantee the FrameDetach lands inside the loop window — so real-world firing is timing-dependent even though the test forces it deterministically via the fake delegate (honest caveat). No exploit primitive beyond the crash is demonstrated.
// Adapted from the in-diff browsertest; conceptual page-side shape.
// Parent page installs a same-origin iframe, then the iframe requests a scan.
// The reentrancy that removes the iframe mid-prompt is what the delegate hook
// forces in the test; in the wild it is the fullscreen-drop nested loop.
// --- parent document ---
await new Promise(resolve => {
const iframe = document.createElement('iframe');
iframe.src = '/simple_page.html';
iframe.onload = resolve;
document.body.appendChild(iframe);
});
// (Test hook) delegate fires this synchronously inside
// ShowBluetoothScanningPrompt(), detaching the requesting frame:
// document.querySelector('iframe').remove();
// --- inside the child iframe (needs user gesture / permission) ---
try {
await navigator.bluetooth.requestLEScan({acceptAllAdvertisements: true});
} catch (e) {
// Expected: 'NotFoundError: ... RenderFrame deleted' after the frame is
// detached during the prompt; pre-fix this path corrupted freed browser heap.
return `${e.name}: ${e.message}`;
}
Exploitation
- Reachability — Attacker serves a page that obtains user activation and calls requestLEScan() from a subframe it controls. The user must grant/permit Web Bluetooth scanning, so a lure or a site already holding the permission is required (inferred: gating narrows but does not eliminate reach).
- Winning the race — The attacker must arrange a FrameDetach for the requesting frame to be pending and dispatched during the nested message loop the desktop prompt opens when dropping fullscreen — e.g. by entering fullscreen first, then removing the requesting iframe as the prompt appears. Landing the detach inside the loop window is non-deterministic (inferred).
- Corruption primitive — On success the freed controller’s prompt_ slot receives a heap-allocated unique_ptr value — a pointer write into freed browser-process heap. Turning this into a controlled write or type confusion would require heap grooming to place attacker-influenced data at the freed controller’s location (inferred; no such primitive is shown or proven in the diff).
- Escalation — Because the corruption is in the unsandboxed browser process, a reliable exploit could bypass the renderer sandbox entirely and progress toward code execution with browser-process privileges (inferred; only the UAF write itself is established by the diff).
Detection & hunting
For defenders and detection engineers:
- ASan/MSan use-after-free write in BluetoothDeviceScanningPromptController constructor — A crash whose write target is a freed BluetoothDeviceScanningPromptController with a stack frame inside its constructor / ShowBluetoothScanningPrompt callout is the direct fingerprint. Fuzzers and browsertests exercising requestLEScan with concurrent frame detach should surface it.
- requestLEScan followed by frame detach during fullscreen exit — Telemetry or logs showing a scanning prompt request from a subframe that is detached while fullscreen is being dropped indicate the vulnerable interleaving; correlate FrameDetach dispatch timestamps with ShowBluetoothScanningPrompt entry/return.
- Renderer promise hang or unexpected PROMPT_CANCELED / WATCH_ADVERTISEMENTS_ABORTED — Post-fix, aborted flows now resolve with these default results via WrapCallbackWithDefaultInvokeIfNotRun; a spike in these results, or pre-fix hung Web Bluetooth promises, can flag flows aborting mid-request.
- DanglingUntriaged / dangling raw_ptr reports — Chrome’s dangling-pointer detector may flag the controller or its FakeBluetoothScanningPrompt (the test uses raw_ptr<…, DanglingUntriaged>) when the frame is torn down mid-prompt; treat such reports around bluetooth prompt objects as candidates for this class of bug.
Audit directions
- Other embedder callouts in content/browser/bluetooth — Audit RunBluetoothChooser / ShowBluetoothScanningPrompt and any other GetBluetoothDelegate()->… calls that store a result into a member on a RFH-owned object. Any that assign to
this’s members after the call without a WeakPtr re-check share the same reentrancy shape. - Synchronous delegate/UI calls that may drop fullscreen or spin nested loops — Search browser-process code for callouts (permission prompts, choosers, modal dialogs, fullscreen transitions) invoked from objects owned by a RenderFrameHostImpl; each is a candidate for mid-call RFH destruction. Require a WeakPtr-or-equivalent liveness check before any post-call member access.
- Constructors that perform reentrant work — Constructors are especially dangerous because callers hold the object under construction; a nested loop can free it before the constructor finishes. Review constructors in content/browser that call into embedder/UI code and then touch members, as done here.
- Callback lifetime on Web Bluetooth advertisement clients — Verify all RequestCallback paths in advertisement_client.* are wrapped so the renderer promise is always fulfilled, and that RunCallback null-guards a moved-from callback, to prevent both hangs and crashes when a client is torn down mid-flight.