Mobile Security via Flutter — Ep.2 Strong Device/ Strong Pin
In this episode, we will strengthen your mobile app even further. “But how?” You may wonder. Let’s compare it to building a house. To ensure that it’s equipped with the highest level of security, you would probably need to install all the alarms and cameras around the house. But none of that would matter if you happen to leave the key at the door! With security, we need to consider every possibility that a bad hacker might choose to attack, so it’s not just one solution, and boom, everything is done. We have to ensure both outsides and inside your app are secure.
Last episode, we talked about SSL. You can think of it like a high fence around the house. Although it’s harder to climb over, it’s still manageable. Therefore, in this episode, we will make sure no one accidentally leaves the key at the door.
In iOS/Android, there’s already a native security API to protect their applications from outsiders, so we will use that one to implement with Flutter. Let’s go through what’s necessary and what’s nice to have.
Mandatory
1. Secure Data Storage
There are essentially two things that you need to keep in mind:
1.1 Don’t save any secure information in the app such as first name, last name, email, username, password, citizen ID, or anything that enable a hacker to trace back and find out who the user is. IF you REALLY NEED to save it, like a token or anything that you want to use to improve user experience, make sure you save it in a secure data storage that both OS platforms provide. For this, I used this lib.
For iOS, they use Keychain which won’t be deleted even when you remove the app, while for Android, they use KeyStore to store the key for decrypting saved data.
1.2 Extra layer of security always encrypted secure information even though iOS/Android already have secure storage. That’s because in the future, there might be a tool that will allow hackers to break the encryption on the device. Since we’re on the topic of encryption, we need to talk about the key as well. It should be dynamic and unique for each user, so we decide to go with the user’s own password because we can be certain that only the app owner knows how to decrypt it with the right password and access all the saved data.
However, that would mean the password is secretly saved on the device. If a hacker brute forces a password to successfully decrypt it, they will know that’s the right password, which could even be more dangerous. They would have all the time they need to decrypt since it’s on-device, so our solution is to use another layer of protection. We allow a hacker to decrypt by using any key, so they don’t know which is the right one to call our server with. If they type an incorrect password 3 times, the backend will automatically lock them out. This will solve this problem. The solution for decryption by using any key won’t be provided here.
2. Turn Off Log in Production
Developers need a log to see if their code is working correctly. That’s fine for non-production but for production build, you need to turn it off to prevent anyone from seeing it. To do so, I override the debugPrint function by calling the following code in main.dart
debugPrint = (String message, {int wrapWidth}) {};
This means if we use debugPrint, it won’t print anything. I divide this into main_dev.dart and main_prod.dart and put this function only in main_prod.dart so that we won’t see any log on the production. As for non-prod build, you can just leave it like that. There’s no need to add anything. Why must we turn off the log? That’s because we don’t want anyone to see what app is working behind the scene. Don’t give hackers any clues to what their next action should be.
Use only debugPrint from now on instead of print
3. Don’t Support Old OS Versions
We have to set this one in Native instead of Flutter. I’m not really worried about iOS since iOS users tend to update OS frequently according to the adoption rate in this article.
In just 3 months after iOS 14 came out, 81% of all devices have updated their OS. Bear in mind that security is like a cat and mouse where you need to run away from potential threats all the time. We shouldn’t try to support OS versions that are too old due to their security vulnerability. In my opinion, it’s okay to support only the latest version and maybe a couple before that. For example, right now we use iOS 16, so we should support only iOS 14, 15 and 16. This will allow 98% of users to use your app. We can set a minimum target in Xcode to control it.
Meanwhile, Android is open-source, meaning Google has no control over it. As such, the adoption rate is very low. Just watch out for Android announcements to see which Android OS is becoming obsolete and have no more security patch. You can look into the website below to check the end of life forAndroid
https://endoflife.date/android
Right now they still support Android 10.0, so we might have to target minSdkVersion in build.gradle to be 29.
In short, iOS = 14 and above / Android = 29 and above
4. Ask Permission Only for What You Need
A basic thing that all developers should know is always ask for permission when needed. Don’t ask permission from the start of the app, and ask only what you need. For instance, some apps might ask to access your GPS location even though none of the features require it. You should never do that because of two things. First, you are collecting unnecessary data which is the user’s private data. Second, hackers might be able to access this data as well if it’s not correctly implemented. Therefore, just to be safe, ask only what you need and when you need it. You don’t want the user experience to be 10 popups when they first open the app. Users will take a leave and never return.
5. Jailbreak Detection
For Flutter, we are using native jailbreak detection libs like Rootbeer for Android and DTTJailbreakDetection for iOS. Both of these are famous ones. For Flutter, I use this lib.
Although it might not strong enough for experienced hackers, at least we have something to protect our app from inexperienced ones. They have several solutions in the commercial SDK for protecting this one, but they’re not mandatory. Unless your app requires a high level of security, Flutter_jailbreak_detection should be sufficient.
6. Biometrics With Cryptography
Many apps implement biometrics authentication for a better user experience nowadays. However, most of them just place their trust in iOS/Android, which I don’t really recommend. For one, we might see this code in Swift that even Apple recommends.
let reason = “Log in to your account”context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason ) { success, error in
if success {
// Login Succeed, do something next } else {
// Failed, somebody else!! }
}
Everybody will trust their devices. Why not, right? Well, there’s actually a tool called Frida script.
After browsing, you will see how easy it is to bypass biometric authentication with this tool.
The lesson here is DON’T just trust boolean from devices. Here’s a nice how-to tutorial from Google and somebody else already implemented it in the Android version.
https://developer.android.com/training/sign-in/biometric-auth#crypto
It’s a combination of biometrics and crypto to double-check. You can use the same concept for Flutter. That being said, if your app doesn’t have any financial or online payment feature, you can skip this part.
7. Don’t Send the Password in Plain Text
Some people might say HTTPS is secure enough, why do we have to care about sending passwords in plain text? In reality, hackers can use MITM (Man-in-the-Middle Attack) to easily retrieve your information in plain text. That’s why we should hash passwords before leaving the mobile app, just in case any hackers eavesdrop, and the best way is to hash with salt.
hash(password + dynamic salt)
Salt should be changed very frequently in a short period. We can do something basic like this.
hash(password + userID)
Even though user ID is unique and dynamic, it’s not always changing. Still, it is acceptable for basic security. If you want to go full security, however, you have to find something else that changes all the time and use slow hashing algorithms like Bcrypt instead of SHA256 because they require special hardware to crack it.
Optional but nice to have
This part is for financial or banking apps that require additional security.
8. Anti-Debugging
For Android, we separate into multiple functions
8.1 Turn off Debuggable Add this flag to build.gradle in the buildTypes section. Have release set to false and debug set to true, or you could set both to false if you want.
buildTypes {
release {
debuggable false
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
debuggable true
signingConfig signingConfigs.release
}
}
8.2 Prevent debugger
// Open ADB Debugging
if (Settings.Secure.getInt(this.applicationContext.contentResolver, Settings.Global.ADB_ENABLED, 0) == 1) {
}
// Check by using `adb shell getprop ro.crypto.type`
if ((applicationContext.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager).storageEncryptionStatus == DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED) {
}
// flag debuggable in gradle is true
if ((0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE || BuildConfig.DEBUG)) {
}
// Use Debugger in Android Studio to connect for getting log
if (Debug.isDebuggerConnected() || Debug.waitingForDebugger()) {
}
val mainHandler = Handler(Looper.getMainLooper())
mainHandler.post(object : Runnable {
override fun run() {
var tracer = false
try {
tracer = hasTracerPid();
} catch (exception: Exception) {
exception.printStackTrace()
}
if (tracer) {
return;
}
mainHandler.postDelayed(this, 5000)
}
})
and hasTracerPid function come from this
private val tracerpid = "TracerPid"
@Throws(IOException::class)
fun hasTracerPid(): Boolean {
var reader: BufferedReader? = null
try {
reader = BufferedReader(InputStreamReader(FileInputStream("/proc/self/status")), 1000)
var line: String
while (reader.readLine().also { line = it } != null) {
if (line.length > tracerpid.length) {
if (line.substring(0, tracerpid.length).equals(tracerpid, ignoreCase = true)) {
if (Integer.decode(line.substring(tracerpid.length + 1).trim { it <= ' ' }) > 0) {
return true
}
break
}
}
}
} catch (exception: java.lang.Exception) {
exception.printStackTrace()
} finally {
reader?.close()
}
return false
}
you can copy the code above and paste it to MainActivity.kt . If it says true, you can kick the user out or do whatever you want. Here, allow me to explain.
Open ADB Debugging to prevent the user from opening ADB mode. Though it’s not that necessary since most Android developers always have it on for when they need to test the app. It’s up to you whether to Toast message to warn the user or comment it out.
ENCRYPTION_STATUS_UNSUPPORTED to check if the device supports storage encryption. It should turn on by default in Android. If it’s turned off, then something is wrong with their device because users, in general, can’t do it themselves. To prevent said risks, we just don’t allow anyone to use them. Recently, I have this problem with Android 13 MIUI 14 from Xiaomi, it’s shown as unsupported status. So this flag will be used at your own risk
ApplicationInfo.FLAG_DEBUGGABLE to check if the flag debug in Gradle that we add in 8.1 is true. If not, don’t allow users to use it. Same as the previous one, this is something users can’t change except someone reverse engineers your app and packs it as APK again.
Debug.isDebuggerConnected() to check if your app is connected to Android Studio debugger. If yes, don’t allow users to use it.
As for iOS, I use this open source
You can add all debuggers they’ve recommended to your code, like Android. For example
let amIDebugged: Bool = IOSSecuritySuite.amIDebugged()
9. Check If the Device Has Secure Access
Some users might decide not to have a pin, finger scanning, or face-scanning on their device. If anyone were to steal their device, they can just unlock it and that’s done. Having this type of security measure is like the very first gate of security.
In Android, I have used Flutter Channel to call back to Flutter in configure FlutterEngine function and let Flutter decide what to do with this user. Here’s the code for Android to check if the device has a passcode.
private val CHANNEL = "com.kbtg.flutter"
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getDeviceHasPasscode") {
result.success((getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isDeviceSecure)
} else {
result.notImplemented()
}
}
Call the following in Flutter.
try { final hasPasscode =await Storage.platform.invokeMethod('getDeviceHasPasscode'); if (!hasPasscode) { Toast.show( "No pin, DANGER DANGER", context, duration: 5, gravity: Toast.BOTTOM, ); }} on PlatformException catch (e) { debugPrint("==== Failed to scan security '${e.message}' ====");}
If hasPasscode is false, we just toast the message to warn the user that you don’t have a pin activated on your device.
10. Turn Off 3rd Party Keyboard if Needed
Some 3rd party keyboards can be malicious. You never know if they have implemented anything that secretly sends the password you type to their server. For Android, there’s no easy way to prevent this since all keyboards count as 3rd party keyboards, even Android’s own keyboard. A solution for this is to implement a secure keyboard ourselves by using a layout with the alphabet and number to build one from scratch.
However, for iOS, we can disable it with the function below to restrict usage to the native keyboard solely.
override func application(_ application: UIApplication, shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplicationExtensionPointIdentifier) -> Bool { return extensionPointIdentifier != .keyboard}
11. Check Code Integrity
Both IPA and APK can be decompiled to change some code inside and rebuild for distribution again. Code that the hackers changes might be something like you connect to the same server but every information will also push to the hacker’s server. For IPA, I am not that worried because to install apps from outside of the App Store is pretty complicated. You have to install the certificate and accept some conditions or jailbreak your device first in order to install it. As for Android, it’s so easy to fake an app since it’s more open and allows any user to install external APK within just 1–2 clicks.
This is where code integrity comes in. You can calculate the checksum of your app and check before you open the app every time whether it’s still the same as the one you deploy to the App Store. The concept sounds simple but it’s hard to implement. Luckily, there is a commercial SDK to solve this, so we don’t need to do it ourselves.
12. Code Obfuscation
For all the business logic and security logic that we implement or save into the app, we need to make sure that no one can decompile and see the source code. If hackers can see it, they will know which logic we use to encrypt data, or where we store secure information. They can mimic that logic and send it to their server instead of using our app. For code obfuscation, there are commercial SDK available that you can use to strengthen your application.
You might have noticed that I’m mentioning commercial SDK a lot. Some might be wondering, “if that’s the case, what’s the point of this article? I come to this article to look for a way to implement it in the app, not just go to another link.”
No one is good at everything. Using commercial SDK is like having a team of professionals focusing on security work for you. You alone can’t implement and close all the security exploits, so better leave it to the experts that know what they’re doing, and you do what you’re best at which is developing the app.
Now that your app’s outer shell is stronger and your user is not leaving their key at the door anymore, let’s make sure that the key to the house is not easy to duplicate. I’m talking about password and pin
Now We Will Work With A Stronger Pin
Pin or password is a means to confirm that you’re the real owner of the account. We choose to adopt a pin for the app instead of a password for two reasons.
1. Better User Experience
Even though the password is more secure, considering how A-Z, 0–9, and special characters can make up a billion possible combinations, it’s too hard to type in using the little keyboard on the devices. You could mistype tons of time before getting into your app. That’s why we adopt a 6 digit pin to use instead. While you can only create 10,000 possible pins with 4 digits, 6 digits give you 1,000,000 possibilities, which is 100 times harder.
People can type a pin under 2–3 seconds but a password might take them up to 10–15 seconds depending on how hard it is.
2. Easy to Remember
Yes, we want to make the password hard for hackers to crack, but we also want our users to be able to remember it without any effort. Since pin is the basic security to access device for both iOS/Android, it should post no challenge because users already get used to it
For the pin, we don’t want to make it too easy. “What does ‘too easy’ even mean?” You ask. Well, let’s make it more concrete.
There is no standard for pins, so I have come up with an idea from my research on the Internet. See the link below.
From the above pattern, we come up with the following rules
- Don’t allow a serial number e.g. 123456, 234567, 345678 — including the reversed ones e.g. 654321, 543210
- Don’t allow “same row pin” e.g. 123123, 456456, 789789
- Allow only three or more unique numbers e.g. 122112 is not allowed but 123321 is okay (even though this might contradict the statistics above.) 155115, 133133, 166661 are not allowed since there are only two different numbers used in the pin.
- Some people even suggest banning birthday pins, like if your birthday is 8 June 1986, you can’t use 080686 for the pin to prevent hackers from using this information to breach in. However, I don’t do that simply because I don’t save user’s birthdays on the system.
We don’t want to come up with too many rules. Otherwise, you will cut out all the pin combinations and worse, make it easier for the hacker to brute force it. We can come up with more rules, but what’s the point if 1,000,000 possibilities come down to merely 100–200 pins?
With the method above, we still have around 60,000 possibilities for the user to use as their pin. Here’s the sample code to implement it.
static bool isPinComplexity(String pin) { const notAllowListPin = [ "123123", "456456", "789789", "012345", "123456", "234567", "345678", "456789", "567890", "098765", "987654", "876543", "765432", "654321", "543210" ]; final pinSet = new Set.from(pin.split("")); final uniqueCharacter = pinSet.length > 2; return uniqueCharacter && !notAllowListPin.contains(pin);}
You can use isPinComplexity to check the pin. If it returns true, we allow the user to add it. Don’t forget to add all the possible hashes to the backend as well. We implement on the front end just for a better user experience so that the user won’t have to call the network and get rejected from the server and on the server to make sure if a hacker tries to bypass the pin, the server will not allow it.
You can add more conditions in notAllowListPin depending on your requirement, but I am good for now.
So for all these solutions that we implement, we’re making sure that our app is equipped with another layer of protection. Now that the outer shell of the app is protected, next episode I will show you how to protect the app from the inside. 💪
Updated 9 Jan 2023: Add iOS anti-debugger lib, and more in Android anti-debugger
Want to read more stories like this? Or catch up with the latest trends in the technology world? Be sure to check out our website for more at www.kbtg.tech