22.
App Analysis
Written by Kolin Stürt
In the previous chapters, you looked at analytic reporting and advanced debugging techniques. Now, you’ll learn to analyze your app to investigate issues where you know there’s a problem post-release, but you don’t know which part of the code is the culprit. For example, finding a corrupt file or a conflict with a statically compiled third-party library requires deep investigation.
In this chapter, you’ll learn how to:
- Look at data artifacts that aren’t obvious from your code.
- Analyze databases.
- Reverse-engineer code you didn’t write.
For this chapter, you’ll use the Pixel XL API 30 (R) Emulator.
Debugging versus investigating
When you debug your app, you apply tools to fix not just the symptoms, but the underlying problem. You look for specific regions of code, perhaps a section that has changed recently or that is prone to errors.
There are two types of tests you can run to find problems:
- Dynamic testing: Testing while executing the code.
- Static testing: Auditing the source code for issues.
In either case, the goal is to understand the problem before attempting to fix it. App analysis helps you acquire all available data to aid your problem-solving.
Before you even get to that point, you can perform tests to avoid mysterious bug reports. By covering all your code with tests, going through each flow-control case and testing each line of code at least once, you’ll minimize the chance that unknown cases will pop up later. Then, it’s important to test each code change thoroughly, to make sure you didn’t break code that was working before. This is called regression testing.
Because you wrote the code, you know how to use your app. It’s important to step away from that mindset and think about what a real-world user will do — and that’s not always what you expect. There are ways of covering more of that behavior: One 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.
Even with all this testing, you’ll find unexpected bugs. One example is memory corruption due to race conditions. It’s difficult 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 lifecycle. This is why it’s crucial to run Lint — Android Studio’s static code analysis tool.
Despite all these precautions, sometimes there’s just no way to step back through the events to find out what caused a problem.
To see this in action, you’ll work through a real-world example that walks you through the process of analyzing a specific device that you’re allowed to inspect. This will give you a sense of the process and the complications you’ll encounter along the way.
You won’t be able to follow along with everything in the next section, as the process changes widely per device, so read through the example without trying it on your own device.
Extracting data
Your CEO comes to you with a device that crashes when they launch PetSave. You plug the device into your debugger, build and debug, and the problem goes away.
However, a week later, the same story happens again. The C-level employees only get the final release version, where you’ve disabled logs, and Logcat is no help. The fact that it’s a release build might be a coincidence. The third time the CEO brings you the device with the issue, you know you have to preserve the state of the defect. You can’t just debug this app, you need a way to extract data from the device.
You’ll start your investigation by using Android Debug Bridge (ADB), an Android Studio tool that lets you communicate with an Android device via the command line. To follow the remainder of this tutorial, enable ADB debugging on a physical device or an emulator.
One of the simplest things to do with ADB is to list the apps installed on a device:
adb shell # 1
pm list packages -f # 2
exit
Here’s what the code above does:
- Starts the ADB shell so you can run commands on the device.
- Lists the packages installed on the device.
After running this command, you’ll see a long list of packages installed on the device. If the CEO has correctly installed PetSave, you’ll see a line like this in your output:
package:/data/app/com.raywenderlich.android.petsave-ei0L3AJk3xo5M3Gs9SVuTQ==/base.apk=com.raywenderlich.android.petsave
Here, com.raywenderlich.android.petsave is PetSave’s package name.
Extracting data from a package
Once you’ve found the PetSave package, try to run the app over ADB to extract data with the correct permissions. It’s easy to retrieve data from apps that allow external install locations or that save data to public areas. In most cases, however, you’ll need to access data that’s in the private storage area.
On some versions of the Android platform, you can access the private storage of debuggable versions of the app:
adb shell
adb exec-out run-as com.raywenderlich.android.petsave cat databases/reports-db > reports-db
Here, you’re using run-as to execute commands with the same permissions as the app.
If that doesn’t work, you can also try to change file permissions and use the adb pull command:
adb shell
run-as com.raywenderlich.android.petsave #1
chmod 666 databases/reports-db #2
exit
cp /data/data/com.raywenderlich.android.petsave/databases/reports-db /sdcard/ #3
run-as com.raywenderlich.android.petsave
chmod 600 databases/reports-db #4
adb pull /sdcard/reports-db . #5
This code:
- Tells ADB to execute commands with the same app permissions.
- Executes
chmod, which lets you change file permissions. Permission 666 means all users can read and write to the file. - Copies reports-db to sdcard, which is a public area of the device.
- Executes
chmodagain to reset the file permissions. Permission 600 means only the owner — the app — can read and write to the file. - Now that you’ve put the file in a public area, you copy the file from the device to the working directory of your computer.
You now have a copy of an app’s local database on your computer. However, many devices disable these features for security reasons. If that’s the case, the next thing you’d try is a device backup. Device backups can include the APKs as well as the private data for each app:
adb backup -apk -shared com.raywenderlich.android.petsave
Here, you use backup to write an archive of the app and its data to the working directory of your computer. The default filename is backup.adb.
Feel free to experiment, if you’re comfortable doing so, on a test device. But for the sake of time and safety, this chapter will use the Android Emulator to skip to the next step.
Extracting data from the emulator
Now that you have access to the file system of the CEO’s device, it’s time to extract the data. Build and run in the emulator, then make a report.
In the Report screen, fill in the details and tap the SEND REPORT button. In Android Studio, select View ▸ Tool Windows ▸ Device File Explorer, then choose Emulator Pixel_XL_API_30 from the drop-down:
Knowing where apps store information makes it easy to look for artifacts or to recover deleted data. Here are some locations where Android keeps important data:
-
All apps store user data in /data/data.
-
You can find a list of apps on the device at /data/system/packages.list.
-
You can see when you last used an app at /data/system/package-usage.list.
-
The operating system stores Wi-Fi connection information, such as a list of access points, at /data/misc/wifi/wpa_supplicant.conf.
To try your hand at saving PetSave’s data to your device, navigate to /data/data. You’ll see a list of all the packages:
Find the com.raywenderlich.android.petsave entry. Right-click on it and choose Save As…. Save the file to a location on your computer and open it to view its contents. You’ll see important directories such as:
- shared_prefs
- files
- databases
Now, you’ll look at each of these in more detail.
Examining SharedPreferences
Open MyPrefs.xml inside shared_prefs. You’ll notice at least one entry with a timestamp.
Timestamps are very important to any debugging investigation because they give you evidence of what happened at a specific time.
Examining other files
Now, select users.dat in the files directory.
Android serializes objects in a specific record format, but you can still search for strings using the strings utility, which both Mac and Linux already include.
If you’re using Windows, download the strings utility here: https://docs.microsoft.com/en-us/sysinternals/downloads/strings.
In the terminal, type strings and a space followed by the path to users.dat. After you press Space, drag users.dat into the terminal window to populate the path. Press Enter and you’ll get an output of items.
Upon looking at the output, you’ll see extrat followed by nameq and passwordq. You can use that order to deduce that you’re looking at the extra info about each account, followed by a login name and an encrypted password. In Chapter 16, “Securing Data at Rest”, you encrypted this data. But wait, it looks like this now:
"::basic_string(void*,void(*),void(*)_char_\0\0cd.Nico Sell - CEO"
There’s a name in there that doesn’t look like a password, nor is it encrypted. Also, there seems to be some extra garbage data.
Doing a Google search for ::basic_string leads you to typedef std::basic_string string, a class template type for std::string. But this is C++.
Choose Edit ▸ Find ▸ Find in Path to search for std::string. Oh right, when working on your app with the iOS team, you shared some portable code for productivity’s sake.
user_processing_jni.cpp shows up in the search. Open it and check out line 40. It looks like that could be what’s getting in the password field by mistake. Without going into C++ too much, you’ve found a possible location of a bug that you can report to the other team to fix.
Note: Interested in a bit of C++ and what the bug is? On line 50, the constructor attempts to set this variable to zero, but there’s a mistake. In C++, you must explicitly initialize all pointers; otherwise, they point to garbage values. The line attempts to set the variable to all zeros, but
sizeofonly sets the first character.
In debug mode, that was enough for the app to keep going. In release mode, the optimizer sees that you’re setting the variable to zero and not doing anything with it, so it removes that line. Now, when you go to access it, it points to a random part of memory — in this case, a section of the previous
_userNameString— and crashes! Worse, the memory layout will vary each time, making this crash random.
Now, the other team sent you the fix. Replace line 50 with the following:
_passwordChar = "";
Now, you have happy C++ code. :]
Note: If you want to prevent the compiler from optimizing out the secure-wiping of memory contents, check out the proper implementation in the destructor on line 54, which uses
volatile.
You used the strings utility here, but there are a few other tools to extract data, as well:
- A hex and text viewer comes in handy to search for strings and patterns.
- A live data imaging tool that may be helpful is dd. You’ll find it at /system/bin.
- To extract the current memory state of the device, check out LiME.
Next, you’ll learn how to check the data stored in databases.
Analyzing databases
Often, user records are stored in a database instead of a serialized object. Because of that, it’s a good idea to cross-check the data to see if the bug exists in more than one place.
Navigate to the databases folder and you’ll see some files. Next, you’ll see some different ways to examine them.
Since you’ve already downloaded the database files, start by heading to the DB Browser homepage: https://sqlitebrowser.org/.
Click the Download button at the top of the page. Choose your operating system, download the file and install the program. Launch DB Browser and choose the Open Database button at the top:
In the folder you downloaded via the Device File Explorer, choose reports-db from the databases directory.
If it doesn’t show up in the list, choose All files from the Filter option at the bottom. If there’s no reports-db, look for reports-master-db:
Assuming everything worked, the database tables show up in the Database Structure tab. Click the Browse Data tab:
Now, click the Table selector under the tab and choose reports:
You’ll see all the reports. In Chapter 16, “Securing Data at Rest”, you encrypted this data and stored it as base 64, so your first step will be to check that it really contains base 64 characters. In this case, these characters include: A–Z, a–z, 0–9 and the + and / symbols.
This time, things seem okay, but the index order of the reports is off:
id:
0:67185506-1e42-4670-a129-801bd8cfe023
2:110b41d5-9eb3-4b3e-96ed-bb90d065fdc0
1:313e975e-9cab-4c99-9f8b-55539cb7c219
When indexes are out of order, it’s a tell-tale sign of a race condition. You thought you had already fixed this in Chapter 18, “App Hardening”, but maybe there’s a regression?
Search the project for the variable that keeps track of that index, ReportTracker.reportNumber. Notice there’s test code on line 136 of ReportDetailFragment. It looks like one of the developers forgot to undo the test and accidentally committed the code!
Remove the test comments, then uncomment the code on lines 136 and 140 to fix the problem.
Note: Always double-check your commits. :]
So far, everything’s going well — but analyzing your app doesn’t always go this smoothly. For example, users often continue using the app after a bug occurs. They’re not developers. They don’t understand that the more they use the app, the farther away they put the state of the app at the time of the bug. For example, say that when the CEO experienced the bug, they logged out of the app and submitted the device to QA — not understanding that the logout functionality deleted the user record and reports.
To address this, you need to know how to recover that data.
Recovering deleted data
The data you’ve analyzed so far exists inside a saved SQLite block. SQLite has unallocated blocks and free blocks. When you delete something from the database, SQLite doesn’t overwrite the block immediately. Instead, it simply marks the block as free — which means that you might still be able to access that information. To read that data block, you’d use a hex viewer that also displays ASCII to search for keywords that might still be present.
The process of finding and extracting data when you don’t have access to the file structure is called file carving. Sometimes, searching for a particular string of content helps. Other times, you’d look for the header of a known file format.
For example, say you’re searching deleted data for images. In the JPEG format, the first two bytes and the last two bytes are always FF D8 and FF D9. Searching for those headers can help you identify the images.
Here are a few more details about recovering deleted data:
-
Find valuable information about SQLite file carving here: https://forensicsfromthesausagefactory.blogspot.com/2011/04/carving-sqlite-databases-from.html.
-
Scalpel is an open-source data-carving tool, available at https://github.com/sleuthkit/scalpel.
-
DiskDigger is an automated undelete tool for Android. It scans the device for photos, documents, music and videos: https://diskdigger.org/android.
-
A commercial tool for viewing and undeleting SQLite records is SQLite Viewer, available here: https://www.oxygen-forensic.com/en/products/oxygen-forensic-detective.
Next, you’ll learn how to handle problems in code you don’t own.
Black box testing and reverse-engineering
At this point, you’ve analyzed and fixed code that you own, but bugs happen in third-party frameworks, too. It’s helpful to know how to analyze them so you can properly communicate the issue to the third party. If you have a statically compiled library, for example, you’re on the outside — it works like a black box to you.
You can get a lot of information by analyzing a binary or app module. This includes the code and files that Android Studio bundles with the APK. First, you’ll look at what happens when you compile an app.
When you build your app, Android Studio produces an APK file. This is like a ZIP file that contains a structure of Java’s JAR archives. Inside the archive are resources, along with a DEX file. DEX stands for Dalvik Executable.
When Android Studio compiles your app, it puts the code into that DEX file and names it classes.dex. That file contains bytecode, an intermediary set of instructions that a Java Virtual Machine (JVM) runs or that ART (the Android Runtime) later converts to native code. So what are JVM, ART and native code?
Apps run on a Java Virtual Machine (JVM). Android traditionally used Dalvik for its JVM, but in recent years, Android replaced Dalvik with ART for performance reasons. ART converts DEX into native code by running the dex2oat tool to create a native ELF binary. Native code refers to the C/C++ code that the operating system understands and the assembly and machine code that the CPU can read.
So now you’re thinking, because PetSave is a Kotlin app, reverse-engineering it must be different than for Java apps. The good news is, like Java, Kotlin is a JVM language. While Kotlin has its own syntax, the kotlinc compiler transforms the code into a DEX file that contains Java bytecode. Because kotlinc compiles Kotlin to the same bytecode as Java, most of its reverse-engineering tools are the same as for apps built in Java!
Note: Sometimes, attackers also reverse-engineer apps in hopes of patching or hooking security checks out of the code. A good example of a target is a feature that’s only available with a paid subscription or after a user achieves a level in a game. Keep in mind that these tools and techniques are not only useful for debugging, but for performing a security audit of your app.
So now you’re thinking — enough theory already. Show me an example!
Understanding bytecode
You now have a new issue to deal with: The team updated an expired API key but the app still isn’t working.
Your first step is to check that the team used the correct key. Open ReportDetailFragment in Android Studio and find sendReportPressed(). It adds the report to the local database and prepares a network request to send the report. That network request requires the API key so only authorized apps can make the call.
Open ApiConstants.kt and note the const val SECRET used to make API requests. It looks like the correct key:
When you set a breakpoint in the debug version, things look fine. Based on your previous experience, it looks like something is happening to the code for the release version.
The release build variant disables debugging in many places. Commenting out those security checks results in a false test. But Android Studio includes a tool called APK Analyzer, which lets you view the bytecode of your finalized app.
Using APK Analyzer
APK Analyzer is a tool for inspecting your finalized app. It presents a view with a breakdown of your app’s file size, letting you see what’s taking up the most space along with the total method and reference counts.
For this example, you’ll look at the debug version. Launch the analyzer by selecting Build ▸ Analyze APK. This will open a dialog for your file system. Then, navigate to the debug folder, PetSave-Starter/app/build/outputs/apk/debug, select app-debug.apk and click OK to open APK Analyzer:
Note: If the APK file is missing, choose Build ▸ Build Bundle(s) / APK(s) ▸ Build APK(s) to generate it.
In APK Analyzer, select classes2.dex, then navigate to com/raywenderlich/android/petsave/core/data/api:
Right-click ApiConstants and choose Show Bytecode. Notice the line that starts with .field public static final SECRET:
At first glance, it seems that the secret token is correct. But is it? In the previous chapters of this book, you looked at how spammers search for tokens to abuse private APIs. Attackers also reverse-engineer apps, to steal intellectual property, for example, or to clone the app.
Since this API key is sensitive, it’s likely protected using obfuscation techniques such as reflection. As a consequence, however, it’s harder to debug when something goes wrong.
Introspection and reflection
When you’re away at work, your pets hang out for hours, not seeming to do very much. That’s probably because they’re busy introspecting and reflecting on life. In Kotlin, introspection and reflection are features of the language that inspect objects and call methods dynamically at runtime.
Open ApiConstants.kt and find aK(). Notice there’s some obfuscation. The previous developer abbreviated the name and created a string from bits and pieces of other strings, as well as from an object called GO. You can find the variables in SN.kt, in the object GO definition. For your next step, you’ll check that those values are in the final APK.
In APK Analyzer, select classes2.dex. Navigate to com/raywenderlich/android/petsave/core/data/api. Right-click GO and choose Show Bytecode. Integers f1, f2 and f3 are the variables of 3, 1 and 5 that create part of the key. Things look OK for those values at first glance:
However, if you follow those methods to the GO companion object, the numbers it returns are not the real ones. As you look through SN.kt, you notice this code:
val kClass = Class.forName(ownerClassName).kotlin // 1
val instance = kClass.objectInstance ?: kClass.java.newInstance() // 2
val member = kClass.memberProperties.filterIsInstance<KMutableProperty<*>>()
.firstOrNull { it.name == fieldName } // 3
member?.setter?.call(instance, value) // 4
Wait, what? What is this magic? This code does the following:
- Gets the Kotlin class for
ownerClassName. - Instantiates that class at runtime, if it isn’t already instantiated.
- Dynamically gets the property that
fieldNamereferences for the instantiated class. - Calls a setter on that property, passing in
value.
The real magic happens when the app invokes sn() (setupNumbers), which looks for com.raywenderlich.android.petsave.core.data.api.GO. It finds the fields named f1 through f3 and swaps the values out for something else at runtime.
You’ve now figured out why the API key change didn’t go as planned and where you need to update the real values.
Note: This is also good motivation to document tricky code for future developers.
Using reverse-engineering tools
You’ve just reverse-engineered code, and because you have the original project open in Android Studio, it was easy to do. But this is not the only way to view the bytecode. Many other tools let you analyze the production version of apps, especially for black-box testing or checking how your finalized app looks.
As long as you’re able to access the release APK, either by using the methods you learned above or by downloading an APK from a site like https://www.apkmirror.com/, you can reverse-engineer the code without having access to the Android Studio project.
For example, Apktool will reverse-engineer the entire Android package back to a workable form, including all resources and original source code. It’s available here: https://ibotpeaches.github.io/Apktool/. There are even online versions, such as the one at http://www.javadecompilers.com/apk.
There are also many other tools you can use:
- smali/baksmali (https://github.com/JesusFreke/smali) is a set of tools to transform bytecode into another intermediate, but more readable, language. From there, you can convert the code back into Java.
- Android Asset Packaging Tool dumps the Android Manifest file.
- AXMLPrinter2 (https://code.google.com/archive/p/android4me/downloads) parses Android binary XML formats.
- dex2Jar (https://github.com/pxb1988/dex2jar) lets you convert a DEX file to a standard Java CLASS file.
- Get all the class names and most source code by opening a jar folder in JD-GUI (https://github.com/java-decompiler/jd-gui).
- Dextra (http://newandroidbook.com/tools/dextra.html) supports ART and OAT.
- Jadx (https://github.com/skylot/jadx) lets you browse decompiled DEX code. It also decompiles almost the entire project.
- JAD (http://varaneckas.com/jad/) will convert Java class files back to source files.
As you can see, it’s easy for anyone to do this. That’s the main reason developers use obfuscation to hide or obscure proprietary logic or secret keys, as you saw above. They can do this with string splitting, dummy code, disguising the names of methods or using reflection, as you saw above. But more often, they use optimizers such as R8 or ProGuard. While the tools optimize code, they have the side effect of obfuscating it. That further complicates things when it comes to debugging.
Debugging with ProGuard output files
In the app build.gradle, replace buildTypes’s code with the following:
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
Sync Gradle, then build and run. When ProGuard finishes running, it produces four output files. They are:
- usage.txt: Lists code that ProGuard removed.
- dump.txt: Describes the structure of the class files in your APK.
- seeds.txt: Lists the classes and members that were not obfuscated. This helps you verify that you obfuscated your important files.
- mapping.txt: Maps the obfuscated names back to the original.
You can use the mapping file to see the original code.
Build and run APK Analyzer again, then select classes.dex. Drill down to com/raywenderlich/android/petsave and you’ll see classes and methods along the lines of i.n0.r.a. The single characters you see will vary from this example, but you can follow the directory path of the characters to various methods:
For debugging, it’s not clear what the directories are. Click the Load Proguard mappings… button to map the obfuscated names back to the original:
Select mapping.txt in the debug folder and click OK.
Toggle the Deobfuscate names button to the left of the Change ProGuard mappings… button to switch between obfuscated and deobfuscated code. Now, you can trace the problem down to the specific code again.
There are a few more things you should know about the mappings file:
- Every time you make a release build, you rewrite mapping.txt. That means you must save each copy with each release of your app. That way, when you receive an obfuscated stack trace for a particular app release, you’ll be able to use it.
- Upload your mapping.txt to Google Play to deobfuscate your crash stack traces. Instructions are here: https://support.google.com/googleplay/android-developer/answer/6295281.
- If you’re using Firebase, you can find instructions about mapping.txt here: https://firebase.google.com/docs/crashlytics/get-deobfuscated-reports?platform=android.
Congratulations, you’ve now learned how to overcome the most common hurdles when analyzing a compiled app.
Some final notes
Finding a software defect is like holding a mirror up to yourself — a great learning opportunity. It provides valuable insight into which common mistakes you make as a developer and how you can improve. App analysis is self-analysis. And, like every other phase of the lifecycle, it’s iterative.
Once you find the bug, thinking of how to solve it is iterative as well. It might mean going back to good variables names, high-quality methods and class interfaces, or even further back — maybe you coded the solution before the problem was clearly defined.
Security researchers look at past bug fixes to profile a developer’s style. This speeds up the process of finding vulnerabilities by guessing where others might be. 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 the same issues from appearing again in future releases. It’s also good motivation for code reuse; when you fix a problem in one place, you don’t have to find all the same occurrences of the problem in the areas of the code you copy-pasted.
App analysis is a complex process. As you progress through the development lifecycle, iterations become more expensive. Code-tuning and refactoring are less expensive than debugging, while working out the initial requirements of the problem domain is even more affordable. In other words, measure twice and cut once to avoid defects in the first place.
This brings you back to the beginning of the cycle. And like any process, you can come back to this book at any time. Just return to Chapter 2, “Starting from the Beginning”.
Key points
- There are two types of tests you can run to help you find problems: dynamic and static.
- Dynamic testing is testing while executing the code.
- Static testing is auditing the source code for issues.
- Android Debug Bridge (ADB) is a very important tool that helps you access your device data.
- Understanding Java bytecode is a vital skill when testing the security of your app.
- Several tools allow you to reverse-engineer your app. APK Analyzer is one of those.