Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Text
DescriptionMemory corruption in Text
ComponentText
Sub-componentUI>Input>Text
Bug ClassMemory Corruption
Tracker562238089
Fix commitc3bc7c376dff (chromium/src) +34/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Files Changed

  • content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
  • content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionPopupController.java
  • content/public/android/junit/src/org/chromium/content/browser/input/TextSuggestionPopupControllerTest.java
From c3bc7c376dff5488ba22eb2e7312fadf3891070a Mon Sep 17 00:00:00 2001
From: Ted Choc <[email protected]>
Date: Wed, 23 Sep 2026 09:13:50 -0700
Subject: [PATCH] Clamp vertical positioning of text suggestion popups

Clamps the caret Y-offset within the container view bounds and handles NaN values when displaying spell check and text suggestion popups. Additionally, prevents SuggestionsPopupWindow from shifting above its parent view during vertical clipping.

Bug: 562238089
Change-Id: I61a4495911e0ebb80b271baae26bc5b578aa3c68
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8431019
Reviewed-by: Yaron Friedman <[email protected]>
Commit-Queue: Ted Choc <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1703506}
---
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
index 96e29b1..3f8381f9 100644
--- a/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
+++ b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java
@@ -365,7 +365,7 @@
         positionX = Math.max(-mContentView.getPaddingLeft(), positionX);
 
         // Vertical clipping: if part of the menu or its bottom margin would fall off the bottom of
-        // the screen, shift it up to keep it on-screen.
+        // the screen, shift it up to keep it on-screen, without drawing above mParentView.
         positionY =
                 Math.min(
                         positionY,
@@ -373,6 +373,7 @@
                                 - height
                                 - mContentView.getPaddingTop()
                                 - mPopupVerticalMargin);
+        positionY = Math.max(positionY, positionInWindow[1] - mContentView.getPaddingTop());
 
         mPopupWindow.showAtLocation(mParentView, Gravity.NO_GRAVITY, positionX, positionY);
     }
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionPopupController.java b/content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionPopupController.java
index 0f4c88f..2475c812 100644
--- a/content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionPopupController.java
+++ b/content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionPopupController.java
@@ -8,6 +8,8 @@
 
 import android.content.Context;
 
+import androidx.annotation.VisibleForTesting;
+
 import org.jni_zero.CalledByNative;
 import org.jni_zero.JNINamespace;
 
@@ -130,8 +132,14 @@
         }
     }
 
-    private float getContentOffsetYPix() {
-        return mWebContents.getRenderCoordinates().getContentOffsetYPix();
+    @VisibleForTesting
+    double calculateYOffset(double caretYPx) {
+        float contentOffsetYPix = mWebContents.getRenderCoordinates().getContentOffsetYPix();
+        if (Double.isNaN(caretYPx)) return contentOffsetYPix;
+
+        int containerHeight = assumeNonNull(mViewDelegate.getContainerView()).getHeight();
+        double maxOffset = Math.max((double) contentOffsetYPix, (double) containerHeight);
+        return Math.clamp(caretYPx + contentOffsetYPix, contentOffsetYPix, maxOffset);
     }
 
     // WindowEventObserver
@@ -222,7 +230,7 @@
                         assumeNonNull(mViewDelegate.getContainerView()));
 
         mSpellCheckPopupWindow.show(
-                caretXPx, caretYPx + getContentOffsetYPix(), markedText, suggestions);
+                caretXPx, calculateYOffset(caretYPx), markedText, suggestions);
     }
 
     @CalledByNative
@@ -263,7 +271,7 @@
                         assumeNonNull(mViewDelegate.getContainerView()));
 
         mTextSuggestionsPopupWindow.show(
-                caretXPx, caretYPx + getContentOffsetYPix(), markedText, suggestions);
+                caretXPx, calculateYOffset(caretYPx), markedText, suggestions);
     }
 
     /** Hides the text suggestion menu (and informs Blink that it was closed). */
