CVE-2026-79044
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.javachrome/android/junit/BUILD.gnchrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.javacontent/browser/renderer_host/render_frame_host_android.cccontent/browser/renderer_host/render_frame_host_android.hcontent/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.javacontent/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java
Patch
From 012d1f609b9cd6e55e83b139474c8c3ed9999091 Mon Sep 17 00:00:00 2001 From: Nate Chapin <[email protected]> Date: Fri, 17 Jul 2026 08:51:14 -0700 Subject: [PATCH] [Android] Only bind InstalledAppProvider for primary main frame InstalledAppProviderFactory now returns null and terminates the renderer when the requesting RenderFrameHost is not the primary main frame, matching the behaviour of the C++ InstalledAppProviderImpl::Create binder used on other platforms. Exposes RenderFrameHost::IsInPrimaryMainFrame to JNI to support this. Adds a Robolectric unit test for the factory. TAG=agy CONV=4b21b69b-2602-49f6-934c-1f7b9d1fcd9c Fixed: 497095313 Change-Id: Id68557a96d18ab377cd2c0dbc73ea96dd9065132 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8109878 Commit-Queue: Nate Chapin <[email protected]> Reviewed-by: Bo Liu <[email protected]> Reviewed-by: Peter Conn <[email protected]> Cr-Commit-Position: refs/heads/main@{#1663928} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java b/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java index e4c8370..2043f59c 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java @@ -19,12 +19,20 @@ implements InterfaceFactory<@Nullable InstalledAppProvider> { private final RenderFrameHost mRenderFrameHost; + // BadMessageReason::RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME in + // content/browser/bad_message.h + private static final int RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME = 227; + public InstalledAppProviderFactory(RenderFrameHost renderFrameHost) { mRenderFrameHost = renderFrameHost; } @Override - public InstalledAppProvider createImpl() { + public @Nullable InstalledAppProvider createImpl() { + if (!mRenderFrameHost.isInPrimaryMainFrame()) { + mRenderFrameHost.terminateRendererDueToBadMessage(RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME); + return null; + } Profile profile = Profile.fromWebContents(WebContentsStatics.fromRenderFrameHost(mRenderFrameHost)); assert profile != null; diff --git a/chrome/android/junit/BUILD.gn b/chrome/android/junit/BUILD.gn index 9304ae7..3560811 100644 --- a/chrome/android/junit/BUILD.gn +++ b/chrome/android/junit/BUILD.gn @@ -1594,6 +1594,7 @@ "src/org/chromium/chrome/browser/incognito/IncognitoWindowNightModeStateProviderUnitTest.java", "src/org/chromium/chrome/browser/init/ActivityProfileProviderTest.java", "src/org/chromium/chrome/browser/init/AsyncInitTaskRunnerTest.java", + "src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java", "src/org/chromium/chrome/browser/invalidation/ResumableDelayedTaskRunnerTest.java", "src/org/chromium/chrome/browser/invalidation/SessionsInvalidationManagerTest.java", "src/org/chromium/chrome/browser/messages/ChromeMessageAutodismissDurationProviderTest.java", diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java new file mode 100644 index 0000000..3132dfc --- /dev/null +++ b/chrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.java @@ -0,0 +1,61 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.chromium.chrome.browser.installedapp; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.annotation.Config; + +import org.chromium.base.test.BaseRobolectricTestRunner; +import org.chromium.chrome.browser.profiles.Profile; +import org.chromium.content_public.browser.RenderFrameHost; +import org.chromium.content_public.browser.WebContents; +import org.chromium.content_public.browser.WebContentsStatics; +import org.chromium.installedapp.mojom.InstalledAppProvider; + +/** Unit tests for {@link InstalledAppProviderFactory}. */ +@RunWith(BaseRobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class InstalledAppProviderFactoryTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + @Mock private RenderFrameHost mRenderFrameHost; + @Mock private WebContents mWebContents; + @Mock private Profile mProfile; + private static final int RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME = 227; + + @Before + public void setUp() { + WebContentsStatics.setWebContentsForTesting(mWebContents); + Profile.setProfileFromWebContentsForTesting(mProfile); + } + + @Test + public void testCreateImpl_primaryMainFrame() { + doReturn(true).when(mRenderFrameHost).isInPrimaryMainFrame(); + InstalledAppProviderFactory factory = new InstalledAppProviderFactory(mRenderFrameHost); + InstalledAppProvider provider = factory.createImpl(); + assertNotNull(provider); + } + + @Test + public void testCreateImpl_notPrimaryMainFrame() { + doReturn(false).when(mRenderFrameHost).isInPrimaryMainFrame(); + InstalledAppProviderFactory factory = new InstalledAppProviderFactory(mRenderFrameHost); + InstalledAppProvider provider = factory.createImpl(); + assertNull(provider); + verify(mRenderFrameHost).terminateRendererDueToBadMessage(RFH_INVALID_CALL_FROM_NOT_MAIN_FRAME); + } +} diff --git a/content/browser/renderer_host/render_frame_host_android.cc b/content/browser/renderer_host/render_frame_host_android.cc index 43d91b8..d6625f5 100644 --- a/content/browser/renderer_host/render_frame_host_android.cc +++ b/content/browser/renderer_host/render_frame_host_android.cc @@ -125,6 +125,10 @@ return render_frame_host_->GetMainFrame()->GetJavaRenderFrameHost(); } +bool RenderFrameHostAndroid::IsInPrimaryMainFrame(JNIEnv* env) const { + return render_frame_host_->IsInPrimaryMainFrame(); +} + void RenderFrameHostAndroid::GetCanonicalUrlForSharing( base::OnceCallback<void(const std::optional<GURL>&)> callback) const { render_frame_host_->GetCanonicalUrl(std::move(callback)); diff --git a/content/browser/renderer_host/render_frame_host_android.h b/content/browser/renderer_host/render_frame_host_android.h index 93e7fbc..7fc816a1 100644 --- a/content/browser/renderer_host/render_frame_host_android.h +++ b/content/browser/renderer_host/render_frame_host_android.h @@ -46,6 +46,8 @@ base::android::ScopedJavaLocalRef<jobject> GetMainFrame(JNIEnv* env); + bool IsInPrimaryMainFrame(JNIEnv* env) const; + void GetCanonicalUrlForSharing( base::OnceCallback<void(const std::optional<GURL>&)> callback) const; diff --git a/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java b/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java index ac7b19a0..bfc6170 100644 --- a/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java +++ b/content/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java @@ -113,6 +113,12 @@ } @Override + public boolean isInPrimaryMainFrame() { + if (mNativeRenderFrameHostAndroid == 0) return false; + return RenderFrameHostImplJni.get().isInPrimaryMainFrame(mNativeRenderFrameHostAndroid); + } + + @Override public void getCanonicalUrlForSharing(Callback<@Nullable GURL> callback) { if (mNativeRenderFrameHostAndroid == 0) { callback.onResult(null); @@ -337,6 +343,8 @@ RenderFrameHost getMainFrame(long nativeRenderFrameHostAndroid); + boolean isInPrimaryMainFrame(long nativeRenderFrameHostAndroid); + void getCanonicalUrlForSharing( long nativeRenderFrameHostAndroid, @JniType("base::OnceCallback<void(const std::optional<GURL>&)>") diff --git a/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java b/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java index 3ae9407..0b0cadcc 100644 --- a/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java +++ b/content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.java @@ -72,6 +72,11 @@ @Nullable RenderFrameHost getMainFrame(); /** + * @return Whether the frame is the primary main frame. + */ + boolean isInPrimaryMainFrame(); + + /** * Fetch the canonical URL associated with the fame. *
Regression Test / PoC
diff --git a/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java b/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
index 061c1a0..d9f7d13 100644
--- a/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
+++ b/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.java
@@ -38,6 +38,11 @@
}
@Override
+ public boolean isInPrimaryMainFrame() {
+ return false;
+ }
+
+ @Override
public void getCanonicalUrlForSharing(Callback<GURL> callback) {}
@Override
Original Bug Report
Potential Sandbox Escape: Missing OutermostMainFrame Check in Android InstalledAppProvider
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: The InstalledAppProvider Mojo interface lacks an outermost main frame check on Android. A compromised subframe renderer can bind this interface, bypassing renderer-side checks, to silently query the user’s installed WebAPKs or trigger limited SSRF requests from the browser process.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.javachrome/browser/chrome_browser_interface_binders.cccomponents/installedapp/android/java/src/org/chromium/components/installedapp/InstalledAppProviderImpl.java
Estimated timestamp from git blame: 2025-07-02
Overview
The InstalledAppProvider interface allows websites to check if their related applications are installed on the user’s device. For security and privacy reasons, this API is restricted to the outermost main frame. This restriction is enforced in the renderer (navigator_installed_app.cc).
On non-Android platforms, InstalledAppProviderImpl::Create performs this verification using host.GetParentOrOuterDocument(). If the frame is not an outermost main frame, the browser kills the renderer with a ReportBadMessage call.
However, on Android, the InstalledAppProvider interface is bound through a different path. In chrome_browser_interface_binders.cc, the binder uses ForwardToJavaFrame, which lacks any frame-type validation. The request is then forwarded to InstalledAppProviderFactory.java, which also does not verify if the frame is the outermost main frame before creating the implementation.
A compromised renderer in a cross-origin subframe can bypass the renderer-side check and bind the InstalledAppProvider interface directly. Once bound, the Android implementation (InstalledAppProviderImpl.java) provides several oracles that can be exploited:
-
Cross-Origin WebAPK Enumeration: The implementation trusts the
manifestUrlprovided by the renderer in theFilterInstalledAppsMojo call. If the renderer provides amanifestUrlthat matches theurlof aRelatedApplication, the implementation assumes the site is checking for its own WebAPK and bypasses the Digital Asset Links (DAL) relationship check. A compromised renderer can set both to an arbitrary target URL (e.g.,https://bank.example/manifest.json) and determine if the user has that specific WebAPK installed. The underlying implementation inWebApkValidator.javascans all installed packages on the device to find a match without consulting the actual frame origin. -
Browser-Process Network Fetch (Limited SSRF): If the renderer provides mismatched URLs, the implementation triggers a browser-process fetch of
.well-known/assetlinks.jsonfrom the origin of theapp.url. This allows a compromised renderer to initiate network requests from the browser process to arbitrary origins, although the path is fixed and credentials are omitted. -
Subframe Play App Oracle: The implementation allows subframes to query for their own associated Play Store apps, which is contrary to the API’s top-level-only restriction.
Potential Attacker Steps
Note: These steps are based on static code analysis. A working proof of concept has not yet been developed.
- An attacker gains arbitrary code execution in a cross-origin subframe renderer (e.g., via a v8 exploit).
- Using MojoJS or direct Mojo bindings, the compromised subframe requests to bind the
blink::mojom::InstalledAppProviderinterface. - The browser process blindly binds the interface via
ForwardToJavaFrameon Android. - For WebAPK Enumeration: The attacker sends a
FilterInstalledAppsmessage, providing a target WebAPK URL (e.g.,https://target.app/manifest.json) for both themanifestUrlparameter and theRelatedApplication’surlparameter (withplatformset to"webapp"). The browser responds with the installation status, bypassing DAL checks. - For SSRF: The attacker sends a
FilterInstalledAppsmessage whereapp.urlis set to an arbitrary origin and differs frommanifestUrl. The browser process initiates a request to[app.url]/.well-known/assetlinks.json.
Suggested Fix
Add an outermost main frame check to InstalledAppProviderFactory.java or when registering the binder in chrome_browser_interface_binders.cc. If the provided RenderFrameHost is not the outermost main frame, the binding request should be rejected (e.g., by returning null or terminating the renderer).
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.