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 <mihalis.haatainen@bountyy.fi>
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 <dtapuska@chromium.org>
Commit-Queue: Charlie Reis <creis@chromium.org>
Reviewed-by: Charlie Reis <creis@chromium.org>
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 <mmaerean@adobe.com>
Mihai Tica <mihai.o.tica@gmail.com>
Mihai Tica <mitica@adobe.com>
+Mihalis Haatainen <mihalis.haatainen@bountyy.fi>
Mike Pennisi <mike@mikepennisi.com>
Mike Tilburg <mtilburg@adobe.com>
Mikhail Pozdnyakov <mikhail.pozdnyakov@intel.com>
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 \