CVE-2026-79086
Overview
Background
- `CustomTabsConnection`
- Chrome on Android’s implementation of the Custom Tabs service, which other installed apps bind to over Binder to warm up, launch and configure Custom Tabs and Trusted Web Activities (TWAs).
- `receiveFile()`
- a Custom Tabs service call through which a client app hands Chrome a
Urifor a stated purpose, such asFILE_PURPOSE_TRUSTED_WEB_ACTIVITY_SPLASH_IMAGE. Chrome then opens it throughCustomTabsClientFileProcessor. - Content URI permissions
- Android limits access to
content://URIs by app identity.Context.checkUriPermission(uri, pid, uid, FLAG_GRANT_READ_URI_PERMISSION)reports whether a given caller may read a URI. - Confused deputy
- a privileged component that does an action under its own identity on behalf of a less-privileged caller, without checking whether that caller is itself allowed to do it.
Root Cause Analysis
CustomTabsConnection.receiveFile() passed the client-supplied Uri straight to CustomTabsClientFileProcessor.getInstance().processFile(...). That opens and reads the URI with Chrome’s own app identity and permissions. Nothing checked that the bound client app, the Binder caller, could itself read that URI. So any app able to bind to Chrome’s Custom Tabs service could name a content:// URI that only Chrome can read, and Chrome would open it on the app’s behalf.
The fix adds a check before processFile: ContextUtils.getApplicationContext().checkUriPermission(uri, Binder.getCallingPid(), Binder.getCallingUid(), Intent.FLAG_GRANT_READ_URI_PERMISSION). If the result is not PackageManager.PERMISSION_GRANTED, it logs the call as failed and returns false without opening the URI. The new Robolectric tests confirm that a denied caller never causes the provider’s openFile to run and that no splash image is stored in SplashImageHolder.
receiveFile() opened a caller-chosen URI with Chrome’s privileges without checking that the caller had read access to it, a classic confused deputy. The fix checks the Binder caller’s own URI read permission before opening anything.Attack Path
- Install a malicious app The attacker needs an app on the user’s Android device that binds to Chrome’s Custom Tabs service and creates a session. This needs no special permission.
- Point `receiveFile()` at a protected URI
The app calls
receiveFile()with acontent://URI it cannot read itself but Chrome can, such as a provider that is not exported or is restricted by permission, using the TWA splash-image purpose. - Chrome opens it under its own identity
Before the patch,
processFileopened and read the URI as Chrome, treating the data as a TWA splash image to show for that session. - Observe or exploit the result The attacker may learn whether the resource exists or can be decoded, or may get protected image content shown in a context it can observe (inferred). The diff shows only that the URI was opened and its data stored as a splash image.
Impact Assessment
content:// resources the app itself cannot read, getting around Android’s URI permission model through Chrome’s identity. Any data disclosure that follows is (inferred), and depends on what the provider returns and how the splash-image path shows it. It needs a separately installed attacker app; web content cannot reach this. The advisory states only “Missing authorization in CustomTabs” and gives no in-the-wild exploitation detail.Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabsConnectionUnitTest.javachrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java
Audit Directions
- Binder entry points that open caller-supplied URIsReview other
CustomTabsConnectionandCustomTabsServicemethods, Trusted Web Activity andPostMessageplumbing, and share or import handlers that take aUrifrom another app. Confirm the caller’s permission is checked withcheckUriPermissionorcheckCallingUriPermissionbefore Chrome opens the URI itself. - Identity drop across thread hopsMake sure caller-identity checks run on the Binder thread before work is posted elsewhere, because
Binder.getCallingUid()is meaningless once a task runs on another thread. - Purpose-specific file processorsAudit
CustomTabsClientFileProcessorand similar dispatchers for other purposes or code paths that might skip the new gate, for example internal callers that reachprocessFiledirectly.
Patch
From f7e33e945ab5509c38b18b0d6feb18d59600123f Mon Sep 17 00:00:00 2001 From: Mohamed Adel <[email protected]> Date: Wed, 01 Jul 2026 14:47:26 -0700 Subject: [PATCH] [CCT] Check caller URI permission in receiveFile() CustomTabsConnection.receiveFile() opens a client-supplied URI on behalf of the bound client app to load a TWA splash image. Ensure the bound client itself holds read access to that URI before opening it under the browser's identity. Adds Robolectric coverage for the granted and denied cases. Bug: 511804361 Fixed: 511804361 Change-Id: Ic1fcaa0e3d4a40fa0897d479dff2d6b102b711f8 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8031565 Reviewed-by: Joel Tan-Aristy <[email protected]> Auto-Submit: Moe Adel <[email protected]> Commit-Queue: Joel Tan-Aristy <[email protected]> Cr-Commit-Position: refs/heads/main@{#1655765} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java index e82fd80..5688cf5b 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java @@ -2145,6 +2145,16 @@ public boolean receiveFile( CustomTabsSessionToken sessionToken, Uri uri, int purpose, @Nullable Bundle extras) { + if (ContextUtils.getApplicationContext() + .checkUriPermission( + uri, + Binder.getCallingPid(), + Binder.getCallingUid(), + Intent.FLAG_GRANT_READ_URI_PERMISSION) + != PackageManager.PERMISSION_GRANTED) { + logCall("receiveFile()", false); + return false; + } return CustomTabsClientFileProcessor.getInstance() .processFile(new SessionHolder<>(sessionToken), uri, purpose, extras); } diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabsConnectionUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabsConnectionUnitTest.java index 94d4582..b63618d2 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabsConnectionUnitTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabsConnectionUnitTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -28,13 +29,23 @@ import static org.chromium.chrome.browser.customtabs.CustomTabsConnection.ON_ACTIVITY_LAYOUT_TOP_EXTRA; import android.app.PendingIntent; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.ContextWrapper; import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.database.Cursor; import android.net.Network; +import android.net.Uri; import android.os.Bundle; +import android.os.ParcelFileDescriptor; import android.os.Process; import androidx.browser.customtabs.CustomTabsCallback; import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.customtabs.CustomTabsService; import androidx.browser.customtabs.CustomTabsSessionToken; import androidx.browser.customtabs.EngagementSignalsCallback; import androidx.browser.customtabs.PostMessageServiceConnection; @@ -43,14 +54,17 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import org.robolectric.Robolectric; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowProcess; +import org.chromium.base.ContextUtils; import org.chromium.base.test.BaseRobolectricTestRunner; import org.chromium.base.test.util.Batch; import org.chromium.base.test.util.Features.DisableFeatures; @@ -59,11 +73,17 @@ import org.chromium.chrome.browser.browserservices.SessionDataHolder; import org.chromium.chrome.browser.browserservices.SessionHandler; import org.chromium.chrome.browser.browserservices.intents.SessionHolder; +import org.chromium.chrome.browser.browserservices.ui.splashscreen.trustedwebactivity.SplashImageHolder; import org.chromium.chrome.browser.customtabs.content.EngagementSignalsHandler; import org.chromium.chrome.browser.flags.ChromeFeatureList; import org.chromium.chrome.browser.privacy.settings.PrivacyPreferencesManagerImpl; import org.chromium.chrome.browser.tab.Tab; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; + /** Tests for some parts of {@link CustomTabsConnection}. */ @RunWith(BaseRobolectricTestRunner.class) @Batch(Batch.UNIT_TESTS) @@ -71,6 +91,7 @@ public class CustomTabsConnectionUnitTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final TemporaryFolder mTemporaryFolder = new TemporaryFolder(); @Mock private SessionHandler mSessionHandler; @Mock private CustomTabsCallback mCallback; @@ -263,5 +284,108 @@ assertNull(mConnection.extractTargetNetwork(intent, mSessionHolder)); } + /** Content provider that records whether it was opened. */ + public static class TestSplashImageContentProvider extends ContentProvider { + static boolean sOpened; + static File sImageFile; + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + sOpened = true; + return ParcelFileDescriptor.open(sImageFile, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public boolean onCreate() { + return false; + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } + } + + private Uri registerSplashImageProvider() throws IOException { + TestSplashImageContentProvider.sOpened = false; + TestSplashImageContentProvider.sImageFile = mTemporaryFolder.newFile("splash.png"); + try (FileOutputStream stream = + new FileOutputStream(TestSplashImageContentProvider.sImageFile)) { + stream.write("non-empty".getBytes()); + } + ProviderInfo info = new ProviderInfo(); + info.authority = "org.chromium.test.splash"; + Robolectric.buildContentProvider(TestSplashImageContentProvider.class).create(info); + return Uri.parse("content://org.chromium.test.splash/splash.png"); + } + + private void setCallerUriPermission(int result) { + Context context = + new ContextWrapper(ContextUtils.getApplicationContext()) { + @Override + public int checkUriPermission(Uri uri, int pid, int uid, int modeFlags) { + return result; + } + }; + ContextUtils.initApplicationContextForTests(context); + } + + @Config(sdk = {BaseRobolectricTestRunner.MIN_SDK, 35}) + @Test + public void receiveFile_CallerWithoutUriPermission() throws IOException { + Uri uri = registerSplashImageProvider(); + setCallerUriPermission(PackageManager.PERMISSION_DENIED); + + assertFalse( + mConnection.receiveFile( + mSession, + uri, + CustomTabsService.FILE_PURPOSE_TRUSTED_WEB_ACTIVITY_SPLASH_IMAGE, + Bundle.EMPTY)); + assertFalse(TestSplashImageContentProvider.sOpened); + assertNull(SplashImageHolder.getInstance().takeImage(mSessionHolder)); + } + + @Config(sdk = {BaseRobolectricTestRunner.MIN_SDK, 35}) + @Test + public void receiveFile_CallerWithUriPermission() throws IOException { + Uri uri = registerSplashImageProvider(); + setCallerUriPermission(PackageManager.PERMISSION_GRANTED); + + assertTrue( + mConnection.receiveFile( + mSession, + uri, + CustomTabsService.FILE_PURPOSE_TRUSTED_WEB_ACTIVITY_SPLASH_IMAGE, + Bundle.EMPTY)); + assertTrue(TestSplashImageContentProvider.sOpened); + assertNotNull(SplashImageHolder.getInstance().takeImage(mSessionHolder)); + } + // TODO(https://crrev.com/c/4118209) Add more tests for Feature enabling/disabling. }