High watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Core
DescriptionIncorrect reference resolution in Core
ComponentCore
Sub-componentFileSystemAccess
Bug ClassLogic 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.
Tracker525167753
Fix commitcfd4ac78e0e0 (chromium/src) +101/-21
CWECWE-706
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-18 (per vendor advisory)
Fix landed2026-08-03 (public git)
Disclosed2026-08-18
Patch gap15 days — fix public in source before the advisory

Background

Content URI
an Android content://<authority>/<path> reference that the OS resolves, using the authority, to a ContentProvider registered by some app. Chrome registers its own internal providers (for example <package>.FileProvider, and according to the commit message also some whose authority is exactly the package name) to serve its private files.
File System Access (FSA) blocklist on Android
ChromeFileSystemAccessPermissionContext (chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc) checks whether a content URI belongs to Chrome’s own providers and aborts access if it does, so web pages cannot get handles to Chrome-private data.
`WebAppLaunchHandler` file handling
Java code for Trusted Web Activity / Custom Tab web-app launches that receives FileHandlingData.uris from a launching intent. It is meant to reject Chrome’s own content URIs before passing them to the web app.
`ContentUriUtils.isUriFromThisApp`
an existing Java helper that, according to its doc comment and the commit message, uses PackageManager to decide whether a URI is served by a provider belonging to the current app (same UID), instead of matching strings.

Root Cause Analysis

