← Chrome — 2026-W41

6d93ae61d435db9c689b133f2dbfbb7211bd2b6b  [M150-LTS] bluetooth: Fix reentrancy UAF during ShowPermissionPrompt

severity high class UAF confidence 0.95 signal score 8.79 content/browser/ patch-now AI review CONFIRMED
Primitive: heap-UAF write to freed BluetoothDeviceScanningPromptController via prompt_ after synchronous FrameDetach in a nested message loop
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.
Structural signals: null_guard (+2.59), uaf_lifetime (+2.2), msg_strong (+3.0), area:content/browser/ (+1.0)

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

  1. Request — Web content in a subframe calls navigator.bluetooth.requestLEScan(); WebBluetoothServiceImpl creates a controller and calls ShowBluetoothScanningPrompt().
  2. Reentrancy — ShowBluetoothScanningPrompt() drops fullscreen, spinning a nested message loop that processes a queued FrameDetach IPC (e.g. iframe.remove()).
  3. Free — FrameDetach destroys the subframe’s RenderFrameHostImpl and the controller; this is now freed.
  4. UAF write — ShowBluetoothScanningPrompt() returns and the original stack frame executes prompt_ = … writing to the freed controller.
  5. 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

  1. Reach — From a subframe, after obtaining the Web Bluetooth permission gesture, call navigator.bluetooth.requestLEScan() so the browser invokes ShowBluetoothScanningPrompt().
  2. 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.
  3. Free — The nested loop dispatches FrameDetach, destroying the RenderFrameHostImpl and the controller (this).
  4. 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.

Before / after

Loading diff…