How long will you go to protect your Android app from being tampered?
The big problem
Security of your app matters, whether you’re an indie developer trying to place a product and make a living out of your application, a big bank, a giant dating app or just an enthusiast who likes building Android apps.
We’re stuck in an apocalyptic world of .apk
files, that are easily decompiled and it doesn’t require a lot of knowledge to tamper with the existing code.
An APK
file contains all of a program’s code (such as .dex
a.k.a classes files), libs
, resources
, assets
, certificates
, and manifest file
, also that APK
file is vulnerable.
If you noticed how cringey the meme is, that’s how cringey it is to reverse-engineer an Android .apk
, it’s cringely funny and easy.
dex2jar converts .apk
to .jar
, using jd-gui you can even look at the Java/Kotlin
code (most of the time at least) and if you want to extract the resources (you probably need images, translations etc…) apktool is here to help you.
You don’t even need to use the aforementioned tools, there are other websites that had this implemented and will do the heavy burden of writing scripts or doing the job yourself, just drop them an .apk
and within minutes they’re done.
Finding or just salvaging a solution, is there a silver bullet?
You might think, of course there’s something that has to be done in order to protect our applications, matter of fact there is, you can only mitigate the solution until someone outsmarts it.
Is there a silver bullet?
NO!
R8/Proguard
You might have heard of proguard, it will obfuscate the code by giving random meaningless names to all methods, classes and variables, you might see something like axv
bdsa
classes or mix up of chars up to several other letters that you can barely even understand, this also does optimize the code.
Dexguard
ProGuard is a generic optimizer for Java bytecode.
DexGuard is a specialized tool for the protection of Android applications.
At least that’s what they claim, that is a paid option and comes at a hefty price, all in all these two solutions do not entirely protect your app from being tampered.
Keep in mind that these tools alone do not solve your problem, they just slow down a determined individual.
Kotlin messing up things
We all love Kotlin alright, it enabled us to make apps that Java never did, especially writing non-confusing lambdas, null pointer safety and all other handy features.
But that comes with a price.
One day you’re enabling R8
and proguard
, minifying and obfuscating code and your class is dead simple
- The
Intrinsics
way and the meta-data way
You’ve been exposed by the Kotlin compiler.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DeadSimpleClass {
lateinit var leastImportantVariable: String
private val lazyVariable by lazy {
"someString"
}
fun someFunction(someAbnormalString: String, aList: List<String>) {
//
}
fun String.doSomething() {
//
}
}
when we decompile this class to Java
bytecode we get
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
import java.util.List;
import kotlin.Lazy;
import kotlin.LazyKt;
import kotlin.Metadata;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
@Metadata(
mv = {1, 4, 3},
bv = {1, 0, 3},
k = 1,
d1 = {"\u0000$\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0002\b\t\n\u0002\u0010\u0002\n\u0002\b\u0002\n\u0002\u0010 \n\u0002\b\u0002\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u001c\u0010\r\u001a\u00020\u000e2\u0006\u0010\u000f\u001a\u00020\u00042\f\u0010\u0010\u001a\b\u0012\u0004\u0012\u00020\u00040\u0011J\n\u0010\u0012\u001a\u00020\u000e*\u00020\u0004R\u001b\u0010\u0003\u001a\u00020\u00048BX\u0082\u0084\u0002¢\u0006\f\n\u0004\b\u0007\u0010\b\u001a\u0004\b\u0005\u0010\u0006R\u001a\u0010\t\u001a\u00020\u0004X\u0086.¢\u0006\u000e\n\u0000\u001a\u0004\b\n\u0010\u0006\"\u0004\b\u000b\u0010\f¨\u0006\u0013"},
d2 = {"LDeadSimpleClass;", "", "()V", "lazyVariable", "", "getLazyVariable", "()Ljava/lang/String;", "lazyVariable$delegate", "Lkotlin/Lazy;", "leastImportantVariable", "getLeastImportantVariable", "setLeastImportantVariable", "(Ljava/lang/String;)V", "someFunction", "", "someAbnormalString", "aList", "", "doSomething", "untitled"}
)
public final class DeadSimpleClass {
public String leastImportantVariable;
private final Lazy lazyVariable$delegate;
@NotNull
public final String getLeastImportantVariable() {
String var10000 = this.leastImportantVariable;
if (var10000 == null) {
Intrinsics.throwUninitializedPropertyAccessException("leastImportantVariable");
}
return var10000;
}
public final void setLeastImportantVariable(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.leastImportantVariable = var1;
}
private final String getLazyVariable() {
Lazy var1 = this.lazyVariable$delegate;
Object var3 = null;
boolean var4 = false;
return (String)var1.getValue();
}
public final void someFunction(@NotNull String someAbnormalString, @NotNull List aList) {
Intrinsics.checkNotNullParameter(someAbnormalString, "someAbnormalString");
Intrinsics.checkNotNullParameter(aList, "aList");
}
public final void doSomething(@NotNull String $this$doSomething) {
Intrinsics.checkNotNullParameter($this$doSomething, "$this$doSomething");
}
public DeadSimpleClass() {
this.lazyVariable$delegate = LazyKt.lazy((Function0)null.INSTANCE);
}
}
You’ve noticed something by now?
Variables and method names are still here!
These names could expose your business logic to an outsider and this information may be used in a harfmul way for your app.
We can NOT fix the Instrinsics
way by adding a proguard rules.
1
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { *; }
-assumenosideeffects
: ProGuard removes calls to such methods, if it can determine that the return values aren’t used. Only applicable when optimizing.
DO NOT ADD THIS TO THE RULES!
Remove all Intrinsics calls will lead to errors, imagine we remove equals
call for data classes and wonder why our diff utils aren’t working for example.
Data class
way
1
data class DeadSimpleClass(val funky:String, val muse:String, val someOtherString:String)
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
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Metadata(
mv = {1, 4, 3},
bv = {1, 0, 3},
k = 1,
d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\u000e\n\u0002\b\f\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0002\b\u0086\b\u0018\u00002\u00020\u0001B\u001d\u0012\u0006\u0010\u0002\u001a\u00020\u0003\u0012\u0006\u0010\u0004\u001a\u00020\u0003\u0012\u0006\u0010\u0005\u001a\u00020\u0003¢\u0006\u0002\u0010\u0006J\t\u0010\u000b\u001a\u00020\u0003HÆ\u0003J\t\u0010\f\u001a\u00020\u0003HÆ\u0003J\t\u0010\r\u001a\u00020\u0003HÆ\u0003J'\u0010\u000e\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u00032\b\b\u0002\u0010\u0004\u001a\u00020\u00032\b\b\u0002\u0010\u0005\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\u000f\u001a\u00020\u00102\b\u0010\u0011\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\u0012\u001a\u00020\u0013HÖ\u0001J\t\u0010\u0014\u001a\u00020\u0003HÖ\u0001R\u0011\u0010\u0002\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\u0007\u0010\bR\u0011\u0010\u0004\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\t\u0010\bR\u0011\u0010\u0005\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\n\u0010\b¨\u0006\u0015"},
d2 = {"LDeadSimpleClass;", "", "funky", "", "muse", "someOtherString", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", "getFunky", "()Ljava/lang/String;", "getMuse", "getSomeOtherString", "component1", "component2", "component3", "copy", "equals", "", "other", "hashCode", "", "toString", "untitled"}
)
public final class DeadSimpleClass {
@NotNull
private final String funky;
@NotNull
private final String muse;
@NotNull
private final String someOtherString;
@NotNull
public final String getFunky() {
return this.funky;
}
@NotNull
public final String getMuse() {
return this.muse;
}
@NotNull
public final String getSomeOtherString() {
return this.someOtherString;
}
public DeadSimpleClass(@NotNull String funky, @NotNull String muse, @NotNull String someOtherString) {
Intrinsics.checkNotNullParameter(funky, "funky");
Intrinsics.checkNotNullParameter(muse, "muse");
Intrinsics.checkNotNullParameter(someOtherString, "someOtherString");
super();
this.funky = funky;
this.muse = muse;
this.someOtherString = someOtherString;
}
@NotNull
public final String component1() {
return this.funky;
}
@NotNull
public final String component2() {
return this.muse;
}
@NotNull
public final String component3() {
return this.someOtherString;
}
@NotNull
public final DeadSimpleClass copy(@NotNull String funky, @NotNull String muse, @NotNull String someOtherString) {
Intrinsics.checkNotNullParameter(funky, "funky");
Intrinsics.checkNotNullParameter(muse, "muse");
Intrinsics.checkNotNullParameter(someOtherString, "someOtherString");
return new DeadSimpleClass(funky, muse, someOtherString);
}
// $FF: synthetic method
public static DeadSimpleClass copy$default(DeadSimpleClass var0, String var1, String var2, String var3, int var4, Object var5) {
if ((var4 & 1) != 0) {
var1 = var0.funky;
}
if ((var4 & 2) != 0) {
var2 = var0.muse;
}
if ((var4 & 4) != 0) {
var3 = var0.someOtherString;
}
return var0.copy(var1, var2, var3);
}
@NotNull
public String toString() {
return "DeadSimpleClass(funky=" + this.funky + ", muse=" + this.muse + ", someOtherString=" + this.someOtherString + ")";
}
public int hashCode() {
String var10000 = this.funky;
int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
String var10001 = this.muse;
var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
var10001 = this.someOtherString;
return var1 + (var10001 != null ? var10001.hashCode() : 0);
}
public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof DeadSimpleClass) {
DeadSimpleClass var2 = (DeadSimpleClass)var1;
if (Intrinsics.areEqual(this.funky, var2.funky) && Intrinsics.areEqual(this.muse, var2.muse) && Intrinsics.areEqual(this.someOtherString, var2.someOtherString)) {
return true;
}
}
return false;
} else {
return true;
}
}
}
As you see, we still have the metadata and now we have the public String toString()
exposing us as well with the variable names and also the copy method.
We can’t beat String
s.
DO NOT PUT sensitive info in shared preferences, they’re just basically XML
that can be easily read, use Encrypted shared preferences.
Also do not use unencrypted SQL database like Room, use a cipher to make your database tables unreadable or some other way of encryption.
We can’t beat String
s
Every app connects to a service for some way of data source, it has to use an url/ip to fetch it from the source.
What seems to be the problem here? We’re exposing the URL
to everyone out there, since everyone can decompile the app, everyone can catch the url and has a clear idea where the source is coming from.
There are ways to make this pain go away by security through obscurity but you can’t really fix this issue, some of the noble things you may have tried is
- Adding the url to
build.gradle
thinking it’s hidden, nope it’s not, the calling site where you provide theurl
to the function for ex.Retrofit.Builder().setBaseUrl(BuildConfing.BASE_URL)
is gonna reveal your beloved and hiddenString
. - Scattering the URL as bytes and assembling it in one or from more places, again security through obscurity.
- Reading the “secure” and hidden string from
.so
C/C++ library and also in combination with #2,IDA pro
orGhidra
can just print all the strings from the.so
, so you’re not really secure.
The best way to beat this is to use https
for your communication with some token auth
for your endpoints.
Another helpful things are Certificate
Pinning, Public Key
Pinning & Hash
Pinning.
Certificate
Pinning - We store the certificate in our application and when the certificate expires, we would update our application with the new certificate. At runtime, we retrieve the server’s certificate in the callback, inside the callback, we compare the newly received certificate with the certificate shipped within our app, if it matches, we can trust the host else we will throw a SSL certificate error.
The downside to pinning a certificate Each time our server rotates a certificate, we need to update our application, imagine this happening frequently.
Public Key
Pinning - We generate a keypair, we put the private key in our server and the public key inside our app. We check the extracted public key with its copy of the public key, if it matches like in the certificate pinning, we can trust the host else we will throw a SSL certificate error. By using public key pinning, we can avoid frequent application updates as the public key can remain same for longer periods.
The downside to Public Key pinning
- Extracting the key is a process on it’s own
- The key is static and may not align or even violate with the key rotation policies.
Hash Pinning
- we pin the hash of thePublic Key
of our server’s certificate and match it with the hash of the certificate’s public key received during a network request. When we receive a certificate can hash it with a secure hashing algorithm (SHA-256
>Base64
for better readability) to provide anonymity to a certificate or public key.
The problem still exists, the strings need to be provided at the calling site for your network service provider, that makes them visible, doesn’t matter if you use Base64
or shifting bytes, a determined person can uncover them, just how far they’re willing to go?
But there are other problems that exist besides this one, these affect your app as well, every layer of security matters.
App modifiers
As you might or might not be aware, rooting
is a process of getting super-user privileges on Android
since it runs on a Linux
kernel, with that in mind you can use apps like Magisk
, Xposed
or the infamous Lucky patcher
to modify or hook into other apps, but with root you can even modify system settings and even tamper the kernel.
Android
allows you to see which apps are installed on your device, if you detect that Magisk
is installed you can just crash the app, you can try to run a sudo
command as well and if it happens then you can crash the app to disallow rooted
phones to use your app.
The problem arises when Magisk
hides itself, YES, it can hide it’s name, although that’s also detectable.
Android 11 “came with a way to improve security” and disables a way for you to read every installed application on your phone, unless you
Query specific packages - you must mention in the manifest which ones you’ll query
1
2
3
4
5
6
<manifest package="com.funkymuse.app">
<queries>
<package android:name="com.funkymuse.vigilante" />
<package android:name="com.funkymuse.aurora" />
</queries>
</manifest>
Use a permission
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
Query using intent filter
1
2
3
4
5
6
7
8
9
<manifest package="com.funkymuse.app">
<queries>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="image/png" />
</intent>
</queries>
...
</manifest>
and as you know each app must have a main intent action, we can safely abuse Query using intent filter and use
1
2
3
4
5
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
to get every app that’s installed on the phone without even declaring a permission.
Security checks that can matter to you
Some of these may help you, some of these may be outdated in the future.
Inspecting Throwable
errors
The best way to inspect if there’s something modified is by throwing an error and catching the trace then simply combing through the trace to check for bad guy’s hook.
This way you can see if there’s xposed
or something X mentioned inside the Throwable
’s stacktrace.
Detect third-party developer’s kernel
From a security standpoint Release-Keys
generally means the kernel is more secure, which is not always the case.
Test-Keys
means it was signed with a custom key generated by a third-party developer.
Usually custom roms have test-keys
if you want to strictly speaking disallow custom roms then you can detect it by
1
2
3
4
fun isTestKeyBuild(): Boolean {
val keys = Build.TAGS
return keys != null && keys.contains("test-keys")
}
Detect a debugger
A debugger can be easily attached to inspect behavior of an app
1
2
3
fun detectDebugger(): Boolean = Debug.isDebuggerConnected()
fun Context.isDebuggable(): Boolean = applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
Debug.threadCpuTimeNanos
indicates the amount of time that the current thread has been executing code
1
2
3
4
5
6
fun isDebuggerAttached(): Boolean {
val start = Debug.threadCpuTimeNanos()
for (i in 0..999999) continue
val stop = Debug.threadCpuTimeNanos()
return stop - start >= 10000000
}
You can also check the Settings.Global.WAIT_FOR_DEBUGGER
if it returns 1 then it will wait for debugger because it’s been enabled in the dev-options inside the Settings.
CRC check
You can use a file that you ship in the strings or something else that’s inside the app, then do a CRC
check to see if it’s modified.
For example you can calculate CRC
on .dex
files based on some layout resource, string etc… or coming from a file that you receive from a server and store offline like a time bomb.
Time check
This one can be done different ways, sometimes some app modifiers can set custom time to go back in time and do time based manipulations on a broken time verification based auth.
I would suggest using the Calendar
class and having your application checking the current date against your expiration date in your onResume
places.
Zygote tampering
Zygote is called only once when your app is forked on launch, which means that if it’s called more than once your app is modified.
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
/**
* If the count is more than 2 then the app is modified
* @return Int
*/
fun zygoteCallCount(): Int {
var zygoteInitCallCount = 0
try {
throw Exception("PiracyChecker")
} catch (e: Exception) {
for (stackTraceElement in e.stackTrace) {
if (stackTraceElement.className == "com.android.internal.os.ZygoteInit") {
zygoteInitCallCount++
}
if (stackTraceElement.className == "com.saurik.substrate.MS$2" && stackTraceElement.methodName == "invoked") {
zygoteInitCallCount++
}
if (stackTraceElement.className == "de.robv.android.xposed.XposedBridge" && stackTraceElement.methodName == "main") {
zygoteInitCallCount++
}
if (stackTraceElement.className == "de.robv.android.xposed.XposedBridge" && stackTraceElement.methodName == "handleHookedMethod") {
zygoteInitCallCount++
}
}
}
return zygoteInitCallCount
}
Is your app being run on an emulator
Emulators are flexible, they can be rooted and played with in many ways, enabling dumping data, behavior and what not for your app, you can disable your app to work on emulators, this is a good idea especially for banking apps.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun isEmulator(): Boolean {
return (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|| Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.PRODUCT.contains("sdk_google")
|| Build.PRODUCT.contains("google_sdk")
|| Build.PRODUCT.contains("sdk")
|| Build.PRODUCT.contains("sdk_x86")
|| Build.PRODUCT.contains("vbox86p")
|| Build.PRODUCT.contains("emulator")
|| Build.PRODUCT.contains("simulator")) || isAlsoEmulator()
}
Check if Developer option is enabled and/or check if USB debugging is enabled
Sometimes some of the requirements need of you to check that as well, especially some banking clients i’ve worked for had us perform this check
1
fun Context.isDevModeEnabled(): Boolean = Settings.Secure.getInt(contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0
1
fun Context.isADBEnabled() : Boolean = Settings.Secure.getInt(contentResolver, Settings.Secure.ADB_ENABLED, 0) == 1
Link to some of the security functions
Inspect SHA-XXX/MD5 signature (Verify your app’s signing certificates (signatures))
The app signatures will be broken if the .apk is altered in any way — unsigned apps cannot typically be installed.
You can check the signature of the app if you had the string of the signature inside your app as a String
, but strings aren’t a good solution, a server side validation can be additionally added for inspecting the signing signature, this is a great idea to check it every time the app is launched.
App ID check
If the app ID doesn’t match the one you’ve set, crash the app, this is a smart idea to have it done also via server side and have that call tied somewhere where the crucial functionality is a must.
Network security configuration
This one is awesomely explained by Google.
Unlocked bootloaders and SafetyNet attestations
Another great example explained by Google.
Server side country validation
If you run a server to validate things, you can check if your user changed the country on the device, do note that this has also be checked if the user is using VPN
as an internet connection.
Server side device ID validation
If you have the Device ID of the user’s device, you can always check if it’s the same one the user registered with and/or you’ve later on stored on the server, this is also nice since the ID is unique to a device, like IMEI.
Do note that this requires a permission
Verify who’s the installer of your app
If you haven’t distributed your application via any other store than Play store, then you have to check who’s the one that installed your app, if that doesn’t match with the package id, crash it.
Verify Google Play Licensing (LVL)
Google Play offers a licensing service that lets you enforce licensing policies for applications that you publish on Google Play. With Google Play Licensing, your application can query Google Play to obtain the licensing status for the current user.
Check out the Google Developers page.
Check if disk is encrypted
Devices running Android 7.0–9 support full-disk encryption. New devices running Android 10 and higher must use file-based encryption.
- For new devices running Android 10 and higher, file-based encryption is required.
- Devices running Android 9 and higher can use adoptable storage and file-based encryption.
- For devices running Android 7.0–8.1, file-based encryption can’t be used together with adoptable storage. If file-based encryption is enabled on these devices, new storage media (such as an SD card) must be used as traditional storage.
1
val isDiskEncrypted = DeviceUtils.getSystemProperty("ro.crypto.state").equals("encrypted", true)
Project Adiantum
Adiantum is an encryption method designed for devices running Android 9 and higher whose CPUs lack AES instructions.
For devices lacking these AES CPU instructions, you can check if Adiantum is enabled for devices
You can check if a CPU has AES instructions by running a shell command in your code
1
grep aes /proc/cpuinfo
If there’s an output that means you have AES and you can ignore checking for Adiantum
, otherwise you can run the following commands
For Android 11 and higher:
1
getSystemProperty("ro.crypto.volume.options").equals("adiantum", true) && getSystemProperty("ro.crypto.volume.metadata").equals("adiantum", true)
For Android 9 and 10:
1
2
getSystemProperty("ro.crypto.volume.contents_mode").equals("adiantum", true) && getSystemProperty("ro.crypto.volume.filenames_mode").equals("adiantum", true)
&& getSystemProperty("ro.crypto.fde_algorithm").equals("adiantum", true)
Automatic backups
Android allows for backing up data content up to 25MBs, you can opt out by disabling that feature inside your AndroidManifest.xml
, this is a discussion with your security analyst whether to allow this feature or not, I always disable it due to the fact that I don’t want to rely on 3rd party system to do this for me.
1
2
3
4
5
6
<manifest ... >
...
<application android:allowBackup="false" ... >
...
</application>
</manifest>
Min API 23
It’s 2021 already and this shouldn’t be a debate, but Android < 23 API hasn’t a pretty good security and everything that’s backported is just done with workarounds, some may argue against, some may agree, but supporting anything below 23 is just a waste of resources.
Do not export your services, activities unless there’s an use-case
Service that is unprotected by permissions and which sends sensitive information when started by an arbitrary application
1
2
3
4
<activity android:exported="false" ... >
<intent-filter > ... </intent-filter>
...
</activity>
same goes for Service
.
Courtesy of CMU.
Limit a Webview
Do not allow WebView
to access sensitive local resource/s through file scheme, that is being enabled by the JavaScript
through the settings of a WebView
, all in all using WebView
in a secure application is probably not a great idea or in the end, as you see fit?
Do not cache sensitive information
Information that is cached may become accessible to other applications, and certainly becomes accessible if the device is found or stolen by a third party.
Check that a calling app has appropriate permissions before responding
If an app is using a granted permission to respond to a calling app then it must check that the calling app has that permission as well. Otherwise, the responding app may be granting privileges to the calling app that it should not have. (This is sometimes called the “confused deputy” problem.)
The methods Context.checkCallingPermission()
and Context.enforceCallingPermission()
can be used to ensure that the calling app has the correct permissions.
Always canonicalize a URL received by a content provider
By using the ContentProvider.openFile()
method, you can provide a facility for another application to access your application data (file). Depending on the implementation of ContentProvider, use of the method can lead to a directory traversal vulnerability.
Therefore, when exchanging a file through a content provider, the path should be canonicalized before it is used.
Discourage Face ID
If someone looks similar as the person who did the verification, then you already know the answer here, make sure that when you check biometrics settings, you discourage logging in with a Face ID.
Test your app’s security
A great tool to test your app’s security is MobSF
It does static and dynamic analysis.
Teach your user
Always assume that the user of the application is dumb.
You need to create a guide explaining how the user should take precaution and do that thoroughly, not everyone is a power user or knows the system pretty well.
It usually comes in form of good UI/UX.
Closing notes
Designing a secure Android
application is hard, or maybe impossible, there’s always a risk, the more paranoid you are about security, the more it matters.
Working on banking apps and with clients that are really paranoid about this kind of thing, enabled me to share some of these practices, there will be more in the future but some are already known such as using strong encryption etc…
Is the price you pay for making a “secure” and “untamperable” application your time and effort?
So be it.
How long are you willing to go?
or does the budget allow this?
Thanks for reading and stay tuned for more articles.