Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Scroll
DescriptionInformation leak in Scroll
ComponentScroll
Sub-componentContent
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517122234
Fix commit708c49844359 (chromium/src) +150/-1
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-27 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

`ScrollRectToVisibleInParentFrame`
a Mojo LocalFrameHost interface 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 CrossProcessFrameConnector returning 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.

Key insight
The core mistake was trusting a child renderer’s scroll rect verbatim and forwarding it to the embedder without bounding it to the child frame’s known extent; the fix intersects the incoming rect with the embedder-reported frame bounds before forwarding, so an attacker-chosen rect can no longer push the parent to positions that reveal cross-origin layout information.

Attack Path

  1. 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 ScrollRectToVisibleInParentFrame Mojo call, as the test does by casting InnerMostFrameTreeNode()->current_frame_host() to blink::mojom::LocalFrameHost*.
  2. 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) with cross_origin_boundaries = true), which pre-patch flowed unclamped into proxy->ScrollRectToVisible(rect_to_scroll, ...).
  3. 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_y instead of child_rect.y().
  4. 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

An attacker running in a cross-origin child renderer gains an information leak: the ability to influence and infer the embedding (cross-origin) document’s layout/scroll extent from within the browser process’s handling of the scroll request, which the commit message describes as a “cross-origin layout-size leak.” The precondition is an embedded cross-origin frame that can issue the 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

FunctionChangeNotes
ScrollRectToVisibleClampBrowserTestBase
content/browser/renderer_host/scroll_into_view_browsertest.cc
modified
ScrollRectToVisibleClampBrowserTest
content/browser/renderer_host/scroll_into_view_browsertest.cc
modified
ScrollRectToVisibleClampHighDPIBrowserTest
content/browser/renderer_host/scroll_into_view_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/scroll_into_view_browsertest.cc

Audit Directions

  • Unbounded renderer geometry forwarded to embedder
    Review 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 boundaries
    Look 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_boundaries is honored.
  • Empty/stale `CrossProcessFrameConnector` geometry
    Audit 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().
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)"));
Loading diff…

Regression Test / PoC

shipped with the fix
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)"));
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • third_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

  1. Unchecked Coordinate Passing in the Browser Process The Mojo IPC handler RenderFrameHostImpl::ScrollRectToVisibleInParentFrame in content/browser/renderer_host/render_frame_host_impl.cc receives a client-provided gfx::RectF rect_to_scroll and forwards it directly to the parent’s frame proxy via RenderFrameProxyHost::ScrollRectToVisible. No bounds validation or clipping of this coordinate against the sender frame’s viewport is performed.

  2. Unclipped Transform in Parent Renderer In the parent’s renderer process, RemoteFrame::ScrollRectToVisible in third_party/blink/renderer/core/frame/remote_frame.cc converts rect_to_scroll to absolute parent coordinates:

    PhysicalRect absolute_rect = owner_object->LocalToAncestorRect(
        PhysicalRect::EnclosingRect(rect_to_scroll), owner_object->View());
    

    LocalToAncestorRect performs a pure coordinate transform. It does not clip absolute_rect to 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.

  3. Unrestricted Ancestor Bubbling scroll_into_view_util::ScrollRectToVisible processes 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).

  4. Feedback via ViewportIntersectionState As the parent’s containers scroll, the position of the iframe relative to the viewport shifts. During layout updates, FrameView::UpdateViewportIntersection in third_party/blink/renderer/core/frame/frame_view.cc captures this translation shift in main_frame_transform and transmits the updated ViewportIntersectionState back 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):

  1. Call ScrollRectToVisibleInParentFrame with 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 matrix T0 from ViewportIntersectionState.
  2. Call ScrollRectToVisibleInParentFrame with a large positive scroll coordinate (e.g., rect.y = +1e7) to force ancestor containers to their maximum scroll limits. Record the resulting translation matrix T1.
  3. 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.

View on issue tracker