Both blocklist checks decided whether a content URI was Chrome-internal by matching the authority string against the prefix <package_name>.. In C++, base::StartsWith(decoded_path, "content://" + package_name() + ".", INSENSITIVE_ASCII) was applied to the unescaped URI. In Java, isValidLaunchUri tested uri.getAuthority().toLowerCase().startsWith(packageName + "."). The commit message names two cases that the string match does not catch but Android still routes to Chrome’s provider. The first is an authority that equals the package name exactly (content://com.android.chrome/...), which lacks the trailing dot. The second is a URI with userinfo in front (content://user@com.android.chrome.FileProvider/...), which fails the C++ prefix and Java getAuthority() checks while still reaching the same provider. The violated rule was that “the URI does not reference a Chrome-owned provider” must be decided by how Android actually resolves the URI, not by its text.

The fix adds base::IsContentUriFromThisApp, which calls a new @CalledByNative ContentUriUtils.isUriFromThisApp(String), and replaces the Java prefix test with ContentUriUtils.isUriFromThisApp(uri). Both paths now ask PackageManager which app owns the provider, so the check is stricter.

Key insight
The check meant to block Chrome’s private content providers parsed the URI authority differently from Android’s resolver: it required a trailing dot and ignored userinfo, so an equivalent URI could get past it. The fix hands the decision to a PackageManager-backed ownership check that uses the same resolution as the platform.

Attack Path

  1. Craft an equivalent internal URI The attacker forms a content URI for a Chrome-internal provider either as content://<package>/... or as content://user@<package>.FileProvider/.... These are the exact forms in the new regression tests.
  2. Deliver it to a guarded path The URI is passed to an FSA handle-open path checked by ChromeFileSystemAccessPermissionContext, or delivered as file-handling data in a web-app launch intent that WebAppLaunchHandler.isValidFileHandlingData processes. The diff does not show how a website alone gets an arbitrary URI into these paths. For the launch-intent path, a cooperating local Android app sending the intent seems required (inferred).
  3. Bypass the string blocklist Pre-patch, the prefix checks fail to match, so the URI is treated as an external provider and access is allowed (the tests now expect IsOpenAbort and an empty file list).
  4. Read Chrome-private data Android resolves the URI to Chrome’s own provider, and the web page gets file or directory handles to content inside Chrome’s private storage (inferred from the commit message’s “access Chrome’s internal private content providers”).

Impact Assessment

On Chrome for Android, a malicious website, possibly with help from a local app that launches a TWA/web app, could get read or write access through FSA handles to files served by Chrome’s internal content providers. Those files may include profile, cache or other app-private data, which breaks the app-sandbox boundary between web content and the browser’s private storage (inferred, beyond the commit message’s statement). The bug is Android-only. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.

Changed Functions

FunctionChangeNotes
for
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
modified
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
modified

Files Changed

  • chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
  • base/android/content_uri_utils.h
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
  • base/android/content_uri_utils.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
  • base/test/android/java/AndroidManifest_file_provider.xml
  • base/android/java/src/org/chromium/base/ContentUriUtils.java

Audit Directions

  • String-matching URI/authority checks
    Search for StartsWith/startsWith on content://, getAuthority(), file:// or package-name strings used for security decisions. Check how each one handles userinfo, exact-match authorities, case, percent-encoding and multiple authorities, and prefer platform resolution (PackageManager.resolveContentProvider, UID checks).
  • Fail-open exception handling
    The new isUriFromThisApp(String) returns false (“not ours”, so allowed) when Uri.parse or provider resolution throws. Check whether a malformed URI can make the ownership check throw while the later open still succeeds, and look for similar fail-open blocklist helpers.
  • Other intent- and URI-ingesting entry points
    Audit other places where Chrome on Android accepts URIs from intents or web APIs (share target, file handling, downloads, <input type=file> results, WebView file choosers) to confirm they use the same resolution-based ownership check.
From cfd4ac78e0e02660e9badf97e4c0f8fe192367c8 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Mon, 03 Aug 2026 15:33:49 -0700
Subject: [PATCH] [FileSystemAccess] Fix internal Content URI blocklist bypasses on Android

This CL fixes a vulnerability where malicious websites could bypass the
File System Access API's blocklist on Android to access Chrome's
internal private content providers.

1. Exact Package Name Bypass: Chrome registers some providers with the
   exact package name as the authority (e.g. content://com.android.chrome).
   The previous blocklist check strictly looked for a trailing dot
   (content://com.android.chrome.), allowing these to bypass the check.
2. Userinfo Bypass: Prepending userinfo (e.g., content://user@com.android.chrome)
   bypassed the prefix check but was still resolved by Android.

Fix:
- Refactored the C++ blocklist check to delegate to Java's robust
  ContentUriUtils.isUriFromThisApp() via JNI. This helper resolves the
  authority using PackageManager, making it immune to string-based bypasses like including userinfo.
- Patched the Java-side TWA validation in WebAppLaunchHandler.java using
  robust host extraction via Uri.getHost().
- Added C++ and Java unit tests.

Fixed: 525167753
Test: chrome_junit_tests --gtest_filter=*WebAppLaunchHandlerTest*
Test: unit_tests --gtest_filter=ChromeFileSystemAccessPermissionContextTest.ConfirmSensitiveEntryAccess_ContentUri

TAG=agy
CONV=c6712a02-0910-4fd2-8f7a-fb4d862b0d19

Change-Id: I369b05fa63e0157c2ddb42e83ffb5d627d503bc2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7986593
Reviewed-by: Balazs Engedy <engedy@chromium.org>
Reviewed-by: Benoit Lize <lizeb@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Reviewed-by: Joel Hockey <joelhockey@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672927}
---
diff --git a/base/android/content_uri_utils.cc b/base/android/content_uri_utils.cc
index 22dc919b..64e6518 100644
--- a/base/android/content_uri_utils.cc
+++ b/base/android/content_uri_utils.cc
@@ -149,6 +149,11 @@
                        Time::FromMillisecondsSinceUnixEpoch(last_modified));
 }
 
+bool IsContentUriFromThisApp(const FilePath& content_uri) {
+  JNIEnv* env = android::AttachCurrentThread();
+  return Java_ContentUriUtils_isUriFromThisApp(env, content_uri.value());
+}
+
 std::string GetContentUriMimeType(const FilePath& content_uri) {
   JNIEnv* env = android::AttachCurrentThread();
   return Java_ContentUriUtils_getMimeType(env, content_uri.value());
diff --git a/base/android/content_uri_utils.h b/base/android/content_uri_utils.h
index fba9161..2a602f32 100644
--- a/base/android/content_uri_utils.h
+++ b/base/android/content_uri_utils.h
@@ -65,8 +65,12 @@
 
 }  // namespace internal
 
+BASE_EXPORT bool IsContentUriFromThisApp(const FilePath& content_uri);
+
 // Gets MIME type from a content URI. Returns an empty string if the URI is
 // invalid.
+// Returns whether the content URI is served by a ContentProvider belonging to
+// the current application.
 BASE_EXPORT std::string GetContentUriMimeType(const FilePath& content_uri);
 
 // Gets the display name from a content URI. Returns true if the name was found.
diff --git a/base/android/java/src/org/chromium/base/ContentUriUtils.java b/base/android/java/src/org/chromium/base/ContentUriUtils.java
index adf5589..4894308 100644
--- a/base/android/java/src/org/chromium/base/ContentUriUtils.java
+++ b/base/android/java/src/org/chromium/base/ContentUriUtils.java
@@ -718,6 +718,16 @@
         return isUriFromThisApp(uri, ContextUtils.getApplicationContext());
     }
 
