CVE-2026-113239
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/android/ime_adapter_android.cc |
modified | |
ThreadedInputConnectioncontent/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java |
modified | |
ifcontent/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java |
modified |
Files Changed
content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.javacontent/browser/android/ime_adapter_android.hcontent/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.javacontent/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.javacontent/browser/android/ime_adapter_android.cccontent/public/android/junit/src/org/chromium/content/browser/input/ImeAdapterImplTest.java
Patch
From 2a427bee0447b4d2758c20dabe2c3faaab44f3ce Mon Sep 17 00:00:00 2001
From: David Awogbemila <awogbemila@chromium.org>
Date: Tue, 22 Sep 2026 14:43:33 -0700
Subject: [PATCH] Reland "Route IME rich media insertion to the originating RenderFrameHost"
This relands commit 83f55f99ff39 with a fix for crbug.com/562881163.
The original commit called mImeAdapter.getFocusedFrame() on the
background IME thread in ThreadedInputConnection.commitContent(), which
caused cross-thread access to WebContents and RenderFrameHost off the
Browser UI thread.
This fix maintains thread-safety by:
1. Storing a volatile GlobalRenderFrameHostId on
ThreadedInputConnection,
queried strictly on the UI thread during connection creation,
resetOnUiThread(), and updateStateOnUiThread().
2. Capturing the target GlobalRenderFrameHostId on the IME thread
without any JNI calls or C++ traversal.
3. Passing the target frame process ID and routing ID to the UI thread
and down to ImeAdapterAndroid::InsertMediaFromBytes via JNI.
4. Resolving the RenderFrameHost safely on the UI thread via
RenderFrameHost::FromID() and verifying that it remains the
currently focused frame before dispatching media insertion.
Bug: 500522368, 562881163
Change-Id: Ia083b24f4baf598fba28fea7369ccbd98c9835bc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8436538
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: David A <awogbemila@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1702973}
---
diff --git a/content/browser/android/ime_adapter_android.cc b/content/browser/android/ime_adapter_android.cc
index ba3d698..58908eb3 100644
--- a/content/browser/android/ime_adapter_android.cc
+++ b/content/browser/android/ime_adapter_android.cc
@@ -27,6 +27,7 @@
#include "content/browser/renderer_host/render_widget_host_view_android.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/browser/browser_thread.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/common/content_features.h"
#include "mojo/public/cpp/base/big_buffer.h"
#include "third_party/blink/public/common/input/web_input_event.h"
@@ -412,8 +413,16 @@
bool ImeAdapterAndroid::InsertMediaFromBytes(
JNIEnv* env,
+ int render_process_id,
+ int render_frame_id,
const base::android::JavaRef<jbyteArray>& bytes,
const base::android::JavaRef<jstring>& extension) {
+ RenderFrameHost* target_rfh =
+ RenderFrameHost::FromID(render_process_id, render_frame_id);
+ if (!target_rfh || GetFocusedFrame() != target_rfh) {
+ return false;
+ }
+
auto* input_handler = GetFocusedFrameWidgetInputHandler();
if (!input_handler) {
return false;
diff --git a/content/browser/android/ime_adapter_android.h b/content/browser/android/ime_adapter_android.h
index 11d5e33ea..d0ba8baf 100644
--- a/content/browser/android/ime_adapter_android.h
+++ b/content/browser/android/ime_adapter_android.h
@@ -119,6 +119,8 @@
const gfx::Rect& caret_bounds);
bool InsertMediaFromBytes(JNIEnv* env,
+ int render_process_id,
+ int render_frame_id,
const base::android::JavaRef<jbyteArray>& bytes,
const base::android::JavaRef<jstring>& extension);
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.java b/content/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.java
index 072d213..2dbfc4e7 100644
--- a/content/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.java
+++ b/content/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.java
@@ -78,9 +78,11 @@
import org.chromium.content.common.ContentInternalFeatures;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.ContentFeatureMap;
+import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.browser.ImeAdapter;
import org.chromium.content_public.browser.ImeEventObserver;
import org.chromium.content_public.browser.InputMethodManagerWrapper;
+import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.StylusWritingImeCallback;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContents.UserDataFactory;
@@ -701,6 +703,13 @@
return mSupportedMimeTypes;
}
+ /** Retrieves the focused frame's GlobalRenderFrameHostId, or null if none is focused. */
+ public @Nullable GlobalRenderFrameHostId getFocusedFrameId() {
+ ImeUtils.checkOnUiThread();
+ RenderFrameHost rfh = mWebContents != null ? mWebContents.getFocusedFrame() : null;
+ return rfh != null ? rfh.getGlobalRenderFrameHostId() : null;
+ }
+
/**
* Updates internal representation of the text being edited and its selection and composition
* properties.
@@ -1753,18 +1762,26 @@
}
/**
- * Sends rich content into the current focused text field
+ * Sends rich content into the target text field, if currently focused.
*
- * @param bytes binary data of therich content to be inserted
+ * @param targetFrameId the GlobalRenderFrameHostId targeted for media insertion
+ * @param bytes binary data of the rich content to be inserted
* @param extension the file extension of the rich content to be inserted
* @return whether the insertion is successful.
*/
- boolean commitContent(byte[] bytes, String extension) {
+ boolean commitContent(
+ @Nullable GlobalRenderFrameHostId targetFrameId, byte[] bytes, String extension) {
onImeEvent();
boolean result =
isValid()
+ && targetFrameId != null
&& ImeAdapterImplJni.get()
- .insertMediaFromBytes(mNativeImeAdapterAndroid, bytes, extension);
+ .insertMediaFromBytes(
+ mNativeImeAdapterAndroid,
+ targetFrameId.childId(),
+ targetFrameId.frameRoutingId(),
+ bytes,
+ extension);
ImeMetricsUtils.recordCommitContentSuccess(extension, result);
return result;
}
@@ -2266,7 +2283,12 @@
String textStr,
int newCursorPosition);
- boolean insertMediaFromBytes(long nativeImeAdapterAndroid, byte[] bytes, String extension);
+ boolean insertMediaFromBytes(
+ long nativeImeAdapterAndroid,
+ int renderProcessId,
+ int renderFrameId,
+ byte[] bytes,
+ String extension);
void finishComposingText(long nativeImeAdapterAndroid);
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java b/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java
index 7bdd3df..61fbae6e 100644
--- a/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java
+++ b/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java
@@ -43,6 +43,7 @@
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import org.chromium.content_public.browser.ContentFeatureMap;
+import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.net.MimeTypeFilter;
@@ -103,6 +104,9 @@
private @Nullable TextInputState mCachedTextInputState;
private int mCurrentExtractedTextRequestToken;
private boolean mShouldUpdateExtractedText;
+ // Cached focused frame ID for rich media insertion (commitContent). Written on the UI thread
+ // during connection creation, reset, and state updates, and read on the background IME thread.
+ private volatile @Nullable GlobalRenderFrameHostId mTargetFrameId;
ThreadedInputConnection(View view, ImeAdapterImpl imeAdapter, Handler handler) {
super(view, true);
@@ -110,10 +114,12 @@
ImeUtils.checkOnUiThread();
mImeAdapter = imeAdapter;
mHandler = handler;
+ mTargetFrameId = mImeAdapter.getFocusedFrameId();
}
void resetOnUiThread() {
ImeUtils.checkOnUiThread();
+ mTargetFrameId = mImeAdapter.getFocusedFrameId();
mHandler.post(
() -> {
@@ -134,6 +140,7 @@
boolean singleLine,
final boolean replyToRequest) {
ImeUtils.checkOnUiThread();
+ mTargetFrameId = mImeAdapter.getFocusedFrameId();
mCachedTextInputState =
new TextInputState(
@@ -868,6 +875,11 @@
return false;
}
+ final GlobalRenderFrameHostId targetFrameId = mTargetFrameId;
+ if (targetFrameId == null) {
+ return false;
+ }
+
final String mimeType = inputContentInfo.getDescription().getMimeType(0);
if (!new MimeTypeFilter(
@@ -898,7 +910,7 @@
PostTask.postTask(
TaskTraits.UI_DEFAULT,
() -> {
- mImeAdapter.commitContent(bytes, extension);
+ mImeAdapter.commitContent(targetFrameId, bytes, extension);
});
} catch (Exception e) {
Log.e(TAG, "Failed to commit rich content.", e);
diff --git a/content/public/android/junit/src/org/chromium/content/browser/input/ImeAdapterImplTest.java b/content/public/android/junit/src/org/chromium/content/browser/input/ImeAdapterImplTest.java
index b7ed5de..158e6fd7 100644
--- a/content/public/android/junit/src/org/chromium/content/browser/input/ImeAdapterImplTest.java
+++ b/content/public/android/junit/src/org/chromium/content/browser/input/ImeAdapterImplTest.java
@@ -57,8 +57,10 @@
import org.chromium.content.browser.RenderCoordinatesImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl;
import org.chromium.content_public.browser.ContentFeatureList;
+import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.browser.ImeEventObserver;
import org.chromium.content_public.browser.InputMethodManagerWrapper;
+import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.ui.accessibility.AccessibilityFeatures;
@@ -89,6 +91,7 @@
@Mock private AutocorrectManager mAutocorrectManager;
@Mock private InputMethodManagerWrapper mInputMethodManagerWrapper;
@Mock private EventForwarder mEventForwarder;
+ @Mock private RenderFrameHost mRenderFrameHost;
@Mock private RenderCoordinatesImpl mRenderCoordinatesImpl;
@Mock private WindowInsetsController mWindowInsetsController;
@@ -493,8 +496,22 @@
}
@Test
+ public void testGetFocusedFrameId() {
+ ImeAdapterImpl adapter = new ImeAdapterImpl(mWebContentsImpl);
+ Assert.assertNull(adapter.getFocusedFrameId());
+
+ GlobalRenderFrameHostId frameId = new GlobalRenderFrameHostId(1, 2);
+ when(mRenderFrameHost.getGlobalRenderFrameHostId()).thenReturn(frameId);
+ when(mWebContentsImpl.getFocusedFrame()).thenReturn(mRenderFrameHost);
+
+ Assert.assertEquals(frameId, adapter.getFocusedFrameId());
+ }
+
+ @Test
public void testCommitContent() {
- when(mImeAdapterImplJni.insertMediaFromBytes(anyLong(), any(), any())).thenReturn(true);
+ GlobalRenderFrameHostId frameId = new GlobalRenderFrameHostId(1, 2);
+ when(mImeAdapterImplJni.insertMediaFromBytes(anyLong(), eq(1), eq(2), any(), any()))
+ .thenReturn(true);
HistogramWatcher watcher =
HistogramWatcher.newBuilder()
.expectIntRecord(
@@ -505,16 +522,39 @@
adapter.onConnectedToRenderProcess();
Assert.assertTrue(
- adapter.commitContent(/* bytes= */ new byte[] {1, 2, 3}, /* extension= */ "png"));
+ adapter.commitContent(
+ frameId, /* bytes= */ new byte[] {1, 2, 3}, /* extension= */ "png"));
verify(mImeAdapterImplJni)
- .insertMediaFromBytes(anyLong(), eq(new byte[] {1, 2, 3}), eq("png"));
+ .insertMediaFromBytes(anyLong(), eq(1), eq(2), eq(new byte[] {1, 2, 3}), eq("png"));
+ watcher.assertExpected();
+ }
+
+ @Test
+ public void testCommitContent_NullTargetFrame() {
+ HistogramWatcher watcher =
+ HistogramWatcher.newBuilder()
+ .expectIntRecord(
+ "Input.CommitContent.Failure", ImeMetricsUtils.ExtensionFormat.PNG)
+ .build();
+
+ ImeAdapterImpl adapter = new ImeAdapterImpl(mWebContentsImpl);
+ adapter.onConnectedToRenderProcess();
+
+ Assert.assertFalse(
+ adapter.commitContent(
+ null, /* bytes= */ new byte[] {1, 2, 3}, /* extension= */ "png"));
+
+ verify(mImeAdapterImplJni, never())
+ .insertMediaFromBytes(anyLong(), anyInt(), anyInt(), any(), any());
watcher.assertExpected();
}
@Test
public void testCommitContent_Failure() {
- when(mImeAdapterImplJni.insertMediaFromBytes(anyLong(), any(), any())).thenReturn(false);
+ GlobalRenderFrameHostId frameId = new GlobalRenderFrameHostId(1, 2);
+ when(mImeAdapterImplJni.insertMediaFromBytes(anyLong(), eq(1), eq(2), any(), any()))
+ .thenReturn(false);
HistogramWatcher watcher =
HistogramWatcher.newBuilder()
.expectIntRecord(
@@ -525,7 +565,7 @@
ImeAdapterImpl adapter = new ImeAdapterImpl(mWebContentsImpl);
adapter.onConnectedToRenderProcess();
- Assert.assertFalse(adapter.commitContent(new byte[] {1, 2, 3}, "unknown_ext"));
+ Assert.assertFalse(adapter.commitContent(frameId, new byte[] {1, 2, 3}, "unknown_ext"));
watcher.assertExpected();
}
diff --git a/content/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.java b/content/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.java
index 8b47602e..d59bf9c 100644
--- a/content/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.java
+++ b/content/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.java
@@ -24,6 +24,7 @@
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputContentInfo;
import android.view.inputmethod.InputMethodManager;
import org.junit.Before;
@@ -42,8 +43,10 @@
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.RobolectricUtil;
import org.chromium.base.test.util.Feature;
+import org.chromium.base.test.util.Features.DisableFeatures;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.content_public.browser.ContentFeatureList;
+import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.common.ContentFeatures;
import java.util.concurrent.Callable;
@@ -55,6 +58,7 @@
@Mock ImeAdapterImpl mImeAdapter;
@Mock CorrectionInfo mCorrectionInfo;
@Mock private Context mContext;
+ @Mock private InputContentInfo mInputContentInfo;
@Mock private View mView;
@Mock private InputMethodManager mInputMethodManager;
@@ -396,4 +400,35 @@
mInOrder.verify(mImeAdapter).commitCorrection(mCorrectionInfo);
}
+
+ @Test
+ @Feature({"TextInput"})
+ @DisableFeatures(ContentFeatures.ANDROID_MEDIA_INSERTION)
+ public void testCommitContent_FeatureDisabled() {
+ assertFalse(mConnection.commitContent(mInputContentInfo, 0, null));
+ }
+
+ @Test
+ @Feature({"TextInput"})
+ @EnableFeatures(ContentFeatures.ANDROID_MEDIA_INSERTION)
+ public void testCommitContent_NullTargetFrame() {
+ when(mImeAdapter.getFocusedFrameId()).thenReturn(null);
+ mConnection.resetOnUiThread();
+ assertFalse(mConnection.commitContent(mInputContentInfo, 0, null));
+ }
+
+ @Test
+ @Feature({"TextInput"})
+ @EnableFeatures(ContentFeatures.ANDROID_MEDIA_INSERTION)
+ public void testCommitContent_TargetFrameClearedOnReset() {
+ GlobalRenderFrameHostId frameId = new GlobalRenderFrameHostId(1, 2);
+ when(mImeAdapter.getFocusedFrameId()).thenReturn(frameId);
+ mConnection.resetOnUiThread();
+
+ // Now clear the focused frame and reset again.
+ when(mImeAdapter.getFocusedFrameId()).thenReturn(null);
+ mConnection.resetOnUiThread();
+
+ assertFalse(mConnection.commitContent(mInputContentInfo, 0, null));
+ }
}
Original Bug Report
Cross-Origin Image Leak via TOCTOU in Android IME commitContent
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 without the security team.
Overview: A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in Android’s commitContent implementation. By shifting focus during the background file-read process, a malicious main frame can route image data intended for a cross-origin iframe to itself. This bypasses security checks and leaks user-selected media to the attacker.
Affected files:
content/browser/android/ime_adapter_android.cccontent/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.javacontent/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.javathird_party/blink/renderer/core/frame/web_frame_widget_impl.cc
Estimated timestamp from git blame: 2026-03-03
Description
A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the way Chrome for Android handles rich media insertion from the IME (e.g., Gboard).
When a user selects an image to insert into a richly editable element, Chrome validates the target frame’s supported MIME types and then initiates an asynchronous background task to read the file via Android’s ContentResolver (ThreadedInputConnection.commitContent). Because this I/O operation can be slow (tens to hundreds of milliseconds), a race window is created.
During this window, a malicious top-level main frame can programmatically steal focus from a cross-origin victim iframe. (Main frames are exempt from user activation restrictions when calling .focus() on their own elements). When the background read finishes, the image bytes are routed to the currently focused frame (ImeAdapterAndroid::InsertMediaFromBytes), delivering the sensitive data to the attacker instead of the victim.
Finally, because WebFrameWidgetImpl::PasteFromImageBytes hardcodes is_accessible_from_start_frame = true when creating the DataObject, cross-origin clipboard restrictions are bypassed, allowing the attacker’s script to read the user’s private image.
Note: These are potential steps and analysis, as our tooling agent does not yet have the ability to run code to produce a live proof of concept.
Potential Steps to Trigger
- Host a malicious top-level page (
attacker.com) that embeds a cross-origin victim page (victim.com) containing acontenteditableelement. - In the attacker page, run a continuous background loop (e.g., via
setInterval) that calls.focus()on a hidden local input element. - The user interacts with the page, tapping the victim’s editable element. This focuses the victim frame and opens the Android keyboard.
- The user selects a large media file (like an animated GIF) from the keyboard to insert into the victim’s chat/editor.
- Chrome verifies the victim frame supports images and starts the background file read.
- During the read, the attacker’s
.focus()loop successfully steals focus back to the main frame. - The background read finishes and routes the image data to the attacker’s frame.
- The attacker intercepts the resulting
pasteevent, callsevent.preventDefault(), and reads the image data viaevent.clipboardData.items[0].getAsFile()to exfiltrate it.
Suggested Fix
- Bind to the Original Target: When
commitContentis initiated, capture the intended destination frame (e.g., itsFrameTreeNodeID or aWeakPtrto the specificRenderWidgetHostImpl). When the background read completes, verify that the current focused frame matches the original target before dispatching thePasteFromImageBytesIPC. - Cancel In-Flight Tasks: Implement the
closeConnection()method inThreadedInputConnection.java(currently an empty stub with a TODO) to properly cancel or invalidate pending background file reads when the IME connection is torn down due to a focus shift.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.