CVE-2026-79132
Overview
Background
- Overscroll edge-swipe navigation
- On Android, a horizontal scroll that begins near the left/right edge of the view can trigger a history back/forward navigation, gated by
OverscrollRefresh. - `scrollable_viewport_size`
- The renderer-reported size of the page’s scrollable viewport, which can be smaller than the native view when browser controls or insets are shown, and is renderer-influenced data.
- `view_width_px`
- The width of the embedding native view in device pixels, a browser-side (“trusted”) value obtained from
view_.GetSizeDevicePx(). - `viewport_width_` / `scroll_begin_x_`
- In
OverscrollRefresh, the stored viewport width and the x-coordinate where a scroll began, both used to decide if a scroll started from an edge.
Root Cause Analysis
OverscrollRefresh::OnFrameUpdated previously stored viewport_size.width() (derived from the renderer-supplied scrollable_viewport_size) into viewport_width_, which is then used in the edge-detection test scroll_begin_x_ < edge_width_ || viewport_width_ - scroll_begin_x_ < edge_width_. Because the scrollable viewport can be smaller than the actual view, and its width is influenced by the renderer, the value used to gate edge-swipe navigation did not correspond to the true view geometry, and scroll_begin_x_ could fall outside the [0, viewport_width_] range that the edge math assumes.
The fix threads the trusted native view_width_px all the way from RenderWidgetHostViewAndroid through OverscrollControllerAndroid::OnFrameMetadataUpdated into OnFrameUpdated, and uses it for viewport_width_ while keeping the renderer-derived scrollable_viewport_height only for the (feature-gated) bottom-overscroll computation. New DCHECKs (DCHECK_LE(scroll_begin_x_, viewport_width_) and DCHECK_GE(scroll_begin_x_, 0)) assert the geometry invariant the edge test relies on. The commit message describes this as mitigating a “minor security issue” by using a trusted value for edge detection.
scrollable_viewport_size width into security-relevant edge-swipe gating; the fix substitutes the browser-trusted native view width for that decision while leaving the untrusted height only for cosmetic bottom-overscroll reporting.Attack Path
- Manipulate reported viewport
A malicious page influences frame metadata so the
scrollable_viewport_sizewidth used for edge detection differs from the true view width (e.g. via browser-controls/inset conditions). - Begin an edge scroll
A horizontal scroll begins at an x-coordinate that, measured against the manipulated
viewport_width_, satisfies the edge test even though it does not correspond to a genuine view edge. - Trip edge detection
WillHandleScrollUpdatecomputesscroll_from_edgefrom the mismatchedviewport_width_andscroll_begin_x_, mis-classifying the gesture as an edge swipe. - Spurious navigation
The overscroll controller treats the gesture as an edge-swipe history navigation the user did not intend
(inferred); the diff shows only the gating logic, not the downstream navigation effect concretely.
Impact Assessment
Input/overscroll path. The commit message itself characterizes this as a “minor security issue”; no exploited-in-the-wild status is reported. Any further consequence beyond mis-triggered navigation is (inferred) and not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/android/overscroll_controller_android.cc |
modified | |
ifcontent/browser/renderer_host/render_widget_host_view_android.cc |
modified | |
TEST_Fui/android/overscroll_refresh_unittest.cc |
modified |
Files Changed
content/browser/android/overscroll_controller_android.hui/android/overscroll_refresh_unittest.ccui/android/overscroll_refresh.ccui/android/overscroll_refresh.hcontent/browser/renderer_host/render_widget_host_view_android.cccontent/browser/android/overscroll_controller_android.cc
Audit Directions
- Trusted vs. renderer-supplied geometryAudit other input/gesture gating that consumes
scrollable_viewport_sizeor other renderer-reported metadata where the true native view dimensions are the security-relevant quantity. - Range assumptions without checksLook for edge/threshold computations like
dimension - coordinate < thresholdthat assume the coordinate lies within[0, dimension]but never validate it, especially where one input is attacker-influenced. - Browser-controls / inset skewReview paths where browser controls or insets make the scrollable viewport smaller than the view, and confirm each consumer uses the dimension appropriate to its purpose rather than reusing viewport size everywhere.
Patch
From 8d0f86ea0f21884d501c0750a215d513fedb7bc4 Mon Sep 17 00:00:00 2001 From: Bo Liu <[email protected]> Date: Mon, 27 Jul 2026 12:23:21 -0700 Subject: [PATCH] android: Use trusted value for overscroll edge detection Scrollable viewport can be smaller than view size for things like browser controls or insets. But for edge scroll detection, it actually makes sense to just use the view size in these cases anyway. So mitigate a minor security issue. Bug: 513834155 Change-Id: Ie8254e2875e6a9caadb2077282418c7d56062bf7 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8141959 Reviewed-by: Kartar Singh <[email protected]> Commit-Queue: Bo Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1668879} --- diff --git a/content/browser/android/overscroll_controller_android.cc b/content/browser/android/overscroll_controller_android.cc index 0a554bdf..08a2f75 100644 --- a/content/browser/android/overscroll_controller_android.cc +++ b/content/browser/android/overscroll_controller_android.cc @@ -254,6 +254,7 @@ } void OverscrollControllerAndroid::OnFrameMetadataUpdated( + float view_width_px, float page_scale_factor, float device_scale_factor, const gfx::SizeF& scrollable_viewport_size, @@ -273,8 +274,9 @@ gfx::ScalePoint(root_scroll_offset, scale_factor); if (refresh_effect_) { - refresh_effect_->OnFrameUpdated(viewport_size, content_scroll_offset, - content_size, root_overflow_y_hidden); + refresh_effect_->OnFrameUpdated(view_width_px, viewport_size.height(), + content_scroll_offset, content_size, + root_overflow_y_hidden); } if (glow_effect_) { diff --git a/content/browser/android/overscroll_controller_android.h b/content/browser/android/overscroll_controller_android.h index 3aaf999..af46de0 100644 --- a/content/browser/android/overscroll_controller_android.h +++ b/content/browser/android/overscroll_controller_android.h @@ -68,8 +68,10 @@ // Note: The effect will detach itself when no further animation is required. bool Animate(base::TimeTicks current_time, cc::slim::Layer* parent_layer); - // To be called whenever the content frame has been updated. - void OnFrameMetadataUpdated(float page_scale_factor, + // To be called whenever the content frame has been updated. `view_width_px` + // is the width of the embedding native view in device pixels. + void OnFrameMetadataUpdated(float view_width_px, + float page_scale_factor, float device_scale_factor, const gfx::SizeF& scrollable_viewport_size, const gfx::SizeF& root_layer_size, diff --git a/content/browser/renderer_host/render_widget_host_view_android.cc b/content/browser/renderer_host/render_widget_host_view_android.cc index dd3fe213..1b0b117 100644 --- a/content/browser/renderer_host/render_widget_host_view_android.cc +++ b/content/browser/renderer_host/render_widget_host_view_android.cc @@ -1003,8 +1003,9 @@ if (overscroll_controller_) { overscroll_controller_->OnFrameMetadataUpdated( - metadata.page_scale_factor, metadata.device_scale_factor, - metadata.scrollable_viewport_size, metadata.root_layer_size, + view_.GetSizeDevicePx().width(), metadata.page_scale_factor, + metadata.device_scale_factor, metadata.scrollable_viewport_size, + metadata.root_layer_size, metadata.root_scroll_offset.value_or(gfx::PointF()), metadata.root_overflow_y_hidden); } diff --git a/ui/android/overscroll_refresh.cc b/ui/android/overscroll_refresh.cc index 6e41d8d..3770f3e 100644 --- a/ui/android/overscroll_refresh.cc +++ b/ui/android/overscroll_refresh.cc @@ -115,6 +115,8 @@ } } else if (in_x_direction) { DCHECK_GE(viewport_width_, 0); + DCHECK_LE(scroll_begin_x_, viewport_width_); + DCHECK_GE(scroll_begin_x_, 0); bool scroll_from_edge = scroll_begin_x_ < edge_width_ || viewport_width_ - scroll_begin_x_ < edge_width_; bool touchpad_swipe_to_navigate = @@ -205,15 +207,17 @@ return scroll_state_.IsAwaitingAck(); } -void OverscrollRefresh::OnFrameUpdated(const gfx::SizeF& viewport_size, +void OverscrollRefresh::OnFrameUpdated(float view_width, + float scrollable_viewport_height, const gfx::PointF& content_scroll_offset, const gfx::SizeF& content_size, bool root_overflow_y_hidden) { - viewport_width_ = viewport_size.width(); + viewport_width_ = view_width; scrolled_to_top_ = content_scroll_offset.y() == 0; if (base::FeatureList::IsEnabled(kReportBottomOverscrolls)) { - scrolled_to_bottom_ = content_size.height() <= - content_scroll_offset.y() + viewport_size.height(); + scrolled_to_bottom_ = + content_size.height() <= + content_scroll_offset.y() + scrollable_viewport_height; } overflow_y_hidden_ = root_overflow_y_hidden; } diff --git a/ui/android/overscroll_refresh.h b/ui/android/overscroll_refresh.h index 184570a7..7ebfb24 100644 --- a/ui/android/overscroll_refresh.h +++ b/ui/android/overscroll_refresh.h @@ -96,8 +96,11 @@ // Notify the effect of the latest scroll offset and overflow properties. // The effect will be disabled when the offset is non-zero or overflow is - // hidden. Note: All dimensions are in device pixels. - void OnFrameUpdated(const gfx::SizeF& viewport_size, + // hidden. Note: All dimensions are in device pixels. `view_width` is the + // width of the embedding native view and is used for edge-swipe gating; + // `scrollable_viewport_height` reflects the page's scrollable viewport. + void OnFrameUpdated(float view_width, + float scrollable_viewport_height, const gfx::PointF& content_scroll_offset, const gfx::SizeF& content_size, bool root_overflow_y_hidden); diff --git a/ui/android/overscroll_refresh_unittest.cc b/ui/android/overscroll_refresh_unittest.cc index d2c659f..e903f28 100644 --- a/ui/android/overscroll_refresh_unittest.cc +++ b/ui/android/overscroll_refresh_unittest.cc @@ -17,6 +17,8 @@ const float kDefaultEdgeWidth = OverscrollRefresh::kDefaultNavigationEdgeWidth * kDipScale; const gfx::SizeF kViewport(100, 100); +const float kViewWidth = 100; +const float kViewportHeight = 100; const gfx::PointF kZeroOffset(0, 0); const gfx::SizeF kContentSize(100, 10000); const bool kOverflowYNotHidden = false; @@ -29,8 +31,8 @@ OverscrollRefreshTest() : OverscrollRefreshHandler(nullptr), effect_(OverscrollRefresh(this, kDefaultEdgeWidth)) { - effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, + kContentSize, kOverflowYNotHidden); } // OverscrollRefreshHandler implementation. @@ -192,11 +194,11 @@ // A positive y scroll offset at the start of scroll will prevent activation, // even if the subsequent scroll overscrolls upward. gfx::PointF nonzero_offset(0, 10); - effect_.OnFrameUpdated(kViewport, nonzero_offset, kContentSize, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset, + kContentSize, kOverflowYNotHidden); effect_.OnScrollBegin(kStartPos); - effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize, kOverflowYNotHidden); gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10); ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta)); @@ -214,7 +216,8 @@ TEST_F(OverscrollRefreshTest, RefreshNotTriggeredIfOverflowYHidden) { // overflow-y:hidden at the start of scroll will prevent activation. - effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, true); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize, + true); effect_.OnScrollBegin(kStartPos); gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10); @@ -234,7 +237,8 @@ TEST_F(OverscrollRefreshTest, RefreshNotTriggeredIfOverflowYHiddenNoUpdateBeforeOverscroll) { // overflow-y:hidden at the start of scroll will prevent activation. - effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, true); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize, + true); effect_.OnScrollBegin(kStartPos); gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10); @@ -350,8 +354,8 @@ // Set yOffset as reaching the bottom of the page. gfx::PointF nonzero_offset(0, 900); gfx::SizeF content_size(100, 1000); - effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset, + content_size, kOverflowYNotHidden); gfx::PointF start(2.f, 902.f); effect_.OnScrollBegin(start); @@ -397,11 +401,11 @@ // since it's not starting from the bottom, even if the subsequent scroll // overscrolls upward. gfx::SizeF content_size(100, 110); - effect_.OnFrameUpdated(kViewport, kZeroOffset, content_size, + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, content_size, kOverflowYNotHidden); effect_.OnScrollBegin(kStartPos); - effect_.OnFrameUpdated(kViewport, kZeroOffset, content_size, + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, content_size, kOverflowYNotHidden); gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, -10); ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta)); @@ -419,11 +423,11 @@ TEST_F(OverscrollRefreshTest, NotTriggeredIfContentSizeEqualsToViewport) { // bottom overscroll only triggers when content is scrollable. - effect_.OnFrameUpdated(kViewport, gfx::PointF(), kViewport, + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(), kViewport, kOverflowYNotHidden); effect_.OnScrollBegin(kStartPos); - effect_.OnFrameUpdated(kViewport, gfx::PointF(), kViewport, + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(), kViewport, kOverflowYNotHidden); gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, -10); ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta)); @@ -444,8 +448,8 @@ // Set yOffset as reaching the bottom of the page. gfx::PointF nonzero_offset(0, 900); gfx::SizeF content_size(100, 1000); - effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset, + content_size, kOverflowYNotHidden); gfx::PointF start(2.f, 902.f); effect_.OnScrollBegin(start); @@ -469,8 +473,8 @@ // Set yOffset as reaching the bottom of the page. gfx::PointF nonzero_offset(0, 900); gfx::SizeF content_size(100, 1000); - effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset, + content_size, kOverflowYNotHidden); gfx::PointF start(2.f, 902.f); effect_.OnScrollBegin(start); @@ -713,8 +717,8 @@ } TEST_F(OverscrollRefreshTest, MultidimensionalOverscroll) { - effect_.OnFrameUpdated(kViewport, gfx::PointF(0, 100), kContentSize, - kOverflowYNotHidden); + effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(0, 100), + kContentSize, kOverflowYNotHidden); effect_.OnScrollBegin(gfx::PointF(2.f, 50.f)); // First overscroll event: pure horizontal overscroll starts History Nav
Regression Test / PoC
diff --git a/ui/android/overscroll_refresh_unittest.cc b/ui/android/overscroll_refresh_unittest.cc
index d2c659f..e903f28 100644
--- a/ui/android/overscroll_refresh_unittest.cc
+++ b/ui/android/overscroll_refresh_unittest.cc
@@ -17,6 +17,8 @@
const float kDefaultEdgeWidth =
OverscrollRefresh::kDefaultNavigationEdgeWidth * kDipScale;
const gfx::SizeF kViewport(100, 100);
+const float kViewWidth = 100;
+const float kViewportHeight = 100;
const gfx::PointF kZeroOffset(0, 0);
const gfx::SizeF kContentSize(100, 10000);
const bool kOverflowYNotHidden = false;
@@ -29,8 +31,8 @@
OverscrollRefreshTest()
: OverscrollRefreshHandler(nullptr),
effect_(OverscrollRefresh(this, kDefaultEdgeWidth)) {
- effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset,
+ kContentSize, kOverflowYNotHidden);
}
// OverscrollRefreshHandler implementation.
@@ -192,11 +194,11 @@
// A positive y scroll offset at the start of scroll will prevent activation,
// even if the subsequent scroll overscrolls upward.
gfx::PointF nonzero_offset(0, 10);
- effect_.OnFrameUpdated(kViewport, nonzero_offset, kContentSize,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset,
+ kContentSize, kOverflowYNotHidden);
effect_.OnScrollBegin(kStartPos);
- effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize,
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize,
kOverflowYNotHidden);
gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10);
ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta));
@@ -214,7 +216,8 @@
TEST_F(OverscrollRefreshTest, RefreshNotTriggeredIfOverflowYHidden) {
// overflow-y:hidden at the start of scroll will prevent activation.
- effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, true);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize,
+ true);
effect_.OnScrollBegin(kStartPos);
gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10);
@@ -234,7 +237,8 @@
TEST_F(OverscrollRefreshTest,
RefreshNotTriggeredIfOverflowYHiddenNoUpdateBeforeOverscroll) {
// overflow-y:hidden at the start of scroll will prevent activation.
- effect_.OnFrameUpdated(kViewport, kZeroOffset, kContentSize, true);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, kContentSize,
+ true);
effect_.OnScrollBegin(kStartPos);
gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, 10);
@@ -350,8 +354,8 @@
// Set yOffset as reaching the bottom of the page.
gfx::PointF nonzero_offset(0, 900);
gfx::SizeF content_size(100, 1000);
- effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset,
+ content_size, kOverflowYNotHidden);
gfx::PointF start(2.f, 902.f);
effect_.OnScrollBegin(start);
@@ -397,11 +401,11 @@
// since it's not starting from the bottom, even if the subsequent scroll
// overscrolls upward.
gfx::SizeF content_size(100, 110);
- effect_.OnFrameUpdated(kViewport, kZeroOffset, content_size,
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, content_size,
kOverflowYNotHidden);
effect_.OnScrollBegin(kStartPos);
- effect_.OnFrameUpdated(kViewport, kZeroOffset, content_size,
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, kZeroOffset, content_size,
kOverflowYNotHidden);
gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, -10);
ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta));
@@ -419,11 +423,11 @@
TEST_F(OverscrollRefreshTest, NotTriggeredIfContentSizeEqualsToViewport) {
// bottom overscroll only triggers when content is scrollable.
- effect_.OnFrameUpdated(kViewport, gfx::PointF(), kViewport,
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(), kViewport,
kOverflowYNotHidden);
effect_.OnScrollBegin(kStartPos);
- effect_.OnFrameUpdated(kViewport, gfx::PointF(), kViewport,
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(), kViewport,
kOverflowYNotHidden);
gfx::Vector2dF scroll_delta = gfx::Vector2dF(0, -10);
ASSERT_FALSE(effect_.WillHandleScrollUpdate(scroll_delta));
@@ -444,8 +448,8 @@
// Set yOffset as reaching the bottom of the page.
gfx::PointF nonzero_offset(0, 900);
gfx::SizeF content_size(100, 1000);
- effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset,
+ content_size, kOverflowYNotHidden);
gfx::PointF start(2.f, 902.f);
effect_.OnScrollBegin(start);
@@ -469,8 +473,8 @@
// Set yOffset as reaching the bottom of the page.
gfx::PointF nonzero_offset(0, 900);
gfx::SizeF content_size(100, 1000);
- effect_.OnFrameUpdated(kViewport, nonzero_offset, content_size,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, nonzero_offset,
+ content_size, kOverflowYNotHidden);
gfx::PointF start(2.f, 902.f);
effect_.OnScrollBegin(start);
@@ -713,8 +717,8 @@
}
TEST_F(OverscrollRefreshTest, MultidimensionalOverscroll) {
- effect_.OnFrameUpdated(kViewport, gfx::PointF(0, 100), kContentSize,
- kOverflowYNotHidden);
+ effect_.OnFrameUpdated(kViewWidth, kViewportHeight, gfx::PointF(0, 100),
+ kContentSize, kOverflowYNotHidden);
effect_.OnScrollBegin(gfx::PointF(2.f, 50.f));
// First overscroll event: pure horizontal overscroll starts History Nav
Original Bug Report
Potential bypass of overscroll edge-swipe gating on Android via spoofed viewport size
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 compromised renderer can potentially bypass the physical edge-swipe requirement for history navigation gestures on Android by spoofing viewport dimensions in RenderFrameMetadata. By reporting a zero width, the browser’s internal logic for gating navigation transitions becomes trivially satisfied for any touch position. This allows an attacker to instantiate and interact with the BackForwardTransitionAnimator in the browser process using any scroll gesture.
Affected files:
ui/android/overscroll_refresh.cccontent/browser/android/overscroll_controller_android.cccc/mojom/render_frame_metadata_mojom_traits.cccontent/browser/renderer_host/render_widget_host_view_android.cccontent/browser/navigation_transitions/back_forward_transition_animation_manager_android.cc
Estimated timestamp from git blame: 2019-04-18
Description
A logic flaw in Chromium’s Android overscroll implementation allows a compromised renderer to bypass the security gating for history navigation transitions (edge-swipes). The browser process relies on renderer-supplied RenderFrameMetadata to determine the viewport width, which is subsequently used to verify if a horizontal scroll gesture originated near the physical edge of the screen.
Root Cause Analysis
In ui/android/overscroll_refresh.cc, the OnOverscrolled method evaluates if a gesture qualifies as a history navigation (kHistoryNavigation) using the following check:
// ui/android/overscroll_refresh.cc:109
bool scroll_from_edge = scroll_begin_x_ < edge_width_ ||
viewport_width_ - scroll_begin_x_ < edge_width_;
The viewport_width_ is updated via OnFrameUpdated, which receives its values from the renderer through OverscrollControllerAndroid::OnFrameMetadataUpdated. A compromised renderer can spoof the scrollable_viewport_size or page_scale_factor in the RenderFrameMetadata IPC such that the calculated viewport_width_ in the browser becomes zero.
When viewport_width_ is 0, the expression 0 - scroll_begin_x_ < edge_width_ simplifies to -scroll_begin_x_ < edge_width_. Since scroll_begin_x_ is a non-negative touch coordinate provided by the browser’s input system and edge_width_ is a positive constant (defaulting to 24px), this condition is always satisfied regardless of where the user touches the screen.
Potential Impact
By bypassing this gating, a compromised renderer can force the browser to instantiate the BackForwardTransitionAnimator. This is a complex browser-process component (approximately 2400 lines) that manages page snapshots, suppresses user input, and interacts with navigation lifecycle events.
While direct memory corruption has not been proven, this bypass significantly expands the browser-process attack surface available to a compromised renderer. It allows an attacker to trigger privileged UI transitions, suppress input for the tab, and probe for race conditions in the animator’s state machine against RenderFrameHost and navigation events. This feature is typically active on Android devices with >= 7.2GB RAM.
Potential Steps to Trigger
Note: These steps are theoretical as we have not executed code to verify a Proof of Concept.
- From a compromised renderer, ensure the tab has session history (e.g., via
history.pushState). - Send a
cc::mojom::RenderFrameMetadataObserverClient::OnRenderFrameMetadataChangedIPC withscrollable_viewport_sizeset to{0, 0}. - The browser updates
OverscrollRefresh::viewport_width_to 0. - Wait for the user to perform any scroll gesture on the page.
- The renderer sends a
blink::mojom::WidgetInputHandlerHost::DidOverscrollIPC indicating horizontal overscroll. - The browser’s edge check evaluates to true due to the zeroed width, triggering the
BackForwardTransitionAnimator.
Suggested Fix
Validate scrollable_viewport_size and page_scale_factor in the browser process within RenderWidgetHostViewAndroid::OnRenderFrameMetadataChangedBeforeActivation. Ensure that viewport_width_ in OverscrollRefresh is sanitized and maintains a sane minimum value before being used in navigation gating logic. Additionally, consider performing the edge-start validation earlier in the input pipeline where the viewport size is known from the browser’s perspective.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.