High chrome Policy Bypass 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in UI
DescriptionImproper input validation in UI
ComponentUI
Sub-componentAndroid
Bug ClassPolicy Bypass
Tracker576133777
Fix commitf90a000c395c (chromium/src) +76/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-27

Background

`SelectFileDialog` (Android)
Blink/Chrome’s Android file picker (ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java). When a chooser returns a content:// URI, Chrome must decide whether the returned file is safe to hand to the web page or whether it points back into Chrome’s own app-private storage (which would be an exfiltration of app-internal files).
`isContentUriUnderAppDir(Uri, Context)`
Helper that decides whether a content URI resolves to a file inside the app’s own directory. It opens the URI, resolves the real path via /proc/self/fd/<fd> symlink, and (per its callers) treats “under app dir” as something to reject/abort. Returning true means “block this URI”.
`Uri` object vs `Uri.parse(String)`
Android Uri built via Uri.Builder can carry opaque parts, odd authorities, or encodings that differ from what you get by round-tripping through toString() + Uri.parse(). Native code later opens the URI from its string form via Uri.parse(), so the Java object the check inspects and the URI the native layer actually opens can differ.
`ContentUriUtils.isUriFromThisApp` / `normalizeScheme()`
Helpers to normalize the scheme (e.g. CONTENT → content) and to detect a provider owned by Chrome’s own UID/package.

Root Cause Analysis

The vulnerability is a parser-differential / TOCTOU-style check bypass, not “SVG” and only loosely “input validation in UI” as the advisory states. The old isContentUriUnderAppDir immediately called context.getContentResolver().openFileDescriptor(uri, "r") on the original Uri object and resolved the fd to a path. Because the native side re-parses the URI from its string form (Uri.parse(normalized.toString())), an attacker could craft a Uri whose object form resolves to a benign/external location (passing the check) while its string form re-parses to a different authority — e.g. a Chrome-owned content provider — so native opens an app-private file the check believed it had cleared. Opaque content URIs (isOpaque()), empty authorities, and case-variant schemes (CONTENT://) produce exactly this object-vs-string divergence.

The patch aligns the check with how native actually resolves the URI: it normalizeScheme()s the URI, re-parses the normalized string via Uri.parse(normalized.toString()), and then rejects (returns true = block) if the original or re-parsed URI is opaque, if the re-parsed authority is empty, if the normalized and re-parsed authorities disagree, or if ContentUriUtils.isUriFromThisApp(parsed, context) is true (the provider belongs to Chrome itself). Only after these gates does it openFileDescriptor(parsed, "r") on the re-parsed URI, and it now returns false on a null pfd instead of asserting. The new testContentSchemeRejectsOpaqueAndSameAppUris test enumerates the bypass vectors (opaque encoded parts, CONTENT case, empty authority content:///…, same-app provider) and asserts the picker aborts for each while a genuinely external content://com.example.external/… still succeeds.

Key insight
The safety check inspected one URI representation while native opened another (Uri.parse(uri.toString())); an opaque, empty-authority, case-variant, or same-app content URI could pass the “not under app dir” check yet be re-parsed by native into a Chrome-owned provider, letting the page read app-private files.

Attack Path

  1. Entry Attacker-influenced content supplies a crafted content:// URI to the Android file picker result (via ACTION_VIEW data or ClipData), as exercised by onIntentCompleted. (reachability consistent with the test harness)
  2. Craft the differential Build a URI whose Uri object form resolves to an allowed/external location but whose toString() + Uri.parse() form yields a different authority — e.g. an opaque encoded part, empty authority, mixed-case CONTENT, or a Chrome-owned provider authority.
  3. Pass the old check isContentUriUnderAppDir opens the object form, resolves a benign path, and reports “not under app dir”, so the picker accepts the file.
  4. Native opens the real target Native re-parses the string form via Uri.parse() and opens the different, app-private file.
  5. Primitive The web origin receives the contents of a Chrome-app-internal file it was never authorized to read (cross-privilege file disclosure / sandbox policy bypass).

Impact Assessment

Android only; the affected code runs in the browser/UI layer handling file-picker results, but the attacker controls the URI reaching it from web-facing flows. The gained primitive is unauthorized read of app-private (same-app content provider) files returned to a web page — an information-disclosure / policy-bypass, gated on the attacker being able to supply a crafted content URI through the picker result and on the native/Java parse divergence. This is not memory corruption and not RCE; strength is bounded by which same-app provider paths are reachable. The advisory’s “input validation in UI” is directionally right but understates that the root cause is a Java-object-vs-parsed-string URI differential.

Changed Functions

FunctionChangeNotes
for
ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java
modified

Files Changed

  • ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java
  • ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java

Audit Directions

  • URI representation consistency
    Audit every place Chrome decides a security property on a Uri object but later acts on Uri.parse(uri.toString()) (or passes the string to native); the check and the use must operate on the same normalized, re-parsed form.
  • Opaque / authority edge cases
    Review other content-URI gatekeepers (sharing, downloads, intent handling) for failure to reject isOpaque() URIs, empty/variant authorities, or same-UID providers.
  • File picker result handling
    Trace all onIntentCompleted/ClipData paths that resolve returned content URIs to paths, ensuring app-private directories and Chrome-owned providers are consistently excluded.
From f90a000c395c8b07ea085cd23122bc3b6eb24515 Mon Sep 17 00:00:00 2001
From: Calder Kitagawa <ckitagawa@chromium.org>
Date: Thu, 22 Oct 2026 14:43:25 -0700
Subject: [PATCH] [Android] Normalize content URI handling in SelectFileDialog

Align SelectFileDialog.isContentUriUnderAppDir() content URI checks with
ContentUriUtils.

Fixed: 576133777
Change-Id: I6961adb7d30175b31ba8697b096156886a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8631697
Reviewed-by: Charles Cai <charlesyc@google.com>
Reviewed-by: Sky Malice <skym@chromium.org>
Commit-Queue: Calder Kitagawa <ckitagawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1725217}
---
diff --git a/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java b/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java
index 35262c3..34b68ed6 100644
--- a/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java
+++ b/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java
@@ -1059,8 +1059,19 @@
 
     public static boolean isContentUriUnderAppDir(Uri uri, Context context) {
         assert !ThreadUtils.runningOnUiThread();
-        try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r")) {
-            assumeNonNull(pfd);
+        // Re-parse the URI string since native opens the string form via Uri.parse().
+        Uri normalized = uri.normalizeScheme();
+        Uri parsed = Uri.parse(normalized.toString());
+        if (uri.isOpaque()
+                || parsed.isOpaque()
+                || TextUtils.isEmpty(parsed.getAuthority())
+                || !TextUtils.equals(normalized.getAuthority(), parsed.getAuthority())
+                || ContentUriUtils.isUriFromThisApp(parsed, context)) {
+            return true;
+        }
+        try (ParcelFileDescriptor pfd =
+                context.getContentResolver().openFileDescriptor(parsed, "r")) {
+            if (pfd == null) return false;
             int fd = pfd.getFd();
             // Use the file descriptor to find out the read file path thru symbolic link.
             Path fdPath = Paths.get("/proc/self/fd/" + fd);
diff --git a/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java b/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java
index cca293a..2ea936b 100644
--- a/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java
+++ b/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java
@@ -21,11 +21,14 @@
 import android.app.Activity;
 import android.content.ClipData;
 import android.content.ContentResolver;
+import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.Build;
+import android.os.Process;
 import android.provider.DocumentsContract;
 import android.provider.MediaStore;
 import android.webkit.MimeTypeMap;
@@ -47,6 +50,7 @@
 import org.robolectric.Shadows;
 import org.robolectric.annotation.Config;
 import org.robolectric.shadows.ShadowMimeTypeMap;
+import org.robolectric.shadows.ShadowPackageManager;
 
 import org.chromium.base.ContextUtils;
 import org.chromium.base.DeviceInfo;
@@ -1393,6 +1397,65 @@
     }
 
     @Test
+    @DisableFeatures({UiAndroidFeatures.CHECK_INTENT_CALLER_PERMISSION})
+    public void testContentSchemeRejectsOpaqueAndSameAppUris() {
+        Context context = ContextUtils.getApplicationContext();
+        String pkg = context.getPackageName();
+        String sameAppAuthority = pkg + ".TestProvider";
+        ShadowPackageManager shadowPm = Shadows.shadowOf(context.getPackageManager());
+        shadowPm.getInternalMutablePackageInfo(pkg).applicationInfo.uid = Process.myUid();
+        ProviderInfo providerInfo = new ProviderInfo();
+        providerInfo.authority = sameAppAuthority;
+        providerInfo.packageName = pkg;
+        providerInfo.name = "TestProvider";
+        providerInfo.applicationInfo = shadowPm.getInternalMutablePackageInfo(pkg).applicationInfo;
+        shadowPm.addOrUpdateProvider(providerInfo);
+
+        Uri[] rejectedUris =
+                new Uri[] {
+                    new Uri.Builder()
+                            .scheme("content")
+                            .encodedOpaquePart("//com.example.app.provider/files/doc.txt")
+                            .build(),
+                    new Uri.Builder()
+                            .scheme("CONTENT")
+                            .encodedOpaquePart("//com.example.app.provider/files/doc.txt")
+                            .build(),
+                    new Uri.Builder()
+                            .scheme("content")
+                            .encodedPath("//com.example.external/doc.txt")
+                            .build(),
+                    Uri.parse("content:///files/doc.txt"),
+                    Uri.parse("content://" + sameAppAuthority + "/files/missing.txt"),
+                };
+
+        TestSelectFileDialog testDialog = new TestSelectFileDialog(null);
+        for (Uri uri : rejectedUris) {
+            Intent dataIntent = new Intent(Intent.ACTION_VIEW, uri);
+            testDialog.onIntentCompleted(Activity.RESULT_OK, dataIntent);
+            runAllAsyncTasks();
+            assertEquals("Expected 0 successes for " + uri, 0, testDialog.mFileSelectionSuccess);
+            assertEquals("Expected 1 abort for " + uri, 1, testDialog.mFileSelectionAborted);
+            testDialog.resetFileSelectionAttempts();
+
+            Intent clipIntent = new Intent();
+            clipIntent.setClipData(new ClipData("label", new String[0], new ClipData.Item(uri)));
+            testDialog.onIntentCompleted(Activity.RESULT_OK, clipIntent);
+            runAllAsyncTasks();
+            assertEquals("Expected 0 successes for " + uri, 0, testDialog.mFileSelectionSuccess);
+            assertEquals("Expected 1 abort for " + uri, 1, testDialog.mFileSelectionAborted);
+            testDialog.resetFileSelectionAttempts();
+        }
+
+        Uri externalUri = Uri.parse("content://com.example.external/doc.txt");
+        testDialog.onIntentCompleted(
+                Activity.RESULT_OK, new Intent(Intent.ACTION_VIEW, externalUri));
+        runAllAsyncTasks();
+        assertEquals(1, testDialog.mFileSelectionSuccess);
+        assertEquals(0, testDialog.mFileSelectionAborted);
+    }
+
+    @Test
     public void testShowTypes() {
         ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton());
         shadowMimeTypeMap.addExtensionMimeTypeMapping("jpg", "image/jpeg");
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.