CVE-2026-79044
Overview
Background
- `InstalledAppProvider`
- The Mojo interface behind the web API that lets a page ask which of its declared related native apps are installed (inferred:
navigator.getInstalledRelatedApps()). On Android it is implemented in Java and created per frame byInstalledAppProviderFactory. - Primary main frame
- The top-level frame of the page currently shown in a tab. Subframes, and main frames of non-primary pages (e.g. fenced frames or prerendered pages), are not primary main frames.
- `terminateRendererDueToBadMessage`
- Browser-side handling for a renderer that makes a request it should never make. The process is killed with a
BadMessageReason, hereRFH_INVALID_CALL_FROM_NOT_MAIN_FRAME(227).
Root Cause Analysis
On Android, InstalledAppProviderFactory.createImpl() built an InstalledAppProvider for any RenderFrameHost that asked for one, with no check on which frame it was. According to the commit message, the C++ binder used on other platforms, InstalledAppProviderImpl::Create, already rejects frames that are not the primary main frame and kills the renderer, so the Java factory was an authorization gap only on Android. The browser therefore trusted the renderer to restrict the API to top-level documents, and a renderer that skipped that restriction could use the interface from a subframe or a non-primary main frame.
The fix adds a JNI bridge (RenderFrameHost.isInPrimaryMainFrame() → RenderFrameHostAndroid::IsInPrimaryMainFrame → native IsInPrimaryMainFrame()). createImpl() now checks it first: when false it calls terminateRendererDueToBadMessage(227) and returns null before any Profile lookup or provider construction. The Java bridge returns false when the native pointer is already gone, so a destroyed frame is denied.
InterfaceFactory for InstalledAppProvider left out the primary-main-frame check that the desktop C++ binder enforces. The fix adds the same browser-side frame check at bind time and treats a violation as a bad message.Attack Path
- Non-primary-main-frame context Content runs in an iframe or another non-primary main frame on Chrome for Android.
- Request the interface
The frame’s renderer asks for
InstalledAppProvider. Blink normally limits the API to top-level contexts, so reaching the browser from a subframe most likely needs a compromised renderer, or some frame type that the renderer-side check misclassifies (inferred). - Unchecked bind (pre-patch)
InstalledAppProviderFactory.createImpl()returned a working provider without checkingisInPrimaryMainFrame(). - Query installed apps The frame can then use the provider to learn whether related native apps are installed, a privacy-sensitive answer meant only for top-level documents (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.java |
modified |
Files Changed
content/public/android/java/src/org/chromium/content_public/browser/RenderFrameHost.javacontent/browser/renderer_host/render_frame_host_android.hcontent/public/android/java/src/org/chromium/content/browser/framehost/RenderFrameHostImpl.javachrome/android/junit/BUILD.gncontent/public/test/android/javatests/src/org/chromium/content_public/browser/test/mock/MockRenderFrameHost.javachrome/android/junit/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactoryTest.javacontent/browser/renderer_host/render_frame_host_android.ccchrome/android/java/src/org/chromium/chrome/browser/installedapp/InstalledAppProviderFactory.java
Audit Directions
- Java `InterfaceFactory` binders vs. C++ bindersCompare Android-only Java factories registered for frame interfaces with their desktop C++ counterparts. Check for frame-type, origin, secure-context or permission checks that only one platform enforces.
- Renderer-only API restrictionsFor web APIs that the spec limits to top-level, secure or user-activated contexts, confirm that the browser-side binder enforces the same restriction rather than relying on Blink.
- Frame-type predicatesCheck that gates use
IsInPrimaryMainFrame/IsOutermostMainFramecorrectly, so fenced frames, prerendered pages and back/forward-cached pages cannot slip through a looser “is main frame” check.
Patch
From 012d1f609b9cd6e55e83b139474c8c3ed9999091 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
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 <japhet@chromium.org>
Reviewed-by: Bo Liu <boliu@chromium.org>
Reviewed-by: Peter Conn <peconn@chromium.org>
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/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.
*
* @param callback The callback to be notified once the canonical URL has been fetched.
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
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",
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.