18.
App Hardening
Written by Antonio Roa-Valverde
As network communications and OSs become more secure, hackers have shifted their focus from basic eavesdropping to attacking devices and apps. In the previous chapters, you’ve secured your data in transit and at rest. Now, to protect your app from these additional kinds of attacks, you need to understand and use app hardening effectively.
From minimizing pointer use to null safety and type checks, Kotlin is a great language for secure development. So much so that it’s tempting to forget about secure coding altogether. However, even Kotlin has vulnerabilities that you need to protect your app against.
In this chapter, you’ll learn how to:
- Avoid code vulnerabilities.
- Validate input and sanitize output.
- Perform integrity checking.
Right now, the app has an overflow of code vulnerabilities which you’ll eventually fix!
Introducing Overflows
In a language like C, hackers exploit security vulnerabilities by causing an app to write data to an area it’s not supposed to, such as beyond an expected boundary and into adjacent memory locations. That’s called an overflow, and it can overwrite important data.
In certain environments, this can be an area that contains code the device executes, giving attackers a way to maliciously change a program. Bug bounty hunters refer to it as “gaining arbitrary code execution”. It’s a very important preoccupation for them.
One example of an overflow in Kotlin is when a recursive function ends up in an infinite loop. Because the size of the stack runs out, you’ll get a StackOverflow exception.
Note: You can read more about stacks at https://www.programmerinterview.com/data-structures/difference-between-stack-and-heap/.
Kotlin provides safety modifiers, such as tailrec, which help avoid the chances of a stack overflow by adding rules and throwing an error if you break them. The rules are:
- The last operation of the function can only call itself.
- There cannot be more code after a recursive call.
- Use within
try/catch/finallyblocks is prohibited.
These rules are especially helpful when your implementation changes later and you forget to check that it’s still safe.
To implement this, open Timing.kt and add tailrec, right after the private modifier in the method definition of factorial. Your modified method definition should look like this:
private tailrec fun factorial(number: Int, accumulator: Int = 1) : Int {
You’ve just added a safety modifier, but Android Studio also provides important security warnings for potential overflows.
Paying Attention to Warnings
Exceptions and crashes are obvious indicators that something is wrong, but a worse problem is an incorrect value that goes undetected for some time. This is what happens with an integer overflow. Kotlin doesn’t throw an exception for a signed integer overflow. Instead, the app continues with the wrong values!
The good news is that Android Studio detects most integer overflows at compile time. To see how this looks, open ReportDetailFragment.kt and look at the warning by hovering over REPORT_APP_ID * REPORT_PROVIDER_ID on the line right under the //Add Signature comment.
Regular numbers defined like this are integers, but multiplying them exceeded the maximum size of the container. That’s why it’s a best security practice to treat warnings as errors.
At the top of the file, replace REPORT_APP_ID and REPORT_PROVIDER_ID with the following:
private const val REPORT_APP_ID = 46341L
private const val REPORT_PROVIDER_ID = 46341L
You’ve now added L to the end of the numbers, which defines them as Long and fixes the warning. That’s because Long is a number that can hold a much larger value.
Note: You can read more about
Longhere: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-long/index.html.
Another vulnerable area is when your app interacts with languages that use pointers. Pointers allow you to access raw memory locations, making it easier to read and write to the wrong area.
Kotlin is much safer than many languages because it mostly does away with pointers, but it still allows you to interface with C using CPointer and COpaquePointer.
Note: You can read more about interoperating with C in Kotlin on the official website: https://kotlinlang.org/docs/reference/native/c_interop.html.
If you’ll be working with NDK, it’s extremely important to do bounds checking on the input to make sure it’s within range. Avoid unsafe casts using .reinterpret() or .toLong() and .toCPointer().
Because attackers can manipulate data in your app, another possible place for vulnerabilities is when your app passes data to a server for further processing. To make sure this is secure, you should sanitize all data that leaves your app.
Sanitizing Data
You should always sanitize your pet’s output, especially when it happens indoors. If your app sends the data in text fields to a server, then sanitizing it reduces the potential for an attack. The most basic technique is to limit the amount of input that you can enter into your fields. This reduces the likelihood that a specific code snippet or payload can get through.
To do this, open activity_main.xml and make sure you’re in the XML editing view. Add the following to the first EditText element, which has the ID login_email:
android:maxLength="254"
This states that mail addresses can have a maximum of 254 characters. Now, open fragment_report_detail.xml and add the following to the EditText field, which has the ID details_edtxtview:
android:maxLength="512"
You now made the maximum character limit 512 for the report. Finally, add this to the next EditText, with the ID category_edtxtview:
android:maxLength="32"
This sets the maximum category length to 32 characters.
Try out your changes by building and running the app and entering a large amount of text into the category field.
Next, you’ll want to remove characters that are dangerous for the language that your server uses. This prevents command injection attacks — when you pass data to an environment that should store it, but instead executes the data as commands. The app’s underlying datastore uses an SQLite database, while the report server is SQL.
Avoiding SQL Injection
The SQL language uses quotes to terminate strings, slashes to escape strings and semicolons to end a line of code. Attackers use this to terminate the string early and then add commands.
For example, you could bypass a login by entering ') OR 1=1 OR (password LIKE '* into the text field. That code translates to “where password is like anything”, which bypasses the authentication altogether!
One solution is to escape, encode or add your double quotes in code. That way, the server sees quotes from the user as part of the input string instead of a terminating character. Another way is to strip out those characters — which is what you’re going to do next.
Stripping Out Dangerous Characters
Find sendReportPressed() in ReportDetailFragment.kt, then add the following below the line that reads //TODO: Sanitize string here:
reportString = reportString.replace("\\", "")
.replace(";", "").replace("%", "")
.replace("\"", "").replace("\'", "")
This strips the vulnerable characters from the string.
Test that it works by building and debugging the app, then entering some illegal characters in the report field. Set a breakpoint after the line you just added and send the report. Notice reportString removes those characters.
Note: If you’re also developing the server-side code, clauses such as
LIKEandCONTAINSallow wild cards that you should avoid. Doing this prevents attackers from getting a list of accounts when they entera*for the account name, for example. If you change theLIKEclause to==, the string has to literally matcha*.
More Sanitization Tips
Only you will know what the expected input and output should be, given the design requirements, but here are a few more points about sanitization:
-
Dots and slashes may be harmful if they’re passed to file management code. A directory traversal attack is when a user enters
../, for example. This lets them view the parent directory of the path instead of the intended sub-directory. -
If you’re interfacing with C, one special character is the
NULLterminating byte, which pointers to C strings require. This lets attackers manipulate the string by introducing aNULLbyte. The attacker might want to terminate the string early if there was a flag such as needs_auth=1 removing it and allowing access without authorisation. -
HTML, XML and JSON strings have their own special characters. Make sure to encode special characters from the user input so attackers can’t instruct the interpreter: < must become <. > should be >. & should become &. Inside attribute values, any ” or ’ need to become " and &apos, respectively.
-
You can find more information about URL encoding at https://developer.android.com/reference/kotlin/java/net/URLEncoder and more about escaping at https://developer.android.com/guide/topics/resources/string-resource#FormattingAndStyling.
Just as it’s important to sanitize data before sending it out, you shouldn’t blindly trust the input your app receives, either. The best practice is to validate all input to your app.
Validating Input
Subconsciously, pets are constantly validating their environment for danger, sometimes in better ways than humans. While we may not be as equipped to validate danger in the wild, at least we can add validation to our apps.
As well as removing special characters for the platform you’re connecting with, you should only allow the correct format for the type of input required. Right now, users can enter anything into the email field.
Validating Emails
To fix this, navigate to DataValidator and add a regular expression definition just after the companion object { line:
private const val EMAIL_REGEX = "^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,4}$"
That makes sure emails have a format of test@example.com. Now, add the following right after that line:
fun isValidEmailString(emailString: String): Boolean {
return emailString.isNotEmpty() && Pattern.compile(EMAIL_REGEX).matcher(emailString).matches()
}
This method verifies an email address via that regular expression. Finally, go back to MainActivity and import your new method:
import com.realworld.android.petsave.core.utils.DataValidator.Companion.isValidEmailString
Then find // TODO: Replace this with email check inside loginPressed(). Replace the line below it with the following:
var success = false
val email = login_email.text.toString()
if (isSignedUp || isValidEmailString(email)) {
success = true
} else {
toast("Please enter a valid email.")
}
Here, you perform email validation before the user can sign up. Test it by deleting the app to remove the previous login, then building and running it again. Enter an invalid email such as my.invalid.email and press SIGN UP. You’ll see that the email address fails:
Designing by Contract
If you’re expecting specific kinds of characters, such as numbers, you should check for this. Some methods that are helpful include:
Char.isLetterOrDigit(): BooleanChar.isLetter(): BooleanChar.isDigit(): Boolean-
String‘slengthmethod
For example, if your server expects a string of 32 characters or less, make sure that the interface will only return up to and including 32 characters.
This is a good programming practice called design by contract, where the inputs and outputs of your methods satisfy a contract that defines specific interface expectations.
You’ve hardened the text inputs of your app, but it’s a good idea to make an inventory of all input to your app. The app allows the user to upload a photo. Right now, you could attach a photo containing malware! You’ll fix that now.
Validating Photos
Add the following to the end of the companion object in DataValidator:
fun isValidJPEGAtPath(pathString: String?): Boolean {
var randomAccessFile: RandomAccessFile? = null
try {
randomAccessFile = RandomAccessFile(pathString, "r")
val length = randomAccessFile.length()
if (length < 10L) {
return false
}
val start = ByteArray(2)
randomAccessFile.readFully(start)
randomAccessFile.seek(length - 2)
val end = ByteArray(2)
randomAccessFile.readFully(end)
return start[0].toInt() == -1 && start[1].toInt() == -40 &&
end[0].toInt() == -1 && end[1].toInt() == -39
} finally {
randomAccessFile?.close()
}
}
For the JPEG format, the first two bytes and the last two bytes of a valid image are always FF D8 and FF D9. This method checks for that.
To implement it, navigate to ReportDetailFragment and import the method you just added:
import com.realworld.android.petsave.core.utils.DataValidator.Companion.isValidJPEGAtPath
Then find showFilename() and replace its complete implementation with the following:
val isValid = isValidJPEGAtPath(decodableImageString)
if (isValid) {
//get filename
val fileNameColumn = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
val nameCursor = activity?.contentResolver?.query(selectedImage, fileNameColumn,
null, null, null)
nameCursor?.moveToFirst()
val nameIndex = nameCursor?.getColumnIndex(fileNameColumn[0])
var filename = ""
nameIndex?.let {
filename = nameCursor.getString(it)
}
nameCursor?.close()
//update UI with filename
upload_status_textview?.text = filename
} else {
val toast = Toast.makeText(context, "Please choose a JPEG image", Toast
.LENGTH_LONG)
toast.show()
}
The first line calls the photo check when the user imports a photo, validating if it’s a valid JPEG image file.
More About Validating Input
Here are a few more tips for validating input:
- Be careful when displaying an error alert that shows a message directly from the server. Error messages could disclose private debugging or security-related information. The solution is to have the server send an error code that the app looks up to show a predefined message.
- An overlooked area for input is inside deep link or URL handlers. Make sure input data fits expectations and that it’s not used directly. You shouldn’t allow a user to enter info that manipulates your logic. For example, instead of letting the user choose which screen in a stack to navigate to by index, allow only specific screens using an opaque identifier, such as t=qs91jz5urq.
- Check out Android’s input validation tips: https://developer.android.com/training/articles/security-tips#InputValidation.
Another vulnerability that developers often overlook is serialized and archived data from storage. You’ll address that next with null and type checks.
Nullability and Safety Checks
Does nothing exist? Or does it exist only in reference to something tangible? How can you divide several things among no things? These are the concepts that our pets surely contemplate while we’re away working. Okay, well, maybe not since nothing is a concept tied to language, and in the Kotlin language, the closest relative is null. To write solid code, it’s important to understand the concept of null.
Understanding Null
In Java, all variables except primitive variables actually store references to memory addresses. Because they’re references, you can set the variables to null.
When the system expects a valid reference but receives null instead, it throws a NullPointerException, or NPE for short. If you haven’t implemented exception handling, the app questions the nature of reality, and then crashes.
Kotlin aims to be a safer language. As you know, variables are non-null references — you can’t set them to null. However, you can make variables nullable by adding ? to the end of the variable. So Kotlin attempts to eliminate NPEs but not do away with them entirely.
The best practice is to start with non-null variables at the narrowest possible scope. You should only change the variable to nullable or move it to a broader scope if absolutely necessary.
NPEs can cause security vulnerabilities, especially when they happen in security-related code or processes. If attackers can trigger an NPE, they might be able to use the resulting exception to bypass security logic or cause the app to reveal debugging information that’s valuable in planning attacks. NPEs are also security vulnerabilities if sensitive files aren’t cleaned up before the process terminates.
Checking Stored Data
Open UserRepository.kt and look at createDataSource. Notice the code assumes that the stored data exists and is uncorrupted. You’ll change that now.
Replace the declaration of users inside createDataSource with the following:
val users = try { serializer.read(Users::class.java, inputStream) } catch (e: Exception) {null}
The code above catches exceptions when the data is read into User. To prevent overuse, Kotlin discourages exceptions in favor of better flow control. For the most part, a better approach is to use safety checks because they make methods resilient to errors. The method contains the failure instead of propagating it outside the method, which can become an app-wide failure.
Replace everything after the try/catch you just added with this:
users?.list?.let { // 1
val userList = ArrayList(it) as? ArrayList // 2
if (userList is ArrayList<User>) { // 3
val firstUser = userList.first() as? User
if (firstUser is User) { // 4
firstUser.password = Base64.encodeToString(password, Base64.NO_WRAP)
val fileOutputStream = FileOutputStream(outFile)
val objectOutputStream = ObjectOutputStream(fileOutputStream)
objectOutputStream.writeObject(userList)
// 5
objectOutputStream.close()
fileOutputStream.close()
}
}
}
inputStream.close()
Here, you:
- Added null checks for the user list.
- Used a safe cast to make sure the instance type is what you expected.
- Made sure the
ArrayListcontainsUserobjects. - Added an extra check to ensure
firstUseris really aUserobject. - Made sure to clean up resources after use.
Adding sanity checks around your code is called Defensive Programming — the process of making sure your app still functions under unexpected conditions.
Note that in step two you removed !!. That’s Kotlin’s non-null assertion operator that force-casts a nullable variable to a non-null one. But if the variable is null, you’ll get an NPE! That’s why in most cases, !! is dangerous to use. As the complexity of a program increases, the edge cases that you originally thought would never happen, start to happen. In a way, the double exclamation mark is Kotlin yelling at you not to use it often!! :]
If you use !!, declare and initialize the !! variable right before you use it to reduce its scope. Use each variable for exactly one purpose. That way there’s less chance that other parts of the code will set that variable to null.
More Tips for Using Nullability and Safety Checks
Here are a few other best practices to keep in mind:
-
Avoid unclear optionals. Write clear and consistent class interfaces as opposed to ones that require a magic combination of parameters.
-
Don’t make assumptions about how other developers will use a function. If you have to pass null into the class constructor to initialize some internal state, it’s a good indicator that the class is too specific and aware of its current use.
-
Don’t depend on knowledge of private implementation like not calling
a.initialize()because you knowa.execute()will lazy-initialize if it needs to. Maybe it won’t in the future, and then you’ll get an NPE. -
Isolate nullable operations into a single method or class. That way, you don’t have to strew
?in many places throughout your code.
You’ve now gotten through all the best practices for nullability in Kotlin. Although Kotlin is safer than Java, you won’t always work with a pure Kotlin app. An example is legacy code that’s too expensive to change — plus, some teams simply prefer Java.
Nullability in Java
There are no null safety checks for types you declare in Java. Types coming from Java subvert the checking system!
The best practice is to treat all variables coming from Java as nullable in your Kotlin code. To avoid unnecessary refactoring, another solution is to update Java methods to include nullability annotations.
Annotations don’t alter any existing logic but, instead, tell the Kotlin compiler about nullability. The two important annotations are @Nullable and @NotNull.
While it’s sometimes acceptable to return null on an error, using null to represent a state is problematic. Variables shouldn’t have hidden or double meanings. A worse example is an Int? that stores the number of logged-in users unless it’s null, which then means the app is in maintenance mode.
Say you have a method that returns ByteArray. Another solution is to have it return an empty ByteArray on failure instead of null. This is Failsafe Programming — where you return a default or safe value that causes minimal harm if something goes wrong.
Depending on your design requirements you’ll want to consider whether your app should be robust or correct. For example, if your app shows the temperature outside and the value is null during one of the iterations, you’d use a safe value or skip that iteration and show the previous reading.
On the other hand, if your app controls factory equipment, you’d want to immediately abort whenever your app finds an incorrect value!
Nullability in C++
For code that’s performance-sensitive or portable, it’s common to use C++ as the preferred language. C++ is powerful because it allows you to work with memory pointers. Here are a few points about pointers:
- As with references, you can set a pointer to null.
- C++ doesn’t offer nullability annotations like Java does. Instead, document your functions well by stating whether the parameters and return values can be null or not.
- In normal cases, you set a pointer to null when you’re finished with it, and don’t store or return pointers for later use. That allows you to work with the pointer only while it’s valid.
- The true native meaning of null is actually a zero. Zero was late to the party in computational systems, arriving only after 5,000BC. It was null before that. :]
You’ve done a lot to harden the app where the logic and flow is obvious. But there are cases where intermittent and unexpected states can appear, and that’s usually due to concurrent code.
Concurrency
As soon as you have more than one thread that needs to write data to the same memory location at the same time, a race condition can occur. Race conditions cause data corruption.
For instance, an attacker might be able to alter a shared resource to change the flow of security code on another thread. In the case of authentication status, an attacker could take advantage of a time gap between when a flag is checked and when it’s used. Wikipedia has a good explanation of the issue: https://en.wikipedia.org/wiki/Time_of_check_to_time_of_use.
Open ReportDetailFragment and find sendReportPressed(), then search for the code that tracks ReportTracker.reportNumber. Notice it’s set before the network call and read after the network fires a callback. Because network calls are asynchronous, if users repeatedly press the SEND REPORT button, they’ll cause the report number to fall out of sync.
Add an if (!isSendingReport) { check right at the beginning of the method so that the entire body is inside that check. This follows the best practice of designing your classes so that you don’t need to implement special concurrency-related code.
Other best practices are to use high-level frameworks like Kotlin coroutines or use thread confinement — where the logic exists only in one thread.
Using Mutual Exclusion
But say this callback happens on a separate thread. The way to avoid those race conditions is to synchronize the data. Synchronizing data means locking it so only one thread can access that part of the code at a time, called mutual exclusion.
Add the following right above the definition for the isSendingReport variable:
@Volatile
In Kotlin, @Volatile is an annotation for atomic. Keep in mind it only secures linear read/writes, not actions with a larger scope. Making a variable atomic doesn’t make it thread-safe. You’ll do that now for the reportNumber variable.
Making Variables Thread-safe
Find the reportNumber definition and replace it with the following:
var reportNumber = AtomicInteger()
An atomic variable is one where the load or store executes with a single instruction. It prevents an attacker from slipping steps in between the save and load of a security flag.
Navigate to sendReportPressed() and find the line that reads ReportTracker.reportNumber++, then replace it with the following:
synchronized(this) {
ReportTracker.reportNumber.incrementAndGet()
}
Now, inside onReportReceived, replace the line that sets the report variable:
synchronized(this) { //Locked.
report = "Report: ${ReportTracker.reportNumber.get()}"
}
You’ve now synchronized reportNumber between two threads. Build and run the app. Try pressing the SEND REPORT button multiple times and notice you can only send one report at a time.
More About Synchronization
Here are a few more tips about synchronization:
- Keep synchronization code in one place. It’s hard to remember which places you’ve synchronized if you’ve scattered those locations all around your code.
- A good way to do this is by using accessor methods. By using only getter and setter methods and only using them to access synchronized data, you can do everything in one place. This avoids having to update many parts of your code when you’re changing or refactoring it.
- Good interface design and data encapsulation are important when designing concurrent programs. They ensure you protect your shared data. It’s pointless to have synchronization inside a class when its interface exposes a mutable object to the shared data. Instead, mark synchronized variables as private and return immutable variables or copies to the data.
- It’s good for code readability to write your methods with only one entry and one exit point, especially if you add locks later. It’s easy to miss a
returnhidden in the middle of a method that was supposed to lock your data later. Instead ofreturn true, for example, you can declare aBoolean, update it along the way and then return it at the end of the method.
You’ve taken all these steps to harden your app against malicious attackers. But it’s also good to know when your app is under attack.
Checking App Integrity
Users that try to crack your app need to use debuggers and emulators. You can often detect these states and monitor or reject those users, which is known as integrity checking. Since spammers use these tools, it helps keep them out of your app too!
Open up WatchDog.kt and check out the various methods; each looks for tell-tale signs someone has altered the environment. They check if popular emulators are running, or if the device is rooted by the existence of super-user features and privileges.
It’s not fool-proof and sometimes you can get false positives. If keeping up to date with the latest changes is tiring, there are also third-party solutions that you can add to the mix:
- Find an open-source solution called Rootbeer here: https://github.com/scottyab/rootbeer.
- If you’re already using Fabric or Firebase Crashlytics, call
CommonUtils.isRooted(context). - GuardSquare, the makers of ProGuard, have a commercial solution called DexGuard that provides app and device integrity checking. It also encrypts classes, strings, assets and resources to thwart reverse-engineering. Check it out here: https://www.guardsquare.com/en/products/dexguard.
- You can also use Google’s SafetyNet Attestation API. It includes device integrity checking, a Safe Browsing API to check for malicious URLs and a reCAPTCHA API to protect your app from spammers and other malicious traffic. Find it here: https://developer.android.com/training/safetynet/attestation.
Key Points
In this chapter, you covered all the major areas for hardening your app. Here’s a summary of the most important points:
- Make sure to sanitize all input and output for the app.
- Adding native code increases the app’s attack surface in regards to pointer and buffer vulnerabilities.
- If you’re not using high-level concurrency APIs, you need to synchronize or use locks around the shared data.
- Use integrity checking if your app is susceptible to spammers or malicious users.
There’s no such thing as a perfectly secure app. There are always changes and you’ll find new bugs along the way. That’s why a big part of designing a robust app comes from the feedback after your release regarding user experience, bugs and crashes.
In the following chapters, you’ll switch gears to look at your release, including how to analyze it and handle debugging and lifecycle considerations.