Medium watch CVSS 5.1 chrome Authorization Error 🔧 Commit mapped

Overview

Medium
Severity
5.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in CustomTabs
DescriptionMissing authorization in CustomTabs
ComponentCustomTabs
Sub-componentCCT
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker511804361
Fix commitf7e33e945ab5 (chromium/src) +134/-0
CWECWE-862
CVSS vectorCVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-07-01 (public git)
Disclosed2026-08-25
Patch gap55 days — fix public in source before the advisory

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 Uri for a stated purpose, such as FILE_PURPOSE_TRUSTED_WEB_ACTIVITY_SPLASH_IMAGE. Chrome then opens it through CustomTabsClientFileProcessor.
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.

Key insight
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

  1. 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.
  2. Point `receiveFile()` at a protected URI The app calls receiveFile() with a content:// 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.
  3. Chrome opens it under its own identity Before the patch, processFile opened and read the URI as Chrome, treating the data as a TWA splash image to show for that session.
  4. 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

A malicious app on the same Android device could make Chrome open 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.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java

Audit Directions

  • Binder entry points that open caller-supplied URIs
    Review other CustomTabsConnection and CustomTabsService methods, Trusted Web Activity and PostMessage plumbing, and share or import handlers that take a Uri from another app. Confirm the caller’s permission is checked with checkUriPermission or checkCallingUriPermission before Chrome opens the URI itself.
  • Identity drop across thread hops
    Make 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 processors
    Audit CustomTabsClientFileProcessor and similar dispatchers for other purposes or code paths that might skip the new gate, for example internal callers that reach processFile directly.
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.
 }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.