+    @CalledByNative
+    public static boolean isUriFromThisApp(@JniType("std::string") String uriString) {
+        if (TextUtils.isEmpty(uriString)) return false;
+        try {
+            return isUriFromThisApp(Uri.parse(uriString));
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     /**
      * Returns whether the content URI is served by a ContentProvider belonging to the current
      * application (i.e. running under the same UID).
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
index 8ac0bcd..3c39e62 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
@@ -30,7 +30,7 @@
 import org.jni_zero.JniType;
 import org.jni_zero.NativeMethods;
 
-import org.chromium.base.ContextUtils;
+import org.chromium.base.ContentUriUtils;
 import org.chromium.base.Log;
 import org.chromium.build.annotations.NullMarked;
 import org.chromium.build.annotations.Nullable;
@@ -51,7 +51,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
-import java.util.Locale;
 
 /**
  * Manages web application launch configurations based on client mode. Provides methods to process
@@ -131,16 +130,15 @@
     }
 
     private boolean isValidFileHandlingData(FileHandlingData fileHandlingData) {
-        String packageName = ContextUtils.getApplicationContext().getPackageName();
         for (Uri uri : fileHandlingData.uris) {
-            if (!isValidLaunchUri(uri, packageName)) {
+            if (!isValidLaunchUri(uri)) {
                 return false;
             }
         }
         return true;
     }
 
-    private static boolean isValidLaunchUri(Uri uri, String packageName) {
+    private static boolean isValidLaunchUri(Uri uri) {
         if (uri == null) return false;
 
         // Only content URIs are allowed. Legitimate file launching on Android should
@@ -150,14 +148,8 @@
         }
 
         // Block Chrome's own Content URIs.
-        String authority = uri.getAuthority();
-        if (authority != null) {
-            String chromeAuthorityPrefix = packageName + ".";
-            if (authority
-                    .toLowerCase(Locale.US)
-                    .startsWith(chromeAuthorityPrefix.toLowerCase(Locale.US))) {
-                return false;
-            }
+        if (ContentUriUtils.isUriFromThisApp(uri)) {
+            return false;
         }
 
         return true;
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
index 067c29e..ee488ba 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
@@ -23,8 +23,11 @@
 import static org.robolectric.Shadows.shadowOf;
 
 import android.app.Activity;
+import android.content.Context;
 import android.content.Intent;
+import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
 import android.net.Uri;
 import android.os.Looper;
 import android.text.TextUtils;
@@ -101,6 +104,25 @@
         when(mWebContentsMock.getLastCommittedUrl()).thenReturn(JUnitTestGURLs.INITIAL_URL);
         when(mTabProviderMock.getInitialTabCreationMode()).thenReturn(TabCreationMode.DEFAULT);
         when(mTabProviderMock.getSpeculatedUrl()).thenReturn(null);
+
+        // Mock PackageManager to resolve our test providers
+        Context context = ContextUtils.getApplicationContext();
+        PackageManager pm = context.getPackageManager();
+        String packageName = context.getPackageName();
+
+        PackageInfo packageInfo = new PackageInfo();
+        packageInfo.packageName = packageName;
+
+        ProviderInfo fileProvider = new ProviderInfo();
+        fileProvider.packageName = packageName;
+        fileProvider.authority = packageName + ".FileProvider";
+
+        ProviderInfo exactProvider = new ProviderInfo();
+        exactProvider.packageName = packageName;
+        exactProvider.authority = packageName;
+
+        packageInfo.providers = new ProviderInfo[] {fileProvider, exactProvider};
+        shadowOf(pm).installPackage(packageInfo);
     }
 
     @Test
@@ -400,6 +422,34 @@
     }
 
     @Test
+    public void filePath_chromePrivateDataBypass() {
+        String packageName = ContextUtils.getApplicationContext().getPackageName();
+        // Bypass using exactly package name as authority (no trailing dot in prefix check)
+        Uri bypassUri1 = Uri.parse("content://" + packageName + "/foo");
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse(CONTENT_URI), bypassUri1));
+        mExpectedFileList = new String[0]; // Expect empty because one is Chrome private (bypassed)
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
+    public void filePath_chromePrivateDataBypassUserInfo() {
+        String packageName = ContextUtils.getApplicationContext().getPackageName();
+        // Bypass using userinfo
+        Uri bypassUri1 = Uri.parse("content://user@" + packageName + ".FileProvider/foo");
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse(CONTENT_URI), bypassUri1));
+        mExpectedFileList = new String[0]; // Expect empty because one is Chrome private (bypassed)
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
     public void filePath_emptyPath() {
         mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse("")));
         mExpectedFileList = new String[0];
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
index b425488..e0d5734 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
@@ -79,6 +79,7 @@
 
 #if BUILDFLAG(IS_ANDROID)
 #include "base/android/apk_info.h"
+#include "base/android/content_uri_utils.h"
 #include "base/strings/string_util.h"
 #include "chrome/browser/glic/host/guest_util.h"  // nogncheck
 #include "chrome/browser/ui/android/tab_model/tab_model.h"
@@ -2269,13 +2270,7 @@
   // The only check for content-URIs is that they are not from an internal
   // FileProvider.
   if (path_info.path.IsContentUri()) {
-    std::string decoded_path = base::UnescapeBinaryURLComponent(
-        path_info.path.value(), base::UnescapeRule::NORMAL);
-    std::move(callback).Run(base::StartsWith(
-        decoded_path,
-        base::StrCat(
-            {"content://", base::android::apk_info::package_name(), "."}),
-        base::CompareCase::INSENSITIVE_ASCII));
+    std::move(callback).Run(base::IsContentUriFromThisApp(path_info.path));
     return;
   }
 #endif  // BUILDFLAG(IS_ANDROID)
diff --git a/base/test/android/java/AndroidManifest_file_provider.xml b/base/test/android/java/AndroidManifest_file_provider.xml
index 92a04dc..4b482ace 100644
--- a/base/test/android/java/AndroidManifest_file_provider.xml
+++ b/base/test/android/java/AndroidManifest_file_provider.xml
@@ -16,7 +16,7 @@
                 android:resource="@xml/file_paths_file_provider" />
         </provider>
         <provider android:name="org.chromium.base.InMemoryContentProvider"
-            android:authorities="${applicationId}.inmemory"
+            android:authorities="${applicationId}.inmemory;${applicationId}"
             android:exported="false"
             android:grantUriPermissions="true">
         </provider>
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 73250979..337d442 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -1155,6 +1155,30 @@
                         "%2efileprovider/cache/file"})),
       HandleType::kFile));
 
+  // Authority exactly matching package name (no dot) should fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "/cache/file"})),
+      HandleType::kFile));
+
+  // Userinfo bypass should also fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(base::StrCat({"content://user@",
+                                   base::android::apk_info::package_name(),
+                                   ".fileprovider/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(base::StrCat({"content://user@",
+                                   base::android::apk_info::package_name(),
+                                   "/cache/file"})),
+      HandleType::kFile));
+
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/dir"),
                             HandleType::kDirectory));
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/file"),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/base/test/android/java/AndroidManifest_file_provider.xml b/base/test/android/java/AndroidManifest_file_provider.xml
index 92a04dc..4b482ace 100644
--- a/base/test/android/java/AndroidManifest_file_provider.xml
+++ b/base/test/android/java/AndroidManifest_file_provider.xml
@@ -16,7 +16,7 @@
                 android:resource="@xml/file_paths_file_provider" />
         </provider>
         <provider android:name="org.chromium.base.InMemoryContentProvider"
-            android:authorities="${applicationId}.inmemory"
+            android:authorities="${applicationId}.inmemory;${applicationId}"
             android:exported="false"
             android:grantUriPermissions="true">
         </provider>
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 73250979..337d442 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -1155,6 +1155,30 @@
                         "%2efileprovider/cache/file"})),
       HandleType::kFile));
 
+  // Authority exactly matching package name (no dot) should fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "/cache/file"})),
+      HandleType::kFile));
+
+  // Userinfo bypass should also fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(base::StrCat({"content://user@",
+                                   base::android::apk_info::package_name(),
+                                   ".fileprovider/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(base::StrCat({"content://user@",
+                                   base::android::apk_info::package_name(),
+                                   "/cache/file"})),
+      HandleType::kFile));
+
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/dir"),
                             HandleType::kDirectory));
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/file"),
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Bypass of Clipboard Confused Deputy Defenses via '0@' Userinfo Prefix

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential vulnerability in ContentUriUtils.isUriFromThisApp() allows bypassing Chrome’s Clipboard Confused Deputy Defenses on Android. Using content URIs with a ‘userId@’ userinfo prefix (e.g., ‘0@’) causes the package validation check to incorrectly identify the URI as external. When ContentResolver subsequently accesses the URI, it normalizes the authority, allowing a malicious app to read Chrome’s private, non-exported FileProvider.

Affected files:

  • base/android/java/src/org/chromium/base/ContentUriUtils.java
  • ui/android/java/src/org/chromium/ui/base/ClipboardImpl.java
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc

Estimated timestamp from git blame: 2026-06-16

Potential Security Vulnerability in Chrome for Android Clipboard and FileProvider Access

Summary

A potential logic vulnerability has been identified in ContentUriUtils.isUriFromThisApp() where the use of a userId@ prefix (such as 0@) in the content URI authority bypasses validation checks. This can potentially allow a co-installed malicious app to trick Chrome into acting as a confused deputy and reading its own private files (e.g., exported passwords, cache files, and downloads) on behalf of untrusted web content.

Note: The following findings and steps are potential/suggested scenarios based on code review; our automated tooling does not currently have the capability to run or execute proof-of-concept exploit code on live environments.


1. Root Cause Analysis

In base/android/java/src/org/chromium/base/ContentUriUtils.java:

public static boolean isUriFromThisApp(@Nullable Uri uri, Context context) {
    if (uri == null || !ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) return false;
    String authority = uri.getAuthority();
    if (TextUtils.isEmpty(authority)) return false;
    PackageManager pm = context.getPackageManager();
    ProviderInfo info = pm.resolveContentProvider(authority, 0);
    return info != null && TextUtils.equals(info.packageName, context.getPackageName());
}
  • When given a URI like content://0@com.android.chrome.FileProvider/passwords/export.csv, uri.getAuthority() returns "0@com.android.chrome.FileProvider" (as the authority includes the userinfo prefix).
  • PackageManager.resolveContentProvider() expects the raw authority name declared in the manifest (e.g., "com.android.chrome.FileProvider") and does not strip user info. Therefore, the query returns null, and isUriFromThisApp() incorrectly returns false (meaning “this URI does not belong to Chrome”).

2. Clipboard Defenses Bypass

In ui/android/java/src/org/chromium/ui/base/ClipboardImpl.java (lines 109-117):

if (UiAndroidFeatureMap.isEnabled(UiAndroidFeatures.CLIPBOARD_CONFUSED_DEPUTY_DEFENSE_TEXT)) {
    Uri uri = item.getUri();
    if (item.getText() == null && ContentUriUtils.isUriFromThisApp(uri)) {  // Evaluates to false; guard skipped
        return null;
    }
}
return item.coerceToText(mContext).toString();
  • Because isUriFromThisApp() returns false, the safety check is skipped.
  • The execution reaches item.coerceToText(mContext), where Android’s ContentResolver normalizes the authority by stripping the 0@ prefix. This successfully maps the URI back to Chrome’s own private, non-exported ChromeFileProvider.
  • Because Chrome is reading its own provider under its own UID, Android permits access, and the raw file contents are returned as plain text to the renderer process (and thus to the untrusted web page via clipboard APIs or paste events).

Similar bypasses apply to the image-pasting and filename-reading defense checks in ClipboardImpl.getPng(), getFilenames(), and hasFilenames().

3. File System Access Blocklist Bypass

In chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc (lines 2174-2183):

if (path_info.path.IsContentUri()) {
  std::string decoded_path = base::UnescapeBinaryURLComponent(
      path_info.path.value(), base::UnescapeRule::NORMAL);
  std::move(callback).Run(base::StartsWith(
      decoded_path,
      base::StrCat({"content://", base::android::apk_info::package_name(), "."}),
      base::CompareCase::INSENSITIVE_ASCII));
  return;
}
  • A URI containing 0@ (e.g., content://0@com.android.chrome.FileProvider/...) does not start with content://com.android.chrome., bypassing this second-layer restriction.

Suggested Potential Steps to Reproduce

  1. Ensure a target file exists under a valid path in file_paths.xml (e.g., a .csv file under <app_cache>/passwords/).
  2. From a co-installed malicious app, write a crafted URI containing the primary user prefix 0@ to the system clipboard: content://0@<chrome_package>.FileProvider/passwords/<target>.csv.
  3. In Chrome, navigate to a page that reads the clipboard (e.g., via navigator.clipboard.readText() or a paste event handler).
  4. Observe that the clipboard validation is bypassed, and the private file contents are successfully read and exposed to the web origin.

Proposed Remediation

  1. In Java (ContentUriUtils.java): Strip any userId@ / userinfo@ prefix from the authority string before querying the PackageManager. For example:
    int idx = authority.indexOf('@');
    String normalizedAuthority = (idx == -1) ? authority : authority.substring(idx + 1);
    
  2. In C++ (chrome_file_system_access_permission_context.cc): Parse the content URI using a robust parser to extract the host/authority correctly instead of performing a raw string StartsWith prefix match, or normalize the URI to strip any userinfo prefix before comparison.

Evaluated with Chrome root at commit: 75203b87cbf6681eb7c7dda8e1d0bf781538c76a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker