Pending Intents: A Pentester’s view

6 min readMay 31, 2022

Few days ago I came across an interesting case of vulnerability posted at the AndroidInfoSec’s facebook page. Since there are not many references on the specific subject I decided to take a short break from my heap exploitation series and cover this topic in a blog post. Before we move to the practical part, let us see first some basic definitions.

Pending Intents

From the android developers web page we have the following definition:

Pending intent: A description of an Intent and target action to perform with it.

While it sounds a bit poetic I can’t say that it is crystal clear, so let me start first by explaining the reason of their existence and you will probably figure out your self what is this all about:

Imagine that you have a non exported activity A which can be launched from your own application’s activities. You want though other applications to be able to call A under specific conditions. This is where the pending intent comes to place. The idea is to wrap a normal intent (base intent) that you would use to start activity A into an object, send this object to the other application and let the other application to unwrap the intent and send it back to activity A.

While this sounds convenient, together with the “wrapped intent” you are also delegating the permissions and identity of your application to the receiver of the pending intent. Think like you are giving your credit card to a stranger to perform a transaction for you. A very common and easy to comprehend show-case is when you want your app to receive a signal when a notification arrives or when a long taking process which is handled by an external app comes to an end. Lets see a very simple example in order to understand the whole concept:

My first app (application.a) has a non exported activity called NonExportedActivity . Then in my MainActivity I could create an intent similar to the one below:

Intent internalIntent = new Intent("My.Action");
internalIntent.putExtra("msg","Secret Msg");

And use the startActivity(internalIntent) to trigger it. To “allow” another app (application.b) to call my NonExportedActivity I can create a pending intent as follows (please ignore the flags for now):

PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(),0,internalIntent,FLAG_IMMUTABLE);

add the pending intent as a parcelable extra to another intent which targets the application.b:

Intent myOtherApp = new Intent();

Then, in application.b, I could write something like the following:

PendingIntent fromOtherApp = (PendingIntent) intent.getParcelableExtra("pendingIntent");

if(fromOtherApp != null){
Runnable theTimeHasCome = new Runnable() {
public void run() {
try {
System.out.println("The time has come....");
} catch (PendingIntent.CanceledException e) {
(new Handler()).postDelayed(theTimeHasCome,5000);

else System.out.println("you shouldn't come here");

The theTimeHasCome runnable simply emulates a delay of 5 seconds and then uses the send() method of the PendingIntent class, to send the intent to application.a. The whole set up is depicted below:

Safety First

As this precious package of yours can get to the wrong hands, there are many things that you have to consider when you are creating or reviewing pending intent related code. There are a couple of flags that can be used to define the wrapped intent’s mutability:

For example, imagine that you want to get notified when a file of yours has been downloaded and you delegate this task to another app:

Intent intentA  = new Intent(getApplicationContext(), NonExportedActivity.class);intentA.putExtra("msg","Download Failed");
PendingIntent pendingIntent=PendingIntent.getActivity(MainActivity.this,((int)System.currentTimeMillis()),intentA,FLAG_MUTABLE);
Intent myOtherApp = new Intent();

The receiver app can notify back using code similar to the one below:

PendingIntent arrivedPendingIntent = (PendingIntent) intent.getParcelableExtra("pendingIntent");if (arrivedPendingIntent != null) {if(downLoadSuccess)
arrivedPendingIntent.send(getApplicationContext(), 0, new Intent().putExtra("msgSuccess","DownloadSuccess"), null, null);

The wrapped intent’s data will be merged and not replaced by the new Intent().putExtra(“msgSuccess”,”DownloadSuccess”) thus the NonExportedActivity of the application.a can simply check if the msgSuccess extra exists and handle per case. It has to be mentioned that For pre-S Android versions a pending intent is considered mutable by default:

⚠️…Up until Build.VERSION_CODES.R, PendingIntents are assumed to be mutable by default, unless FLAG_IMMUTABLE is set. Starting with Build.VERSION_CODES.S, it will be required to explicitly specify the mutability of PendingIntents on creation with either (@link #FLAG_IMMUTABLE} or FLAG_MUTABLE. It is strongly recommended to use FLAG_IMMUTABLE when creating a PendingIntent. FLAG_MUTABLE should only be used when some functionality relies on modifying the underlying intent, e.g. any PendingIntent that needs to be used with inline reply or bubbles.

Hijacking ?

You might wonder if what is described above can be a real case scenario. Why an application would send a pending intent to another app if it doesn’t trust it. Actually it doesn’t have to….

There are many scenarios on how a malicious application can get its hands on a pending intent. Some of them are described in the initial post of this attack here and here. A very common case is to use a pending intent in order to let the notification manager to call your application when the user clicks on it. This is exactly what the code below does (via the setContentIntent of the NotificationCompat.Builder):

Intent intentA  = new Intent(getApplicationContext(), NonExportedActivity.class);NotificationCompat.Builder builder = new NotificationCompat.Builder(MainActivity.this, "my notification");
builder.setContentTitle("my title");
builder.setContentText("My text");
Intent intentA = new Intent();

NotificationManagerCompat managerCompat = NotificationManagerCompat.from(MainActivity.this);

Any application with the android.permission.BIND_NOTIFICATION_LISTENER_SERVICE can bind the notification listener service and fetch this pending intent via the following call:

PendingIntent pendingIntent = sbn.getNotification().contentIntent;

Which means that we have an “app in the middle” case:

In such a case assuming that the Pending Intent is mutable many unpredictable things can happen by the time that the base intent allows it. The worst case scenario is described below:

⚠️ …As such, you should be careful about how you build the PendingIntent: almost always, for example, the base Intent you supply should have the component name explicitly set to one of your own components, to ensure it is ultimately sent there and nowhere else [1].

This is exactly what happened here:

The base intent doesn’t explicitly set the target package and component, the flag is considered by default mutable (assuming that the app is running on Build.VERSION_CODES.R ). Thus a malicious application can:

  • Change the package name and component of the intent to target a component within the malicious app.
  • Run code with the id of the vulnerable app

Depending on the privileges and the exposure of the vulnerable app, this attack can lead even to code execution leverage components such as content providers. An excellent post describing these cases can be found here. The vulnerable app in HackerOne’s report has READ_CONTACTS permission thus it has access to the contacts provider. Let’s see this case in our applications application.a and application.b:

My application.a has the READ_CONTACTS permission approved, but my applicaion.b doesn’t. Then the application.a, creates the following Pending Intent:

PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(),0,new Intent(),FLAG_MUTABLE);

In application.b I have the following code:

PendingIntent pendingIntent = (PendingIntent) intent.getParcelableExtra("pendingIntent");Intent hijackIntent = new Intent();
hijackIntent.setClipData(ClipData.newRawUri(null, Uri.parse("content://contacts/people")));
hijackIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);pendingIntent.send(getApplicationContext(), 0, hijackIntent, null, null);

And in my MainActivity:

if(intent.getAction() != null && intent.getAction().equals("WELCOME.BACK"))

Where the handleCallBack will simply perform the content query:

Cursor cursor = getContentResolver().query(uri, null, null, null, null);

How to trace

Fortunately, tracing such bugs is very easy and can be done via a simple code search for mutable pending intents and misconfigured base intents. You can also use the ipc/pending_intents medusa module to see everything in action:


The post has been cited in