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