diff --git a/content/public/android/junit/src/org/chromium/content/browser/input/TextSuggestionPopupControllerTest.java b/content/public/android/junit/src/org/chromium/content/browser/input/TextSuggestionPopupControllerTest.java
index 40e96065..84fa62f4e 100644
--- a/content/public/android/junit/src/org/chromium/content/browser/input/TextSuggestionPopupControllerTest.java
+++ b/content/public/android/junit/src/org/chromium/content/browser/input/TextSuggestionPopupControllerTest.java
@@ -11,6 +11,7 @@
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
+import android.view.ViewGroup;
 
 import androidx.test.core.app.ApplicationProvider;
 
@@ -23,6 +24,7 @@
 import org.mockito.junit.MockitoRule;
 
 import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.content.browser.RenderCoordinatesImpl;
 import org.chromium.content.browser.webcontents.WebContentsImpl;
 import org.chromium.content_public.browser.WebContents.UserDataFactory;
 import org.chromium.ui.base.ViewAndroidDelegate;
@@ -40,15 +42,18 @@
 
     @Mock private WebContentsImpl mWebContents;
     @Mock private WindowAndroid mWindowAndroid;
-    @Mock private ViewAndroidDelegate mViewDelegate;
+    @Mock private RenderCoordinatesImpl mRenderCoordinates;
+    @Mock private ViewGroup mContainerView;
 
     @Before
     public void setUp() {
         mContext = ApplicationProvider.getApplicationContext();
 
+        ViewAndroidDelegate viewDelegate = ViewAndroidDelegate.createBasicDelegate(mContainerView);
         when(mWebContents.getContext()).thenReturn(mContext);
         when(mWebContents.getTopLevelNativeWindow()).thenReturn(mWindowAndroid);
-        when(mWebContents.getViewAndroidDelegate()).thenReturn(mViewDelegate);
+        when(mWebContents.getViewAndroidDelegate()).thenReturn(viewDelegate);
+        when(mWebContents.getRenderCoordinates()).thenReturn(mRenderCoordinates);
 
         when(mWebContents.getOrSetUserData(any(), any()))
                 .thenAnswer(
@@ -61,6 +66,19 @@
     }
 
     @Test
+    public void testCalculateYOffset() {
+        when(mRenderCoordinates.getContentOffsetYPix()).thenReturn(50.0f);
+        when(mContainerView.getHeight()).thenReturn(1000);
+
+        assertEquals(200.0, mController.calculateYOffset(150.0), 0.001);
+        assertEquals(50.0, mController.calculateYOffset(-100.0), 0.001);
+        assertEquals(50.0, mController.calculateYOffset(Double.NEGATIVE_INFINITY), 0.001);
+        assertEquals(50.0, mController.calculateYOffset(Double.NaN), 0.001);
+        assertEquals(1000.0, mController.calculateYOffset(2000.0), 0.001);
+        assertEquals(1000.0, mController.calculateYOffset(Double.POSITIVE_INFINITY), 0.001);
+    }
+
+    @Test
     public void testDestroyClearsNativePtrsAndHosts() {
         long nativePtr1 = 111L;
         long nativePtr2 = 222L;
Loading diff…

Original Bug Report

reported by [email protected]

Omnibox overlay spoof via missing top clamp in SuggestionsPopupWindow on Android

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: On Chrome for Android, a compromised renderer can make the browser process draw the native text/spell-check suggestion popup at an arbitrary window Y coordinate — including directly on top of the toolbar/omnibox — because SuggestionsPopupWindow.show() clamps the popup’s Y position only against the bottom screen edge while framework clipping is disabled, and no layer between Mojo ingress and PopupWindow.showAtLocation() validates the renderer-supplied caret coordinates. The popup rows render attacker-chosen strings with omnibox-like styling (gray prefix + black text), so the overlay can display a fake origin such as “https://accounts.google.com” over the real URL while the tab shows attacker content. Demonstrated end-to-end on a real Android device: the popup fully covers the omnibox and can even extend over the system status bar.

Affected files:

Estimated timestamp from git blame: 2017-07-26

Summary

blink.mojom.TextSuggestionHost.ShowSpellCheckSuggestionMenu / ShowTextSuggestionMenu (third_party/blink/public/mojom/input/input_host.mojom:24-36) accept unconstrained double caret_x, caret_y and are bound for every RenderFrameHost on Android. The browser-side handlers cap only the suggestion count at 5 and pass the coordinates unmodified through JNI to Java, where the only additions are a renderer-known offset and a bottom-edge clamp. With setClippingEnabled(false) on the popup, WindowManager honors negative and near-zero Y coordinates, and the popup window (TYPE_APPLICATION_PANEL) z-orders above the entire Chrome view hierarchy, toolbar included.

The one gate on the path — HasTransientUserActivation() — does not stop this attacker:

  • Any ordinary tap on the attacker page within the preceding 5 s satisfies it (the working exploit sends the IPC from a tap’s pointerup handler).
  • A compromised renderer can also forge it via LocalFrameHost.UpdateUserActivationState, which the browser accepts with no input-event verification (render_frame_host_impl.cc:9832-9850 → frame_tree_node.cc:933-953 → ActivateUserActivation, render_frame_host_impl.cc:7750-7760), renewable every 5 s (third_party/blink/public/common/frame/user_activation_state.h:23).
  • content/SECURITY.md:119-124 states this explicitly: browser-side user activation is not a security boundary because “the renderer ultimately controls the browser-side user activation state.”

Result: a persistent, re-showable, attacker-styled native overlay on the omnibox surface — the class content/SECURITY.md:107-110 (“UI Spoofing via Unvalidated IPCs”) identifies as valid, with omnibox spoofing called out as the severe end.


Root Cause Analysis

  1. Unbounded Mojo coordinates, forwarded verbatim: content/browser/android/text_suggestion_host_android.cc:110-136 (spell-check) and :138-176 (text-suggestion) enforce kMaxNumberOfSuggestions = 5 and the activation gate, then pass caret_x/caret_y unmodified over JNI (:132-135, :172-175). The legitimate Blink caller always sends non-negative viewport-relative caret bounds (third_party/blink/renderer/core/editing/suggestion/text_suggestion_controller.cc:455-463), but the browser never enforces that contract.
  2. Java controller adds a renderer-known offset, no validation: TextSuggestionPopupController.java:224-225 / :265-266 compute caretYPx + getContentOffsetYPix() and show, checking only mIsAttachedToWindow (:210-213, :251-254). The offset is the visible top-controls height taken from renderer-authored compositor frame metadata (RenderCoordinatesImpl.java:188-190, set via GestureListenerManagerImpl.updateScrollInfo), so the attacker knows the exact compensation; precision is not load-bearing anyway, since the popup is 2-4x the toolbar height.
  3. Missing top clamp with framework clipping disabled (the defect):
    // SuggestionsPopupWindow.java:132 (createPopupWindow)
    mPopupWindow.setClippingEnabled(false);   // WindowManager will honor off-screen coords
    
    // SuggestionsPopupWindow.java:343-377 (show)
    int positionY = (int) Math.round(caretYPx);           // renderer-controlled
    ...
    positionY += positionInWindow[1];
    positionY -= mContentView.getPaddingTop();
    positionX = Math.min(menuAtRightEdgeOfWindowPositionX, positionX);  // right edge clamped
    positionX = Math.max(-mContentView.getPaddingLeft(), positionX);    // left edge clamped
    positionY = Math.min(positionY, mDisplayMetrics.heightPixels - height
            - mContentView.getPaddingTop() - mPopupVerticalMargin);     // BOTTOM only — no Math.max
    mPopupWindow.showAtLocation(mParentView, Gravity.NO_GRAVITY, positionX, positionY);
    
    X is clamped on both edges; Y only from below. statusBarHeight (:303-308) is used solely to budget the item count (:317-330), never to clamp position. The in-file comments (“shift it up to keep it on-screen”, :367-368) show the code performs its own screen-edge management precisely because clipping is off — the missing top bound is an oversight, not a delegated framework guarantee.
  4. Framework honors the hostile position: AOSP frameworks/base PopupWindow.java — computeFlags sets FLAG_LAYOUT_NO_LIMITS when clipping is disabled, showAtLocation applies p.x/p.y verbatim with Gravity.NO_GRAVITY ≡ TOP|LEFT, and the window type is TYPE_APPLICATION_PANEL, which draws above the host Activity’s view hierarchy including Chrome’s toolbar. Even an on-screen Y in [0, toolbar_height] overlaps the omnibox regardless of clipping, so the overlay depends only on Chromium’s missing clamp.
  5. Attacker-controlled, omnibox-like content: TextSuggestionsPopupWindow.java:81-107 renders each of up to 5 rows as gray prefix + black suggestion + gray suffix (attacker-supplied strings), mimicking omnibox URL styling; :66 reduces the footer to a single “Delete” row. Each new IPC does hidePopups() + fresh show (TextSuggestionPopupController.java:215-225), so the overlay is re-established immediately after any dismissal, indefinitely.

Reproduction

Verified end-to-end on a production-configuration ChromePublic build on an Android arm64 device, using MojoJS test bindings as a stand-in for a compromised renderer (the standard threat-model premise for this interface; cf. CVE-2023-6348 on the same binder).

  1. From the attacker frame, bind blink.mojom.TextSuggestionHost via the frame’s BrowserInterfaceBroker (bound per-frame on Android, browser_interface_binders.cc:1422-1423).
  2. Satisfy transient activation: send the IPC from an ordinary tap’s pointerup handler (or forge via LocalFrameHost.UpdateUserActivationState(kNotifyActivation, kInteraction)).
  3. Send ShowTextSuggestionMenu(caret_x = screen_width/2, caret_y = -120, marked_text = "x", suggestions = [{prefix:"https://", suggestion:"accounts.google.com", suffix:""}, ...]).
  4. Expected: the popup is confined to the content area (positioned at/below the content-area top, as for any legitimate caret).

Observed (screenshot-documented on device):

  • caret_y=-40: popup top cuts into the omnibox row.
  • caret_y=-120: popup fully covers the omnibox, displaying gray “https://” + black “accounts.google.com” where the real URL was, while the tab renders attacker content.
  • caret_y=-160: popup extends over the system status bar (no top bound at all).
  • caret_y=NaN: also lands at the window top over the omnibox (Java Math.round(NaN) == 0); +Infinity yields (int) Math.round(+inf) == -1 — coordinate sanitization must handle non-finite doubles, not just negatives.
  • Repeatable indefinitely: after any dismissal (outside tap/back), re-sending the IPC restores the overlay; 5 consecutive re-shows demonstrated.

No crash or sanitizer signal is involved; the demonstrated impact is the spoofed browser UI. A minimal clamp patch at the TextSuggestionPopupController trust boundary was built and verified on-device to restore correct behavior for both -120 and NaN.

Severity

medium — The attacker premise (compromised renderer sending arbitrary Mojo IPC) is explicitly in scope, and content/SECURITY.md:107-110 names compromised-renderer UI spoofing of the omnibox as a valid, severe-end vulnerability class. Per docs/security/severity-guidelines.md this is S2: it is an arbitrary-content spoof on the omnibox surface (outside the S3 carve-outs for “only certain URLs” or non-omnibox surfaces), but not S1 “complete control over the apparent origin” — the overlay retains card edges, elevation shadow, and a footer row, and requires a compromised renderer. Consistent with Chromium precedent rating arbitrary-content Android omnibox spoofs Medium (e.g. CVE-2023-0130, CVE-2023-1231, CVE-2023-2463, CVE-2024-6995). Not a duplicate of CVE-2023-6348 (same entry point, unrelated memory defect, since fixed).

Suggested Fix Direction

Clamp the renderer-supplied caret Y at the trust boundary in TextSuggestionPopupController (both paths): reject/zero non-finite doubles, then lower-bound at 0 before adding getContentOffsetYPix() — clamping later to window top would still allow toolbar overlap, since the container view’s window frame includes the browser-controls region. Because the top-controls offset itself comes from renderer-authored frame metadata, a complete fix should additionally bound the final position against browser-owned controls state; legitimate Blink callers are unaffected either way (they always send finite, non-negative viewport coordinates).


POC: Android omnibox overlay spoofing — renderer-supplied negative caret_y draws the native suggestion popup over the URL bar

Strategy: mojo-js Bug Type: UI spoofing (CWE-451) — browser-process native popup drawn over browser chrome from renderer-controlled coordinates

A compromised renderer calls blink.mojom.TextSuggestionHost.ShowTextSuggestionMenu (bound for every frame on Android via BindTextSuggestionHostForFrame, content/browser/browser_interface_binders.cc) with a negative caret_y. The browser process forwards the doubles verbatim (content/browser/android/text_suggestion_host_android.cc), the Java controller adds only the top-controls offset (TextSuggestionPopupController.java), and SuggestionsPopupWindow.show() (content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java) clamps positionY only at the bottom edge (Math.min, lines ~369–375) while the popup has framework clipping disabled (setClippingEnabled(false), line ~132). Result: the native, elevation-shadowed suggestion popup — with attacker-chosen, omnibox-styled rows (gray prefix + black text) — is drawn on top of the Chrome toolbar/omnibox (and even the system status bar), while the tab displays attacker content.

This POC uses the MojoJS bindings (--enable-blink-features=MojoJS,MojoJSTest) to send the exact Mojo message a compromised renderer would send. The only browser-side gate is transient user activation, satisfied here by an ordinary tap on the attacker page (the POC sends the IPC from the tap’s pointerup handler); a fully compromised renderer can also forge activation via LocalFrameHost.UpdateUserActivationState, which the browser accepts without input-event verification.

Extra finding: non-finite coordinates

Besides negative values, caret_y = NaN also lands the popup over the omnibox (Java Math.round(NaN) == 0 → window top; see screenshot 5), and +Infinity would too ((int) Math.round(+inf) == -1). Any fix must sanitize non-finite doubles, not just clamp negatives — the included bandaid does.

Prerequisites

  • ChromePublic.apk built from this tree (autoninja -C out/android-arm64 chrome_public_apk).
  • An arm64 Android device (tested on an Android 17 Cuttlefish VM, 720x1348 @ 280dpi).
  • The two generated MojoJS lite bindings in bindings/ (copied unmodified from out/<dir>/gen/mojo/public/js/mojo_bindings_lite.js and out/<dir>/gen/third_party/blink/public/mojom/input/input_host.mojom-lite.js of the same tree).

Reproduction

export ANDROID_SERIAL=<device>
adb install -r -t out/android-arm64/apks/ChromePublic.apk
adb shell 'echo "_ --enable-blink-features=MojoJS,MojoJSTest --disable-fre" > /data/local/tmp/chrome-command-line'

# Serve poc.html + bindings/ on port 8000. Standard setup:
python3 server.py &
adb reverse tcp:8000 tcp:8000
# (In this lab the device could not reach a host-side server — adb reverse
# terminates on a remote adb-server host — so the files were served from the
# device itself instead; identical origin/URL, any HTTP origin works:)
#   adb shell mkdir -p /data/local/tmp/poc/bindings
#   adb push poc.html device-server.sh /data/local/tmp/poc/
#   adb push bindings/*.js /data/local/tmp/poc/bindings/
#   adb shell "nohup nc -s 127.0.0.1 -p 8000 -L /system/bin/sh /data/local/tmp/poc/device-server.sh </dev/null >/dev/null 2>&1 &"

adb shell am start -a android.intent.action.VIEW -d "http://localhost:8000/poc.html" \
    -n org.chromium.chrome/com.google.android.apps.chrome.Main
sleep 8
# Each tap on the page sends one ShowTextSuggestionMenu with the next caret_y
# from the cycle (-40, -80, -120, -160, ...); a tap while the popup is showing
# dismisses it (the next tap re-triggers — indefinitely repeatable).
adb shell input tap 360 800; sleep 2
adb exec-out screencap -p > result.png

Use poc.html?y=<value> to pin a single caret_y (e.g. ?y=-120, ?y=NaN).

Expected Result

  • The page status line reports e.g. tap #1: sent ShowTextSuggestionMenu(caret_x=360, caret_y=-120).
  • The native text-suggestion popup appears over the Chrome toolbar/omnibox, showing the attacker rows https://accounts.google.com (gray/black omnibox-style spans) and 🔒 Connection is secure, hiding the real URL, while the tab renders the attacker page. At caret_y=-160 the popup also covers part of the system status bar. There is no crash — the impact is the spoofed browser UI.

Output

Observation oracle (UI spoof — no sanitizer involved). Captured screenshots:

screenshot-1-before.png                      page loaded; real URL "localhost:8000/poc.html"
                                             visible in omnibox; status: "ready … [Mojo OK]"
screenshot-2-caret_y-40-partial.png          popup top cuts into the omnibox row
screenshot-3-caret_y-120-omnibox-covered.png popup FULLY covers the omnibox URL; shows
                                             "https://accounts.google.com" where the URL was
screenshot-4-caret_y-160-over-statusbar.png  popup extends over the system status bar
                                             (no top bound at all)
screenshot-5-caret_y-NaN-omnibox-covered.png caret_y=NaN also lands at window top,
                                             covering the omnibox

On-page status text visible inside each screenshot documents the exact IPC parameters sent for that frame.

Bandaid Fix

bandaid-fix.patch clamps the renderer-supplied, viewport-relative caret Y at the trust boundary in TextSuggestionPopupController (both the spell-check and text-suggestion paths), before the top-controls offset is added:

private static double clampCaretYToViewport(double caretYPx) {
    if (!Double.isFinite(caretYPx)) return 0.0;
    return Math.max(caretYPx, 0.0);
}

Clamping to >= 0 before adding getContentOffsetYPix() bounds the popup to the visible content-area top (clamping later, e.g. to window top in SuggestionsPopupWindow, would still allow toolbar overlap because the container view’s window frame includes the browser-controls region). Legitimate callers are unaffected: Blink sends FrameToViewport(AbsoluteCaretBounds()), which is always finite and non-negative.

Verified on-device (autoninja -C out/android-arm64 chrome_public_apk, reinstall, same POC):

screenshot-6-fixed-caret_y-120-clamped.png   caret_y=-120: omnibox fully visible with the
                                             real URL; popup clamped to content-area top
screenshot-7-fixed-caret_y-NaN-clamped.png   caret_y=NaN: same clamped placement

Residual (out of scope for this bandaid): the top-controls offset itself comes from renderer-authored compositor frame metadata, so a renderer claiming offset 0 while controls are shown could still shift placement by the toolbar height; a complete fix should bound the final positionY against browser-owned controls state.

Files

  • poc.html — the attacker page (MojoJS caller); ?y=/?x= pin coordinates
  • server.py — standard host-side HTTP server
  • device-server.sh — on-device toybox-nc HTTP handler (lab fallback, see above)
  • bindings/ — unmodified generated MojoJS lite bindings (reproduction dependency)
  • bandaid-fix.patch — minimal fix, verified above
  • screenshot-*.png — proof (before / vulnerable / fixed)

Attached file hierarchy:

The following directories were attached as zip archives:

  • bindings.zip → bindings/

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