CVE-2026-11221
Overview
Files Changed
AUTHORScontent/browser/bad_message.hcontent/browser/renderer_host/render_widget_host_delegate.cccontent/browser/renderer_host/render_widget_host_delegate.hcontent/browser/renderer_host/render_widget_host_impl.cccontent/browser/security_exploit_browsertest.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl.htools/metrics/histograms/metadata/stability/enums.xml
Patch
From 80ddc28c7cb4038a6a73b15f452c1239222fd036 Mon Sep 17 00:00:00 2001 From: Mihalis Haatainen <[email protected]> Date: Fri, 17 Apr 2026 15:00:53 -0700 Subject: [PATCH] Fix missing browser-side sandbox enforcement for kPointerLock RenderWidgetHostImpl::RequestMouseLock() does not check the WebSandboxFlags::kPointerLock sandbox flag. A compromised renderer inside a sandboxed frame (without allow-pointer-lock) can call RequestMouseLock() via Mojo IPC directly, bypassing the renderer-side check in PointerLockController::RequestPointerLock(). Add browser-side IsSandboxed(kPointerLock) check matching the pattern of the kModals fix (Bug 491676472, fixed 2025-03-12). Bug: 492211919 Change-Id: I504cbfc17b6e7e8484a9622939dc98fef2beb755 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7761269 Reviewed-by: Dave Tapuska <[email protected]> Commit-Queue: Charlie Reis <[email protected]> Reviewed-by: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1616880} --- diff --git a/AUTHORS b/AUTHORS index 7ec65ea..ebb541c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1099,6 +1099,7 @@ Mihai Maerean <[email protected]> Mihai Tica <[email protected]> Mihai Tica <[email protected]> +Mihalis Haatainen <[email protected]> Mike Pennisi <[email protected]> Mike Tilburg <[email protected]> Mikhail Pozdnyakov <[email protected]> diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h index 4b9d513..d1b63fb 100644 --- a/content/browser/bad_message.h +++ b/content/browser/bad_message.h @@ -365,6 +365,7 @@ RFH_OPEN_URL_INVALID_DISPOSITION = 337, RFH_ENTER_FULLSCREEN_PERMISSION_DENIED = 338, DT_DUPLICATE_CHILD_TARGET_CREATED = 339, + RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME = 340, // 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/renderer_host/render_widget_host_delegate.cc b/content/browser/renderer_host/render_widget_host_delegate.cc index f180bdd..fd4b6b1 100644 --- a/content/browser/renderer_host/render_widget_host_delegate.cc +++ b/content/browser/renderer_host/render_widget_host_delegate.cc @@ -133,6 +133,11 @@ return false; } +bool RenderWidgetHostDelegate::IsPointerLockSandboxedForWidget( + RenderWidgetHostImpl* render_widget_host) { + return false; +} + bool RenderWidgetHostDelegate::RequestKeyboardLock(RenderWidgetHostImpl* host, bool esc_key_locked) { return false; diff --git a/content/browser/renderer_host/render_widget_host_delegate.h b/content/browser/renderer_host/render_widget_host_delegate.h index e4c41fea..d83118c 100644 --- a/content/browser/renderer_host/render_widget_host_delegate.h +++ b/content/browser/renderer_host/render_widget_host_delegate.h @@ -258,6 +258,17 @@ // pointer isn't locked. virtual RenderWidgetHostImpl* GetPointerLockWidget(); + // Returns true if the owning frame of |render_widget_host| is sandboxed + // with the kPointerLock flag, meaning the pointer lock request should be + // denied. It is ok to only check the top-most frame of the widget, because + // any subframes within the widget will be at least as restrictive as it. + // Any additional restrictions imposed on subframes of the widget cannot be + // enforced by the browser process, because they share a renderer process + // with the top-most frame of the widget. + // Note: crbug.com/492211919 + virtual bool IsPointerLockSandboxedForWidget( + RenderWidgetHostImpl* render_widget_host); + // Returns true if we are waiting for the user to make a selection on the // pointer lock permission request dialog. virtual bool IsWaitingForPointerLockPrompt( diff --git a/content/browser/renderer_host/render_widget_host_impl.cc b/content/browser/renderer_host/render_widget_host_impl.cc index a97558e..6eef4da 100644 --- a/content/browser/renderer_host/render_widget_host_impl.cc +++ b/content/browser/renderer_host/render_widget_host_impl.cc @@ -3279,6 +3279,17 @@ bool from_user_gesture, bool unadjusted_movement, input::InputRouterImpl::RequestMouseLockCallback response) { + // Browser-side enforcement of the kPointerLock sandbox flag. + // The renderer correctly blocks this via PointerLockController, but a + // compromised renderer can bypass that check via direct Mojo IPC. + // Note: crbug.com/492211919 + if (delegate_ && delegate_->IsPointerLockSandboxedForWidget(this)) { + bad_message::ReceivedBadMessage( + GetProcess(), bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME); + std::move(response).Run(blink::mojom::PointerLockResult::kPermissionDenied, + mojo::NullRemote()); + return; + } if (IsPointerLocked()) { std::move(response).Run(blink::mojom::PointerLockResult::kAlreadyLocked, /*context=*/mojo::NullRemote()); diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc index aaa2699..29527cd7 100644 --- a/content/browser/security_exploit_browsertest.cc +++ b/content/browser/security_exploit_browsertest.cc @@ -3658,4 +3658,38 @@ EXPECT_TRUE(subframe->IsRenderFrameLive()); } +// Regression test for browser-side validation of the allow-pointer-lock +// sandbox attribute. A sandboxed frame without allow-pointer-lock should not +// be able to acquire pointer lock via Mojo IPC. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, + PointerLockDisallowedFromSandboxedFrame) { + IsolateOrigin("b.com"); + GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root(); + RenderFrameHostImpl* main_frame = root->current_frame_host(); + GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html")); + { + std::string js_str = base::StringPrintf( + "var frame = document.createElement('iframe'); " + "frame.sandbox = 'allow-scripts'; " + "frame.src = '%s'; " + "document.body.appendChild(frame);", + child_url.spec().c_str()); + EXPECT_TRUE(ExecJs(main_frame, js_str)); + ASSERT_TRUE(WaitForLoadStop(web_contents)); + } + RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host(); + EXPECT_TRUE( + subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock)); + RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess()); + RenderWidgetHostImpl* rwh = subframe->GetRenderWidgetHost(); + rwh->RequestMouseLock(false, false, base::DoNothing()); + EXPECT_EQ(bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME, + kill_waiter.Wait()); + EXPECT_FALSE(subframe->IsRenderFrameLive()); +} + } // namespace content diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index da7bf6a8..73df454 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -5284,6 +5284,24 @@ } } +bool WebContentsImpl::IsPointerLockSandboxedForWidget( + RenderWidgetHostImpl* render_widget_host) { + // Check the sandbox flags of the frame that owns the requesting widget. + // It is ok to only check the top-most frame of the widget, because any + // subframes within the widget will be at least as restrictive as it. Any + // additional restrictions imposed on subframes of the widget cannot be + // enforced by the browser process, because they share a renderer process + // with the top-most frame of the widget. + // Note: crbug.com/492211919 + for (FrameTreeNode* node : GetPrimaryFrameTree().Nodes()) { + RenderFrameHostImpl* rfh = node->current_frame_host(); + if (rfh && rfh->GetRenderWidgetHost() == render_widget_host) { + return rfh->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock); + } + } + return false; +} + bool WebContentsImpl::HasPointerLock(RenderWidgetHostImpl* render_widget_host) { // To verify if the mouse is locked, the mouse_lock_widget_ needs to be // assigned to the widget that requested the mouse lock, and the top-level diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h index 03b93177..a082cfe 100644 --- a/content/browser/web_contents/web_contents_impl.h +++ b/content/browser/web_contents/web_contents_impl.h @@ -1196,6 +1196,8 @@ DevicePostureProviderImpl* GetDevicePostureProvider() override; bool GetResizable() override; void LostPointerLock(RenderWidgetHostImpl* render_widget_host) override; + bool IsPointerLockSandboxedForWidget( + RenderWidgetHostImpl* render_widget_host) override; bool HasPointerLock(RenderWidgetHostImpl* render_widget_host) override; RenderWidgetHostImpl* GetPointerLockWidget() override; bool OnRenderFrameProxyVisibilityChanged( diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml index 74cc653..7bf5109e 100644 --- a/tools/metrics/histograms/metadata/stability/enums.xml +++ b/tools/metrics/histograms/metadata/stability/enums.xml @@ -506,6 +506,7 @@ <int value="337" label="RFH_OPEN_URL_INVALID_DISPOSITION"/> <int value="338" label="RFH_ENTER_FULLSCREEN_PERMISSION_DENIED"/> <int value="339" label="DT_DUPLICATE_CHILD_TARGET_CREATED"/>
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index aaa2699..29527cd7 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -3658,4 +3658,38 @@
EXPECT_TRUE(subframe->IsRenderFrameLive());
}
+// Regression test for browser-side validation of the allow-pointer-lock
+// sandbox attribute. A sandboxed frame without allow-pointer-lock should not
+// be able to acquire pointer lock via Mojo IPC.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ PointerLockDisallowedFromSandboxedFrame) {
+ IsolateOrigin("b.com");
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+ RenderFrameHostImpl* main_frame = root->current_frame_host();
+ GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+ {
+ std::string js_str = base::StringPrintf(
+ "var frame = document.createElement('iframe'); "
+ "frame.sandbox = 'allow-scripts'; "
+ "frame.src = '%s'; "
+ "document.body.appendChild(frame);",
+ child_url.spec().c_str());
+ EXPECT_TRUE(ExecJs(main_frame, js_str));
+ ASSERT_TRUE(WaitForLoadStop(web_contents));
+ }
+ RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+ EXPECT_TRUE(
+ subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPointerLock));
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+ RenderWidgetHostImpl* rwh = subframe->GetRenderWidgetHost();
+ rwh->RequestMouseLock(false, false, base::DoNothing());
+ EXPECT_EQ(bad_message::RWH_POINTER_LOCK_FROM_SANDBOXED_FRAME,
+ kill_waiter.Wait());
+ EXPECT_FALSE(subframe->IsRenderFrameLive());
+}
+
} // namespace content
Original Bug Report
Missing Browser-Side Sandbox Enforcement for kPointerLock
Steps to reproduce the problem
RenderWidgetHostImpl::RequestMouseLock()does not check theWebSandboxFlags::kPointerLocksandbox flag. A compromised renderer inside a sandboxed frame (withoutallow-pointer-lock) can callFrameWidgetInputHandler::RequestMouseLockover Mojo IPC directly, bypassing the renderer-side check and obtaining pointer lock from the browser process.
Severity
High - Sandbox policy bypass from compromised renderer process.
Comparable to Bug 491676472 (allow-modals missing browser-side check,
fixed 2025-03-12). That bug was assigned Security_Severity-High and
fixed with bad_message::ReceivedBadMessage().
Component
- Primary:
content/browser/renderer_host/render_widget_host_impl.cc - Secondary:
content/browser/web_contents/web_contents_impl.cc - Mojo interface:
blink.mojom.FrameWidgetInputHandler::RequestMouseLock
Affected Versions
All current Chrome stable/beta versions (unfixed as of 2026-03-12):
- Chrome 145.0.7632.160 (Stable, arm64, macOS) - verified
- Chrome 146.0.7680.72 (next Stable, branch pos 1582197) - unfixed
- Chromium main - unfixed
Note: The comparable kModals fix (Bug 491676472) landed at Cr-Commit-Position 1598123, which is newer than both 145 stable (pos ~1568190) and 146 stable (pos ~1582197). kPointerLock fix has not landed anywhere.
Problem Description
Root Cause
Renderer-side (PointerLockController::RequestPointerLock) correctly
checks the sandbox flag and blocks the request:
// third_party/blink/renderer/core/page/pointer_lock_controller.cc:94
if (window->IsSandboxed(WebSandboxFlags::kPointerLock)) {
resolver->RejectWithSecurityError(...);
return;
}
// calls: GetWidgetForLocalRoot()->RequestMouseLock(...)
But the browser-side handler does NOT re-validate:
// content/browser/renderer_host/render_widget_host_impl.cc:3281
void RenderWidgetHostImpl::RequestMouseLock(
bool from_user_gesture,
bool unadjusted_movement,
RequestMouseLockCallback response) {
// ❌ MISSING: IsSandboxed(WebSandboxFlags::kPointerLock) check
if (IsPointerLocked()) { ... }
if (!view_ || !view_->CanBePointerLocked()) { ... }
delegate_->RequestToLockPointer(this, from_user_gesture, ...);
}
WebContentsImpl::RequestToLockPointer (called next) checks for
fenced frames but NOT for sandboxed frames:
// content/browser/web_contents/web_contents_impl.cc:5091
void WebContentsImpl::RequestToLockPointer(...) {
if (render_widget_host->frame_tree()->is_fenced_frame()) {
ReceivedBadMessage(...); // ✅ fenced frame check
return;
}
// ❌ NO: GetSandboxFlags() & WebSandboxFlags::kPointerLock
delegate_->RequestPointerLock(this, user_gesture, ...);
}
Attack Scenario
As an attacker with code execution in a renderer process (e.g. via a V8 or Blink memory corruption bug):
- The renderer is hosting a sandboxed iframe (sandbox=“allow-scripts”, no allow-pointer-lock).
- Normal pointer lock is blocked by renderer check.
- Attacker patches renderer or calls Mojo IPC directly:
FrameWidgetInputHandler::RequestMouseLock(true, false, callback) - Browser grants pointer lock - cursor disappears, all mouse movement events captured by the sandboxed frame.
As an attacker I could:
- Capture the mouse cursor from a privileged parent frame context
- Perform UI redress attacks against the user (cursor hidden, fake UI shown)
- Bypass the
allow-pointer-locksandboxing contract that sites rely on to safely embed third-party content
PoC
See attached index.html. It demonstrates:
- The renderer-side check correctly blocks pointer lock via normal JS.
- The missing browser-side check (shown via code reference).
- A compromised renderer can call the Mojo IPC directly to bypass.
To reproduce the FULL bypass (requires patched renderer or Mojo fuzzer):
# Call FrameWidgetInputHandler::RequestMouseLock(true, false)
# from a renderer hosting a sandbox="allow-scripts" iframe
# without allow-pointer-lock. Browser grants the lock.
Comparison: Bug 491676472 (Fixed)
The just-fixed allow-modals bug had the identical pattern:
// BEFORE fix: RunJavaScriptDialog() had no IsSandboxed(kModals) check
// AFTER fix (render_frame_host_impl.cc:7251):
if (IsSandboxed(WebSandboxFlags::kModals)) {
bad_message::ReceivedBadMessage(GetProcess(),
bad_message::RFH_JS_DIALOG_FROM_SANDBOXED_FRAME);
return;
}
Proposed Fix
In RenderWidgetHostImpl::RequestMouseLock():
// Get the focused frame and check its sandbox flags
if (RenderFrameHostImpl* rfh = GetFocusedFrame()) {
if (rfh->IsSandboxed(WebSandboxFlags::kPointerLock)) {
bad_message::ReceivedBadMessage(
GetProcess(),
bad_message::RWHI_POINTER_LOCK_FROM_SANDBOXED_FRAME);
std::move(response).Run(
blink::mojom::PointerLockResult::kPermissionDenied,
mojo::NullRemote());
return;
}
}
Also add corresponding test to
content/browser/security_exploit_browsertest.cc (same file as the
kModals fix test).
Additional Notes
- Other sandbox flags may have similar missing browser-side checks.
Recommend auditing:
kDownloads,kOrientationLock,kPresentationController,kDocumentDomain. kPopupsalready has browser-side check (CreateNewWindow, line 10092).kTopNavigationByUserActivationhas browser-side check (line 983).kModalsjust got its check (Bug 491676472).
Summary
Missing Browser-Side Sandbox Enforcement for kPointerLock
Additional Data
Category: Security
Chrome Channel: Stable
Regression: N/A \