Android Exported Service MITM Attacks

We are used to a device having multiple activities that can respond to the same <intent-filter>. In that case, by default, the user will see a chooser if we try to start one of those activities.

We are used to a device having multiple BroadcastReceiver components that can respond to the same <intent-filter> (or IntentFilter). In that case, in a regular broadcast, all eligible receivers will receive it.

We are used to it being impossible to have multiple ContentProvider components with the same authority, as the second one fails on install with an INSTALL_FAILED_CONFLICTING_PROVIDER error.

Services, though, follow none of these patterns, and the pattern they do follow raises the possibility of man-in-the-middle (MITM) attacks if you are not careful.

Services that are private to your application (no <intent-filter> or android:exported="false") are safe, as explicit Intents are safe with services, as they stipulate the specific component that you are working with.

Services that are exported but are secured with a signature-level permission are also safe, as only another app signed by the same signing key will be able to work with the service.

But what happens if you want to have a exported service for which a signature-level permission is impossible? For example, what happens if you want to have a service expose an API for third party apps to consume? Those third party apps will be signed by their own signing keys, not yours, and so signature-level permissions will not work.

Without a signature-level permission, there is no automatic validation employed by Android to ensure that the service you are trying to work with is, indeed, the actual service you want to work with, instead of an imposter. Such an imposter might be a totally different service implementation, simply advertising the same <intent-filter> as the real service. Or, the imposter might be a cracked version of the original service app, with additional code injected that makes use of the data flowing between client and service, in addition to doing the normal service behavior.

In the latter case, the cracked version of the app might retain the same package name as your original service implementation. In that case, only one edition of the service can exist on the device, where the first one in “wins”. However, the user will be informed of a problem when they try to install the second such app, and so hopefully they will be able to determine which app is the valid implementation and which is the malware. If, however, they only install the cracked version of the component, the user is in trouble.

In addition, what happens if there are two (or more) services installed on the device that claim to support the same <intent-filter>, but have different package names? You might think that this would fail on install, as happens with providers with duplicate authorities. Alas, it does not. Instead, once again, the first one in “wins”.

So, if we have BadService and GoodService, both responding to the same <intent-filter>, and a client app tries to communicate to GoodService via the explicit Intent matching that <intent-filter>, it might actually be communicating with BadService, simply because BadService was installed first. The user is oblivious to this.

If you want to make sure that you are talking to the right third-party app, by any IPC mechanism, you will need to compare the public keys. The public key that signed an app is part of the APK; only apps signed by the same private key should contain the same public key. Hence, BadService should have a different public key than the expected one, while GoodService would have the expected public key.

You can obtain a binary-encoded edition of the public key from PackageManager. Use getPackageInfo() to retrieve the PackageInfo for an app, given its package name. Pass PackageManager.GET_SIGNATURES as the second parameter to getPackageInfo() to ensure that signature data is retrieved and is part of the PackageInfo structure. Then, the signatures data member of the PackageInfo will contain an array of Signature objects, which, despite their name, are actually binary-encoded versions of the public keys. Calling toByteArray() on a Signature will give you a value that you can compare against a known good binary-encoded version of the public key (e.g., packaged as a raw resource).

This is somewhat tedious, and so over time somebody (possibly me) will need to package this up into something easier for developers to employ. However, you can see some sample code in this area:

  • An app that will list all installed packages and let you inspect the public key of that package, plus dump a copy of the binary-encoded public key for you to perhaps incorporate into a client app

  • An app that validates another package given such a binary-encoded public key

  • A set of three apps demonstrating the above scenario, where we want to ensure that we work with GoodService despite the possibility of a BadService that also advertises support for the same <intent-filter>

(and, yes, a blow-by-blow explanation of this stuff is forthcoming in a future book update)

This code is clunky, and it does not handle apps signed with multiple keys, but it demonstrates some basic defenses that you can employ to avoid rogue service implementations.

Again, this is mostly a concern for IPC where signature-level permissions are not employed for securing that IPC. Non-exported services, or services with signature-level permissions, should not need this sort of work. Conversely, you might consider using this sort of defense for other components as well, such as a ContentProvider, to ensure that you are working with the expected provider, and not a cracked version that does something nefarious with the data being passed back and forth.

Want an expert opinion on your Android app architecture decisions? Perhaps Mark Murphy can help!