CVE-2026-87543
Overview
Background
- Fenced frame
- an embedded frame type whose interaction with its embedder is deliberately restricted for privacy, so certain web APIs must be blocked inside it.
- `BrowserInterfaceBroker`
- the per-frame browser-process endpoint (
blink::mojom::BrowserInterfaceBroker) through which a renderer requests Mojo interfaces such asdevice::mojom::VibrationManager. - Interface binder
- a browser-process callback registered in the binder map (
content/browser/browser_interface_binders.cc) that receives amojo::PendingReceiverfrom a renderer request and connects it to the backing service. - `bad_message::ReceivedBadMessage`
- the mechanism that terminates a renderer process when it sends an illegitimate IPC, recording a reason enum from
content/browser/bad_message.h.
Root Cause Analysis
The renderer-side guard in third_party/blink/renderer/modules/vibration/vibration_controller.cc blocked navigator.vibrate() inside a fenced-frame tree, but this check lived entirely in the untrusted renderer. The browser-process binder for device::mojom::VibrationManager, registered in browser_interface_binders.cc, bound the receiver to the device service unconditionally via GetDeviceService().BindVibrationManager(...) without ever consulting the frame’s fenced-frame status. A renderer could therefore request the VibrationManager interface directly on its BrowserInterfaceBroker, bypassing the JavaScript-level check and obtaining a working interface the fenced-frame policy was supposed to deny — a missing browser-side authorization check.
The fix factors the inline lambda into a named BindVibrationManager() that calls host->IsNestedWithinFencedFrame() and, when true, invokes bad_message::ReceivedBadMessage with the new BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME reason and returns before binding, mirroring the adjacent BindBatteryMonitor() pattern. The check now runs in the trusted browser process, so the fenced-frame restriction is enforced regardless of what the renderer does.
Attack Path
- Load a fenced frame
The attacker embeds a fenced frame (or controls a document inside one), a context in which
navigator.vibrate()is meant to be blocked. - Bypass the renderer guard
Instead of calling the JavaScript API, the renderer requests
device::mojom::VibrationManagerdirectly through itsBrowserInterfaceBroker(broker->GetInterface(...)), as the shipped testVibrationManagerBindRejectedInFencedFramedemonstrates against the pre-patch binder. - Browser binds the interface
Before the fix, the binder lambda called
GetDeviceService().BindVibrationManager(...)with no fenced-frame check, connecting the fenced frame to the vibration service that policy intended to deny. - Use of the disallowed capability
With a live
VibrationManager, the fenced frame can drive device vibration and any privacy/side-channel or user-annoyance behavior that access enables(inferred)— the patch itself only shows an authorization check being added, not a memory-safety sink.
Impact Assessment
device::mojom::VibrationManager browser-process interface that the fenced-frame policy is designed to withhold, defeating a privacy/isolation restriction rather than corrupting memory. The precondition is the ability to run script in a fenced-frame tree and issue a raw Mojo interface request. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation beyond obtaining the disallowed interface is (inferred).Files Changed
tools/metrics/histograms/metadata/stability/enums.xmlcontent/browser/bad_message.hcontent/browser/browser_interface_binders.ccthird_party/blink/renderer/modules/vibration/vibration_controller.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Renderer-only guards on privileged capabilitiesWherever an API is blocked only in Blink (e.g. a
IsInFencedFrameTree()/ policy check in a renderer-side controller), verify the corresponding browser-process binder inbrowser_interface_binders.ccindependently enforces the same restriction, since renderers are untrusted. - Interface binders lacking a frame-context checkAudit
map->Add<...>entries whose lambdas callGetDeviceService()or similar service getters without consultingIsNestedWithinFencedFrame()or other frame-state predicates; compare each against the guardedBindBatteryMonitor()/BindVibrationManager()pattern. - Fenced-frame and prerender API surfaceSystematically enumerate web-exposed device and sensor interfaces (vibration, battery, orientation, pressure, geolocation) and confirm each browser-side binder rejects binds from fenced-frame or other restricted browsing contexts with a distinct
bad_messagereason.
Patch
From 0488f340b54088e3ecf978b6a2ff7227ccb187de Mon Sep 17 00:00:00 2001 From: Shunya Shishido <[email protected]> Date: Thu, 30 Jul 2026 22:58:59 -0700 Subject: [PATCH] Reject VibrationManager bind requests from fenced frames The renderer already refuses navigator.vibrate() inside a fenced-frame tree, but the browser-side device::mojom::VibrationManager binder did not enforce the same restriction. Mirror the adjacent BindBatteryMonitor() pattern: factor the lambda into BindVibrationManager(), check IsNestedWithinFencedFrame(), and terminate the renderer with the new BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME reason instead of binding the receiver to the device service. In addition, update VibrationController::vibrate() in Blink to check IsInFencedFrameTree() before calling From(navigator). This prevents VibrationController instantiation and unconditional Mojo interface binding in fenced frames, avoiding unexpected renderer terminations during web platform tests (navigator-vibrate.https.html). Add a content_browsertest that requests the interface directly on a fenced frame's BrowserInterfaceBroker and asserts the renderer is terminated with the new bad-message reason. TAG=agy CONV=0f7e37f2-2d45-401e-9bf7-0a3ac2b8fc8e Bug: 502452118 Change-Id: Ic81c2d205150577c13c50f8bcd4d4328145012a1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8173483 Reviewed-by: Rakina Zata Amni <[email protected]> Reviewed-by: Alexei Svitkine <[email protected]> Reviewed-by: Takashi Toyoshima <[email protected]> Commit-Queue: Shunya Shishido <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671637} --- diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h index 8fa45ea3..baddc1a5 100644 --- a/content/browser/bad_message.h +++ b/content/browser/bad_message.h @@ -394,6 +394,7 @@ RFH_INVALID_CONNECTION_ALLOWLIST_ATTRIBUTE = 366, PMM_UNSUBSCRIBE_IN_FENCED_FRAME = 367, PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME = 368, + BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME = 369, // Please add new elements here. The naming convention is abbreviated class // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc index 94c73d9..6f080e0 100644 --- a/content/browser/browser_interface_binders.cc +++ b/content/browser/browser_interface_binders.cc @@ -741,6 +741,20 @@ GetDeviceService().BindBatteryMonitor(std::move(receiver)); } +void BindVibrationManager( + RenderFrameHost* host, + mojo::PendingReceiver<device::mojom::VibrationManager> receiver) { + if (host->IsNestedWithinFencedFrame()) { + bad_message::ReceivedBadMessage( + host->GetProcess(), bad_message::BadMessageReason:: + BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME); + return; + } + GetDeviceService().BindVibrationManager( + std::move(receiver), static_cast<RenderFrameHostImpl*>(host) + ->CreateVibrationManagerListener()); +} + #if BUILDFLAG(ENABLE_COMPUTE_PRESSURE) void BindPressureManager( RenderFrameHost* host, @@ -1434,13 +1448,7 @@ map->Add<blink::mojom::AnchorElementInteractionHost>( &AnchorElementInteractionHostImpl::Create); - map->Add<device::mojom::VibrationManager>( - [](RenderFrameHost* host, - mojo::PendingReceiver<device::mojom::VibrationManager> receiver) { - GetDeviceService().BindVibrationManager( - std::move(receiver), static_cast<RenderFrameHostImpl*>(host) - ->CreateVibrationManagerListener()); - }); + map->Add<device::mojom::VibrationManager>(&BindVibrationManager); #if BUILDFLAG(IS_CHROMEOS) if (base::FeatureList::IsEnabled(features::kWebLockScreenApi)) { diff --git a/third_party/blink/renderer/modules/vibration/vibration_controller.cc b/third_party/blink/renderer/modules/vibration/vibration_controller.cc index 9950d048..3ba3da13 100644 --- a/third_party/blink/renderer/modules/vibration/vibration_controller.cc +++ b/third_party/blink/renderer/modules/vibration/vibration_controller.cc @@ -108,6 +108,15 @@ // reference to |window| or |navigator| was retained in another window. if (!navigator.DomWindow()) return false; + + LocalFrame* frame = navigator.DomWindow()->GetFrame(); + if (frame && frame->IsInFencedFrameTree()) { + Intervention::GenerateReport( + frame, "NavigatorVibrate", + "Blocked call to navigator.vibrate inside a fenced frame."); + return false; + } + return From(navigator).Vibrate(pattern); } @@ -133,13 +142,6 @@ UseCounter::Count(DomWindow(), WebFeature::kNavigatorVibrate); LocalFrame* frame = DomWindow()->GetFrame(); - if (frame->IsInFencedFrameTree()) { - Intervention::GenerateReport( - frame, "NavigatorVibrate", - "Blocked call to navigator.vibrate inside a fenced frame."); - return false; - } - if (!frame->GetPage()->IsPageVisible()) return false; diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml index 47d0566..dc2dc4e3 100644 --- a/tools/metrics/histograms/metadata/stability/enums.xml +++ b/tools/metrics/histograms/metadata/stability/enums.xml @@ -544,6 +544,7 @@ <int value="366" label="RFH_INVALID_CONNECTION_ALLOWLIST_ATTRIBUTE"/> <int value="367" label="PMM_UNSUBSCRIBE_IN_FENCED_FRAME"/> <int value="368" label="PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME"/> + <int value="369" label="BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME"/> </enum> <enum name="BadMessageReasonExtensions"> diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc index 2a3793a..c1d86a44 100644 --- a/content/browser/security_exploit_browsertest.cc +++ b/content/browser/security_exploit_browsertest.cc @@ -102,7 +102,9 @@ #include "net/test/embedded_test_server/embedded_test_server.h" #include "net/test/embedded_test_server/http_request.h" #include "net/traffic_annotation/network_traffic_annotation_test_helper.h" +#include "services/device/public/mojom/battery_monitor.mojom.h" #include "services/device/public/mojom/screen_orientation.mojom.h" +#include "services/device/public/mojom/vibration_manager.mojom.h" #include "services/network/public/cpp/network_switches.h" #include "services/network/public/cpp/resource_request.h" #include "services/network/public/cpp/resource_request_body.h" @@ -4175,6 +4177,66 @@ << handle->GetInitiatorOrigin()->Serialize(); } +// Verify that binding VibrationManager is rejected when requested from within a +// fenced frame. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestFencedFrames, + VibrationManagerBindRejectedInFencedFrame) { + GURL main_url(https_server()->GetURL("a.test", "/simple_page.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + GURL fenced_url( + https_server()->GetURL("a.test", "/fenced_frames/title1.html")); + RenderFrameHost* fenced_rfh_generic = + fenced_frame_test_helper().CreateFencedFrame( + shell()->web_contents()->GetPrimaryMainFrame(), fenced_url); + ASSERT_TRUE(fenced_rfh_generic); + RenderFrameHostImpl* fenced_rfh = + static_cast<RenderFrameHostImpl*>(fenced_rfh_generic); + + RenderProcessHostBadIpcMessageWaiter kill_waiter(fenced_rfh->GetProcess()); + + mojo::Receiver<blink::mojom::BrowserInterfaceBroker>& broker_receiver = + fenced_rfh->browser_interface_broker_receiver_for_testing(); + blink::mojom::BrowserInterfaceBroker* broker = + broker_receiver.internal_state()->impl(); + + mojo::Remote<device::mojom::VibrationManager> remote; + broker->GetInterface(remote.BindNewPipeAndPassReceiver()); + + EXPECT_EQ(bad_message::BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME, + kill_waiter.Wait()); +} + +// Verify that binding BatteryMonitor is rejected when requested from within a +// fenced frame. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestFencedFrames, + BatteryMonitorBindRejectedInFencedFrame) { + GURL main_url(https_server()->GetURL("a.test", "/simple_page.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + GURL fenced_url( + https_server()->GetURL("a.test", "/fenced_frames/title1.html")); + RenderFrameHost* fenced_rfh_generic = + fenced_frame_test_helper().CreateFencedFrame( + shell()->web_contents()->GetPrimaryMainFrame(), fenced_url); + ASSERT_TRUE(fenced_rfh_generic); + RenderFrameHostImpl* fenced_rfh = + static_cast<RenderFrameHostImpl*>(fenced_rfh_generic); + + RenderProcessHostBadIpcMessageWaiter kill_waiter(fenced_rfh->GetProcess()); + + mojo::Receiver<blink::mojom::BrowserInterfaceBroker>& broker_receiver = + fenced_rfh->browser_interface_broker_receiver_for_testing(); + blink::mojom::BrowserInterfaceBroker* broker = + broker_receiver.internal_state()->impl(); + + mojo::Remote<device::mojom::BatteryMonitor> remote; + broker->GetInterface(remote.BindNewPipeAndPassReceiver()); + + EXPECT_EQ(bad_message::BIBI_BIND_BATTERY_MONITOR_FOR_FENCED_FRAME, + kill_waiter.Wait()); +} + namespace { // Interceptor that replaces the origin in the DidCommitProvisionalLoadParams
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 2a3793a..c1d86a44 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -102,7 +102,9 @@
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/http_request.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "services/device/public/mojom/battery_monitor.mojom.h"
#include "services/device/public/mojom/screen_orientation.mojom.h"
+#include "services/device/public/mojom/vibration_manager.mojom.h"
#include "services/network/public/cpp/network_switches.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/cpp/resource_request_body.h"
@@ -4175,6 +4177,66 @@
<< handle->GetInitiatorOrigin()->Serialize();
}
+// Verify that binding VibrationManager is rejected when requested from within a
+// fenced frame.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestFencedFrames,
+ VibrationManagerBindRejectedInFencedFrame) {
+ GURL main_url(https_server()->GetURL("a.test", "/simple_page.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ GURL fenced_url(
+ https_server()->GetURL("a.test", "/fenced_frames/title1.html"));
+ RenderFrameHost* fenced_rfh_generic =
+ fenced_frame_test_helper().CreateFencedFrame(
+ shell()->web_contents()->GetPrimaryMainFrame(), fenced_url);
+ ASSERT_TRUE(fenced_rfh_generic);
+ RenderFrameHostImpl* fenced_rfh =
+ static_cast<RenderFrameHostImpl*>(fenced_rfh_generic);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(fenced_rfh->GetProcess());
+
+ mojo::Receiver<blink::mojom::BrowserInterfaceBroker>& broker_receiver =
+ fenced_rfh->browser_interface_broker_receiver_for_testing();
+ blink::mojom::BrowserInterfaceBroker* broker =
+ broker_receiver.internal_state()->impl();
+
+ mojo::Remote<device::mojom::VibrationManager> remote;
+ broker->GetInterface(remote.BindNewPipeAndPassReceiver());
+
+ EXPECT_EQ(bad_message::BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME,
+ kill_waiter.Wait());
+}
+
+// Verify that binding BatteryMonitor is rejected when requested from within a
+// fenced frame.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestFencedFrames,
+ BatteryMonitorBindRejectedInFencedFrame) {
+ GURL main_url(https_server()->GetURL("a.test", "/simple_page.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ GURL fenced_url(
+ https_server()->GetURL("a.test", "/fenced_frames/title1.html"));
+ RenderFrameHost* fenced_rfh_generic =
+ fenced_frame_test_helper().CreateFencedFrame(
+ shell()->web_contents()->GetPrimaryMainFrame(), fenced_url);
+ ASSERT_TRUE(fenced_rfh_generic);
+ RenderFrameHostImpl* fenced_rfh =
+ static_cast<RenderFrameHostImpl*>(fenced_rfh_generic);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(fenced_rfh->GetProcess());
+
+ mojo::Receiver<blink::mojom::BrowserInterfaceBroker>& broker_receiver =
+ fenced_rfh->browser_interface_broker_receiver_for_testing();
+ blink::mojom::BrowserInterfaceBroker* broker =
+ broker_receiver.internal_state()->impl();
+
+ mojo::Remote<device::mojom::BatteryMonitor> remote;
+ broker->GetInterface(remote.BindNewPipeAndPassReceiver());
+
+ EXPECT_EQ(bad_message::BIBI_BIND_BATTERY_MONITOR_FOR_FENCED_FRAME,
+ kill_waiter.Wait());
+}
+
namespace {
// Interceptor that replaces the origin in the DidCommitProvisionalLoadParams