App Hardening Tutorial for Android With Kotlin
In this App Hardening Tutorial for Android with Kotlin, you’ll learn how to code securely to mitigate security vulnerabilities. By Kolin Stürt.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
App Hardening Tutorial for Android With Kotlin
30 mins
As network communications and OSs become more secure, hackers have shifted their focus from basic eavesdropping to attacking devices and apps. To protect your app from attacks, you need to know how to use app hardening effectively.
From minimizing pointer use to null safety and mutability checks, Kotlin is a great language for secure development. However, that means it’s tempting to forget about security altogether. Even Kotlin has vulnerabilities that you need to protect your app against.
In this tutorial, you’ll learn how to:
- Avoid code vulnerabilities.
- Validate input and sanitize output.
- Secure concurrent code.
Getting Started
Download and unzip the materials for this tutorial by using the Download Materials button at the top or bottom of this page. You’ll work with the sample app, Snitcher, which you previously cracked. Now it’s time to secure it.
If you didn’t read the previous tutorial, the Snitcher app lets users send anonymous tips about animal crimes to law enforcement. Well, it doesn’t send the information to law enforcement, so feel free to test it out.
Open and run the starter project in Android Studio 3.5.0 or higher. You’ll see a simple sign-up screen. Once you enter an email and any password and choose SIGN UP, you’ll need to enter that password when you launch the app in the future:
After that step, you’ll get a list of wrongdoings to report:
Tap an entry in the list to proceed to the reporting screen. Right now, the app has an “overflow” of security issues which you will eventually fix!
Introducing Overflows
In languages such as 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. It’s been a major way for attackers 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 will run out, you’ll get a StackOverflow
exception.
Kotlin provides safety modifiers such as tailrec
, which helps 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.
- Prohibit use within
try
/catch
/finally
blocks.
These rules are helpful in the instances where your implementation changes later and you forget to re-check that it’s still safe.
To implement this in Snitcher, open Timing.kt and add tailrec
right after the private
modifier in the method definition of factorial
. Your modified method definition should look like below:
private tailrec fun factorial(number: Int, accumulator: Int = 1) : Int {
You’ve just added a safety modifier! Also, Android Studio provides important security warnings for potential overflows.
Paying Attention to Warnings
Exceptions and crashes are obvious indicators that something is wrong. Often, 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 the ReportDetailActivity.kt file and take a look at the warning by hovering over REPORT_APP_ID * REPORT_PROVIDER_ID
on the line right under comment //2. Send report
.
Regular numbers defined like this are integers. But adding them together exceeded the maximum size of the container. That’s why it’s the best security practice to treat warnings as errors!
At the top of the file, replace the REPORT_APP_ID
and REPORT_PROVIDER_ID
declarations 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.
Another vulnerable area is interacting 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 the CPointer
and COpaquePointer
objects.
While native code is beyond the scope of this article, here are a few things to keep in mind if you’ll be working with NDK:
- It’s extremely important when interfacing with C to do bounds checking on the input to make sure it’s within range.
- Don’t store or return pointers for later use.
- Avoid unsafe casts using
.reinterpret()
or.toLong()
and.toCPointer()
methods.
Because vulnerabilities can manipulate data in your app, another possible place for vulnerabilities is when your app passes data for further processing to a server. To make sure this is secure, you should sanitize all data that leaves your app.
Sanitizing Output
Some people are terribly afraid of germs they can’t see.
For app developers, that’s a good quality.
If the app sends data in the text fields to a server, then sanitizing that input reduces the potential of 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 the activity_main.xml file and make sure you’re in the XML editing view. Add the following to the first EditText
element, with the id login_email
:
android:maxLength="254"
Email addresses can have a max of 254 characters.
Now, add this to the next two EditText
elements, with the ids login_password
and login_confirm_password
:
android:maxLength="32"
You’ve set the maximum password length to be 32 characters.
Now, open the activity_report_detail.xml file. Add the following to the EditText
field, with id details_edtxtview
:
android:maxLength="512"
You made the maximum character limit 512 for the report.
Try out your changes. Build and run the app, entering a large amount of text into the password field. You’ll see you’ve made your app a bit safer.
Next, you’ll want to remove dangerous characters for the language that your server is using. This prevents command injection attacks — when you pass data to an environment that should store it but instead executes it as commands. The app’s underlying datastore uses an SQLite database, while the pretend 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 to 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. That’s what you’re going to do next.
Find sendReportPressed()
in ReportDetailActivity.kt. Replace the line that declares reportString
with this:
var reportString = details_edtxtview.text.toString()
reportString = reportString.replace("\\", "")
.replace(";", "").replace("%", "")
.replace("\"", "").replace("\'", "")
You’ve now stripped the vulnerable characters from the string.
LIKE
and CONTAINS
allow wild cards that you should avoid. It prevents attackers from getting a list of accounts when they enter a*
as the account name.
If you change the LIKE
clause to ==
, the string has to literally match a*
.
Only you will know what the expected input and output should be, given the design requirements, but here’s a few more points about sanitization:
- Dots and slashes may be harmful if passed to file management code. A directory traversal attack is when a user enters
../
, i.e. to view the parent directory of the path instead of the intended sub-directory. - If you’re interfacing with C, one special character is the
NULL
terminating byte. Pointers to C strings require it. Because of this, you can manipulate the string by introducing aNULL
byte. The attacker might want to terminate the string early if there was a flag such as needs_auth=1. - HTML, XML and JSON strings have their own special characters. Make sure to encode the URL and escape special characters from the user input so attackers can’t instruct the interpreter:
< must become <.
> should be replaced with >.
& should become &.
Inside attribute values, any “ or ‘ need to become " and &apos, respectively.
Just as it’s important to sanitize data before sending it out, you shouldn’t blindly trust the data you receive. The best practice is to validate all input to your app.
Validating Input
Subconsciously, animals are constantly validating their environment for danger, sometimes in better ways than our minimal human instincts. While we may not be great at validating danger in the wild, at least we can add validation to our programs.
Besides removing special characters for the platform you’re connecting with, you should only allow characters for the type of input required. Right now, users can enter anything into the email field.
For your next step, you’ll fix this by adding the following to the MainActivity.kt file, under the variable declarations for the class:
import java.util.regex.Pattern
private fun isValidEmailString(emailString: String): Boolean {
return emailString.isNotEmpty() && Pattern.compile(EMAIL_REGEX).matcher(emailString).matches()
}
This creates a method that verifies an email address via regular expressions. Now, add this to the companion object that’s at the bottom of the file:
private const val EMAIL_REGEX = "^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,4}$"
That regular expression makes sure emails have a format of test@example.com.
Finally, navigate to // TODO: Replace below line with check for email field
inside the loginPressed()
method. Replace the // TODO
and success = true
line with the following:
val email = login_email.text.toString()
if (isValidEmailString(email)) {
createDataSource("users.xml", it)
success = true
} else {
toast("Please enter a valid email.")
}
Test it out 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 you’re restricted from doing so:
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 that would get delivered right to the organization! You’ll fix that now.
Add the following to the end of the class in the ReportDetailActivity.kt
file:
import java.io.IOException
import java.io.RandomAccessFile
@Throws(IOException::class)
private 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 last two bytes are always FF D8 and FF D9. This method checks for that.
To implement it, find the getFilename()
method and replace its complete implementation with the following:
// Validate image
val isValid = isValidJPEGAtPath(decodableImageString)
if (isValid) {
//get filename
val fileNameColumn = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
val nameCursor = 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 {
toast("Please choose a JPEG image")
}
This calls the photo check when the user imports a photo, validating if it’s a valid JPEG image file.
Speaking of files, developers often overlook serialized and archived data from storage.
Checking Stored Data
Open MainActivity.kt and take a look at createDataSource
. Notice the code makes assumptions about the stored data. Next, you’ll change that.
Replace the declaration of users
inside the createDataSource
method with the following:
val users = try { serializer.read(Users::class.java, inputStream) } catch (e: Exception) {null}
You caught exceptions during the reading of data into User
. To prevent overuse, Kotlin discourages exceptions in favor of better flow control. For the most part, using safety checks is a better approach because it makes a method resilient to errors. It contains the failure instead of propagating it outside the method, which can become an app-wide failure.
In your next step, you’ll implement safety checks. Start by replacing everything after the try
/catch
you just added with this:
users?.list?.let { //1
val userList = ArrayList(it) as? ArrayList
if (userList is ArrayList<User>) { //2
val firstUser = userList.first() as? User
if (firstUser is User) { //3
firstUser.password = login_password.text.toString()
val fileOutputStream = FileOutputStream(outFile)
val objectOutputStream = ObjectOutputStream(fileOutputStream)
objectOutputStream.writeObject(userList)
objectOutputStream.close()
fileOutputStream.close()
}
}
}
Here:
- You added null checks for the user list.
- You made sure the
ArrayList
containsUser
objects. - Added an extra check that ensures the
firstUser
is really aUser
object.
Adding safety checks around your code is Defensive Programming — the process of making sure your app still functions under unexpected conditions.
Next, navigate to // Todo: Implement safety checks
in the loginPressed()
method and implement safety checks as below :
if (list is ArrayList<User>) { //1
val firstUser = list.first() as? User
if (firstUser is User) { //2
if (firstUser.password == password) {
success = true
}
}
}
Here, you only set success
to true
when:
-
list
is anArrayList
. -
firsUser
is really aUser
object.
Hardening Code
Taking that technique further, another good programming practice is design by contract, where the inputs and outputs of your methods satisfy a contract that defines specific interface expectations.
To see how this works, navigate to the ReportViewModel.kt file. Replace the contents of getReportList
with the following:
if (file.exists() && password.isNotEmpty()) { // 1
if (reportList == null) {
loadReportList(file, password)
}
}
if (reportList is ArrayList) { // 2
return reportList ?: arrayListOf()
}
return arrayListOf()
Here’s what this code does:
- Makes sure that the
file
exists before accessing it and thatpassword
is not empty. - Adds type checks and a safety fallback for the return value.
This is Failsafe Programming — where you return a default or safe value that causes minimal harm if something goes wrong.
Now that you’ve hardened both the inputs and outputs of your app, here are a few more tips:
- If you’re expecting specific kinds of characters, such as numbers, you should check for this. Some methods that are helpful include:
–Char.isLetterOrDigit(): Boolean
–Char.isLetter(): Boolean
–Char.isDigit(): Boolean
–String
‘slength
method is also handy. 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. - Another overlooked area is inside deep link or URL handlers. Make sure user input fits expectations and that it’s not used directly. It shouldn’t allow a user to enter info to manipulate 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.
- Be careful when displaying an error alert that directly shows a message 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.
Now it’s time to take a look at another common source of vulnerabilities.
Working With 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 the time of check and the time of use of a flag.
The way to avoid race conditions is to synchronize the data. Synchronizing data means to “lock” it so that only one thread can access that part of the code at a time, which is called a mutex — for mutual exclusion.
If the design of your app makes you work with multiple threads, a good way to protect yourself from the security issues of concurrency is to design your classes to be lock-free so that you don’t need any synchronization code in the first place. This requires some real thought about the design of your interface.
Open the ReportDetailActivity.kt file and find the sendReportPressed()
method. Notice the line that reads if (!isSendingReport) {
. It’s right after the line that spawns a thread. If the user repeatedly presses the SEND REPORT button, several threads will change the isSendingReport
variable at the same time.
Select the if (!isSendingReport) {
line and the line below it. Cut and paste them right above the Executors.newSingleThreadExecutor().execute {
statement.
Now, nothing bad will happen if the user taps the button multiple times. This is called Thread Confinement — where the logic doesn’t exist outside a single thread.
You fixed one problem without needing to add locks. But there’s another problem. One thread updates the reportNumber
variable while another displays it to the user at the same time.
Synchronizing Data
You can fix this by adding locks. However, modern techniques call a synchronized
method or mark variables atomic instead. An atomic variable is one where the load or store executes with a single instruction. It prevents an attacker slipping steps in between the save and load of a security flag.
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 does not make it thread-safe. You’ll do that now for the reportNumber
variable.
Find the reportNumber
definition and replace it with the following:
import java.util.concurrent.atomic.AtomicInteger
var reportNumber = AtomicInteger()
Navigate to the sendReportPressed()
method, find the line that reads ReportTracker.reportNumber++
and replace it with the following:
synchronized(this) {
ReportTracker.reportNumber.incrementAndGet()
}
Inside the runOnUiThread
block, replace everything up to the finish()
statement with this:
isSendingReport = false
var report : String
synchronized(this) { //Locked.
report = "Report: ${ReportTracker.reportNumber.get()}"
}
You’ve now synchronized reportNumber
between two threads! Here are a few more tips:
- If there are more than a few places to synchronize, scattering synchronization all over your code isn’t good practice. It’s hard to remember the places you’ve synchronized and easy to miss places that you should have synchronized. Instead, keep all that functionality in one place.
- Good design using accessor methods is the solution. Using getter and setter methods and only using them to access the data means you can synchronize in one place. This avoids having to update many parts of your code when you’re changing or refactoring it.
You’ve hardened your concurrent code. Still, the ability to spawn multiple tasks has risks when it comes to availability.
Denial of Service
Allowing more than one process at the same time opens the door for denial of service attacks — where an attacker purposely uses up resources to prevent your app from working. These vulnerabilities arise if the developer forgets to relinquish resources when finished with them.
Navigate to // Todo: Close streams here
under loginPressed()
in MainActivity.kt and replace it with this:
objectInputStream.close()
fileInputStream.close()
This releases all the acquired resources. When you find a software defect, it’s a good clue that the defect likely exists in other places as well. It’s a great opportunity to look at other places in the code for the same mistake. Can you find it?
Replace // Todo: Close streams here also
at the end of createDataSource
with the following:
inputStream.close()
You’ve been a good citizen by cleaning up after yourself!
Here are a few more points about concurrent programming:
- It’s pointless to have synchronization inside a class when its interface exposes a mutable object to the shared data. Any user of your class can do whatever they want with the data if it isn’t protected. Instead, return immutable variables or copies to the data.
- Make sure you mark synchronized variables as private. Good interface design and data encapsulation are important when designing concurrent programs to make sure you protect your shared data.
- It’s good for code readability to write your methods with only one entry and one exit point, especially if you’ll be adding locks later. It’s easy to miss a
return
hidden in the middle of a method that was supposed to lock your data later. That can cause a race condition. Instead ofreturn true
for example, you can declare aBoolean
, update it along the way and then return it at the end of the method. Then you can wrap synchronization code in the method without much work. - When a thread is waiting on another thread but the other thread requires something from the first thread, it’s called a Deadlock and the app will crash. Crashes are security vulnerabilities when temporary work files are never cleaned up before the thread terminates.
With all the changes you’ve made, it’s important to do thorough testing to make sure you didn’t break something that was working before. Developers call that Regression Testing.
Testing
Have you tested your unit tests? Are your unit-test tests tested? What about testing those tests…
OK, that might throw a mental overflow exception. But when you’re testing, cover all your code. Go through each flow-control case and test each line of code at least once.
When you find a bug, it’s like holding a mirror up to yourself – a great learning opportunity. Security researchers look at past bug fixes to profile a developer’s style. This speeds up the process of finding bugs by guessing where others might be.
As you did above, taking the time to check the rest of your code for the same mistake when you encounter a bug is an efficient way of preventing security vulnerabilities from appearing over again in future releases. It’s good motivation for code reuse; knowing that you fixed a problem in one place and don’t have to find all the same occurrences in copy/pasted areas.
Testing Tools
It’s time-consuming to find race conditions during testing because you have to corrupt memory in the “right way” to see the problem. Sometimes the problems appear a long time later in the app’s execution. One solution is to run Lint — Android Studio’s static code analysis tool.
While static testing refers to auditing the source code, dynamic testing involves testing while executing the code. One trick is to input random data, called Fuzz Testing.
Another is to choose extreme values in hopes of finding an edge case. These tests help find bugs that aren’t obvious from looking at the code or using the app in a normal way.
Building With ProGuard
One thing that’s different for configuring apps published to the store is code optimizations by tools such as ProGuard or R8. For your published app, your code is changed, or different from the one that you tested. This means that it can introduce bugs that only exist once you release your app.
Make sure you test your app on the optimized version. See the Getting Started with ProGuard tutorial for more information about how to use it.
Where to Go From Here
Congratulations! You’ve hardened your very important crime reporting app. :]
Feel free to download the completed project using the Download Materials button at the top or bottom of this tutorial.
While you’ve tightened the application code, you should also protect the user’s data. Read the Encryption Tutorial For Android to learn how to secure data-at-rest and the Securing Network Data Tutorial for securing data-in-transit.
Another part of app hardening revolves around nullability. See the Null Safety Tutorial to learn how to further harden your app against null pointer exceptions.
Finding security vulnerabilities without having the source code involves reverse-engineering the app, finding information leaks and forensics artifacts in the data it stores. Check out the Hack an Android App: Finding Forensic Artifacts tutorial to learn how to do that.
Look at Android’s security tips and as always, if you have any questions, feel free to comment in the discussion below.
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more