CVE-2026-87495
Overview
Background
- `ScrollRectToVisibleInParentFrame`
- a Mojo
LocalFrameHostinterface method a child renderer calls to ask its embedder (the parent frame’s process) to scroll a rect belonging to the child into view. - `CrossProcessFrameConnector`
- the browser-side object that mediates between an out-of-process child frame and its embedder, holding geometry the embedder last reported (e.g. via
FrameVisualProperties). - `GetLocalFrameSizeInPixels`
- accessor on
CrossProcessFrameConnectorreturning the child frame’s rendered extent in pixels, as last known to the embedder. - `gfx::RectF::Intersect`
- clips a rectangle to the region shared with another rectangle, so the result never extends beyond that other rectangle.
Root Cause Analysis
The patched code path forwards a scroll request that originates in a child renderer directly to the embedder via proxy->ScrollRectToVisible(rect_to_scroll, std::move(params)). As the commit message and the removed line show, rect_to_scroll is expressed in the sending frame’s local coordinate space but is consumed by the embedder relative to the child frame’s content area, and it was passed through unbounded — a compromised or malicious child renderer could supply a rect far outside the frame’s actual extent (the test uses gfx::RectF(0, 10000000, 1, 1)).
The invariant that was violated is that a child frame should only be able to influence scrolling within its own extent in the embedding document; instead the embedder would scroll to accommodate the out-of-range rect, driving the parent document toward its maximum scroll offset.
The fix computes frame_bounds from connector->GetLocalFrameSizeInPixels() and does clamped_rect.Intersect(frame_bounds) (only when !frame_bounds.IsEmpty()), then forwards clamped_rect instead. This is purely an added clamp/validation on data crossing the renderer→browser trust boundary — no new mechanism is introduced — so the request can no longer target a region outside the frame.
Attack Path
- Compromise or script a child frame
An attacker controls a cross-origin child frame (site B embedded in site A) whose renderer can issue the
ScrollRectToVisibleInParentFrameMojo call, as the test does by castingInnerMostFrameTreeNode()->current_frame_host()toblink::mojom::LocalFrameHost*. - Send an out-of-bounds rect
The child sends a rect with coordinates far beyond its own extent (e.g.
gfx::RectF(0, 10000000, 1, 1)withcross_origin_boundaries = true), which pre-patch flowed unclamped intoproxy->ScrollRectToVisible(rect_to_scroll, ...). - Embedder scrolls to an out-of-range position
The embedder honors the request and scrolls the parent document toward its maximum scroll offset rather than staying within the child frame’s extent; the test asserts the pre-fix behavior would reach
max_scroll_yinstead ofchild_rect.y(). - Infer cross-origin layout size (inferred) By probing which rects cause the embedder to move and observing the resulting scroll behavior, the child can deduce the embedder’s scrollable extent / layout dimensions, i.e. a cross-origin layout-size leak as stated in the commit message; the diff demonstrates the unclamped scroll but does not itself implement the observation channel.
Impact Assessment
ScrollRectToVisibleInParentFrame call with cross_origin_boundaries = true. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation beyond the layout-size disclosure the patch guards against is not demonstrated by the diff (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ScrollRectToVisibleClampBrowserTestBasecontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified | |
ScrollRectToVisibleClampBrowserTestcontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified | |
ScrollRectToVisibleClampHighDPIBrowserTestcontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/scroll_into_view_browsertest.cc
Audit Directions
- Unbounded renderer geometry forwarded to embedderReview other
RenderFrameHostImpl/proxy paths that accept coordinates or rects from a child renderer (scroll, focus, IME, autoscroll, drag) and forward them across the frame boundary without intersecting against the connector-reported frame extent. - Coordinate-space confusion at process boundariesLook for places where a value in a child frame’s local coordinate space is consumed relative to the embedder without validation, especially where
cross_origin_boundariesis honored. - Empty/stale `CrossProcessFrameConnector` geometryAudit call sites that read
GetLocalFrameSizeInPixels()or similar embedder-reported sizes and confirm they handle the empty/unset case safely rather than skipping validation, as this fix does only when!frame_bounds.IsEmpty().
Patch
From 708c49844359df4225688e562ee20ab478cd8d70 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Thu, 30 Jul 2026 08:51:27 -0700
Subject: [PATCH] [agy][content] Clamp ScrollRectToVisible rect
ScrollRectToVisibleInParentFrame receives a rect in the sending frame's
local coordinate space and forwards it to the embedder. Intersect the
rect with the frame's extent (as last reported by the embedder via
FrameVisualProperties) before forwarding.
This prevents cross-origin layout-size leak.
RectFromChildIsClampedToFrameBounds
Fixed: 517122234
Test: ScrollRectToVisibleClampBrowserTest.
Change-Id: I3b86a8622bb8393da93dd27cbae1e310457376e8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8107015
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Commit-Queue: Tzarial <zork@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671110}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 608401e6..d5f8e0b 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -136,6 +136,7 @@
#include "content/browser/renderer_host/clipboard_host_impl.h"
#include "content/browser/renderer_host/code_cache_host_impl.h"
#include "content/browser/renderer_host/cookie_utils.h"
+#include "content/browser/renderer_host/cross_process_frame_connector.h"
#include "content/browser/renderer_host/dip_util.h"
#include "content/browser/renderer_host/frame_tree.h"
#include "content/browser/renderer_host/frame_tree_node.h"
@@ -9783,7 +9784,20 @@
return;
}
- proxy->ScrollRectToVisible(rect_to_scroll, std::move(params));
+ // The rect is expressed in the sending frame's local coordinate space and
+ // is consumed by the embedder relative to the frame's content area. Clamp
+ // it to the frame's extent (as last reported by the embedder) so that the
+ // request only ever targets a region inside the frame.
+ gfx::RectF clamped_rect = rect_to_scroll;
+ if (CrossProcessFrameConnector* connector =
+ proxy->cross_process_frame_connector()) {
+ gfx::RectF frame_bounds(gfx::SizeF(connector->GetLocalFrameSizeInPixels()));
+ if (!frame_bounds.IsEmpty()) {
+ clamped_rect.Intersect(frame_bounds);
+ }
+ }
+
+ proxy->ScrollRectToVisible(clamped_rect, std::move(params));
}
void RenderFrameHostImpl::BubbleLogicalScrollInParentFrame(
diff --git a/content/browser/renderer_host/scroll_into_view_browsertest.cc b/content/browser/renderer_host/scroll_into_view_browsertest.cc
index 52cc9562..254b6ec 100644
--- a/content/browser/renderer_host/scroll_into_view_browsertest.cc
+++ b/content/browser/renderer_host/scroll_into_view_browsertest.cc
@@ -29,6 +29,7 @@
#include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/scroll/scroll_into_view_params.mojom.h"
#include "third_party/re2/src/re2/re2.h"
+#include "ui/display/display_switches.h"
#include "ui/events/event_constants.h"
#include "url/gurl.h"
@@ -977,6 +978,140 @@
RunTest();
}
+class ScrollRectToVisibleClampBrowserTestBase
+ : public ScrollIntoViewBrowserTestBase {
+ protected:
+ bool IsForceLocalFrames() const override { return false; }
+ bool IsWritingModeLTR() const override { return true; }
+ TestInvokeMethod GetInvokeMethod() const override { return kJavaScript; }
+
+ void VerifyRectFromChildIsClamped();
+ void VerifyValidScrollIsNotClamped();
+};
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyRectFromChildIsClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ // The child frame's location and size in the root document, at the initial
+ // (0,0) scroll position established by SetupTest.
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ // The page is built so that there is scrollable extent well beyond the
+ // child frame; otherwise the assertion below isn't meaningful.
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, 10000000, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ // The resulting scroll must stay within the child frame's extent in the
+ // embedder rather than reaching the document's maximum scroll offset.
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ EXPECT_NEAR(scroll_y, child_rect.y(), 0.5);
+ EXPECT_LT(scroll_y, max_scroll_y);
+}
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyValidScrollIsNotClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+ double dsf =
+ EvalJs(RootFrameTreeNode(), "window.devicePixelRatio").ExtractDouble();
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ // Target 50 DIPs inside the child frame.
+ // We must send it in physical pixels because the Mojo call expects physical
+ // pixels (when zoom-for-dsf is enabled).
+ double target_y_dip = child_rect.height() - 50;
+ double target_y_px = target_y_dip * dsf;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, target_y_px, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ // It should scroll to the target.
+ EXPECT_NEAR(scroll_y, child_rect.y() + target_y_dip, 0.5);
+}
+
+class ScrollRectToVisibleClampBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {};
+
+// The rect bubbled from a child frame to its embedder is in the child's local
+// frame coordinates. Ensure the embedder only scrolls within the child's
+// extent even if the requested rect lies far outside it.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
+class ScrollRectToVisibleClampHighDPIBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {
+ public:
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ ScrollRectToVisibleClampBrowserTestBase::SetUpCommandLine(command_line);
+ command_line->AppendSwitchASCII(switches::kForceDeviceScaleFactor, "2.0");
+ }
+};
+
+// Ensure clamping works correctly on High-DPI displays where the device scale
+// factor is not 1.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest,
ProgrammaticScrollIntoViewDoesntCrossFencedFrame) {
ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB)"));
Regression Test / PoC
diff --git a/content/browser/renderer_host/scroll_into_view_browsertest.cc b/content/browser/renderer_host/scroll_into_view_browsertest.cc
index 52cc9562..254b6ec 100644
--- a/content/browser/renderer_host/scroll_into_view_browsertest.cc
+++ b/content/browser/renderer_host/scroll_into_view_browsertest.cc
@@ -29,6 +29,7 @@
#include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/scroll/scroll_into_view_params.mojom.h"
#include "third_party/re2/src/re2/re2.h"
+#include "ui/display/display_switches.h"
#include "ui/events/event_constants.h"
#include "url/gurl.h"
@@ -977,6 +978,140 @@
RunTest();
}
+class ScrollRectToVisibleClampBrowserTestBase
+ : public ScrollIntoViewBrowserTestBase {
+ protected:
+ bool IsForceLocalFrames() const override { return false; }
+ bool IsWritingModeLTR() const override { return true; }
+ TestInvokeMethod GetInvokeMethod() const override { return kJavaScript; }
+
+ void VerifyRectFromChildIsClamped();
+ void VerifyValidScrollIsNotClamped();
+};
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyRectFromChildIsClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ // The child frame's location and size in the root document, at the initial
+ // (0,0) scroll position established by SetupTest.
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ // The page is built so that there is scrollable extent well beyond the
+ // child frame; otherwise the assertion below isn't meaningful.
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, 10000000, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ // The resulting scroll must stay within the child frame's extent in the
+ // embedder rather than reaching the document's maximum scroll offset.
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ EXPECT_NEAR(scroll_y, child_rect.y(), 0.5);
+ EXPECT_LT(scroll_y, max_scroll_y);
+}
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyValidScrollIsNotClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+ double dsf =
+ EvalJs(RootFrameTreeNode(), "window.devicePixelRatio").ExtractDouble();
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ // Target 50 DIPs inside the child frame.
+ // We must send it in physical pixels because the Mojo call expects physical
+ // pixels (when zoom-for-dsf is enabled).
+ double target_y_dip = child_rect.height() - 50;
+ double target_y_px = target_y_dip * dsf;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, target_y_px, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ // It should scroll to the target.
+ EXPECT_NEAR(scroll_y, child_rect.y() + target_y_dip, 0.5);
+}
+
+class ScrollRectToVisibleClampBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {};
+
+// The rect bubbled from a child frame to its embedder is in the child's local
+// frame coordinates. Ensure the embedder only scrolls within the child's
+// extent even if the requested rect lies far outside it.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
+class ScrollRectToVisibleClampHighDPIBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {
+ public:
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ ScrollRectToVisibleClampBrowserTestBase::SetUpCommandLine(command_line);
+ command_line->AppendSwitchASCII(switches::kForceDeviceScaleFactor, "2.0");
+ }
+};
+
+// Ensure clamping works correctly on High-DPI displays where the device scale
+// factor is not 1.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest,
ProgrammaticScrollIntoViewDoesntCrossFencedFrame) {
ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB)"));
Original Bug Report
Cross-origin layout-size oracle via unclipped ScrollRectToVisible
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential cross-origin layout-size oracle exists in Chromium due to a lack of coordinate clipping during scroll-into-view requests across frames. An untrusted cross-origin iframe can submit extreme coordinates to force ancestor scroll containers in the parent frame to scroll to their boundaries. The resulting layout shift is leaked back to the iframe via ViewportIntersectionState feedback, allowing it to measure parent container dimensions.
Affected files:
content/browser/renderer_host/render_frame_host_impl.ccthird_party/blink/renderer/core/frame/remote_frame.cc
Estimated timestamp from git blame: 2017-10-23
Summary
A potential cross-origin layout-size disclosure (XS-Leak) exists in Chromium’s frame scrolling code. A malicious or compromised cross-origin iframe can exploit this behavior to measure the scrollable content dimensions of its ancestor scroll containers in the parent document (such as div blocks with overflow: scroll), bypassing Site Isolation and the Same-Origin Policy.
Potential Root Cause Analysis
-
Unchecked Coordinate Passing in the Browser Process The Mojo IPC handler
RenderFrameHostImpl::ScrollRectToVisibleInParentFrameincontent/browser/renderer_host/render_frame_host_impl.ccreceives a client-providedgfx::RectF rect_to_scrolland forwards it directly to the parent’s frame proxy viaRenderFrameProxyHost::ScrollRectToVisible. No bounds validation or clipping of this coordinate against the sender frame’s viewport is performed. -
Unclipped Transform in Parent Renderer In the parent’s renderer process,
RemoteFrame::ScrollRectToVisibleinthird_party/blink/renderer/core/frame/remote_frame.ccconvertsrect_to_scrollto absolute parent coordinates:PhysicalRect absolute_rect = owner_object->LocalToAncestorRect( PhysicalRect::EnclosingRect(rect_to_scroll), owner_object->View());LocalToAncestorRectperforms a pure coordinate transform. It does not clipabsolute_rectto the actual bounds of the iframe’s layout box. An attacker can supply arbitrarily large coordinates (e.g.,±1e7) which map to points far outside the iframe element. -
Unrestricted Ancestor Bubbling
scroll_into_view_util::ScrollRectToVisibleprocesses the converted rect. It traverses the ancestor containment chain and scrolls all scrollable containers in the parent document. Each container clamps the scrolling to its minimum or maximum limits (ClampScrollOffset). -
Feedback via ViewportIntersectionState As the parent’s containers scroll, the position of the iframe relative to the viewport shifts. During layout updates,
FrameView::UpdateViewportIntersectioninthird_party/blink/renderer/core/frame/frame_view.cccaptures this translation shift inmain_frame_transformand transmits the updatedViewportIntersectionStateback to the child iframe, leaking the exact scroll offsets.
Potential Attack Steps
An attacker in a cross-origin iframe (or with code execution in the iframe’s renderer process) could potentially measure cross-origin layout dimensions by following these steps (note: these are suggested steps, as our analysis has been conducted statically without live code execution capability):
- Call
ScrollRectToVisibleInParentFramewith a large negative scroll coordinate (e.g.,rect.y = -1e7) to force ancestor scrollable containers to scroll to their minimum scroll limits. Record the resulting translation matrixT0fromViewportIntersectionState. - Call
ScrollRectToVisibleInParentFramewith a large positive scroll coordinate (e.g.,rect.y = +1e7) to force ancestor containers to their maximum scroll limits. Record the resulting translation matrixT1. - Compute the delta
(T0 - T1).y, which exposes the sum of the maximum scroll offsets (content_size - viewport_size) across the parent containers. By using a binary search over intermediate coordinates, the attacker could isolate individual ancestor container dimensions.
Suggested Fix
To remediate this issue, coordinate conversions in RemoteFrame::ScrollRectToVisible should clip the incoming rect_to_scroll coordinates against the layout boundaries of the subframe’s element (or the subframe’s viewport size) before applying the coordinate space transformation and bubbling the scroll action upward.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.