Protect your customer data and your reputation with our state-of-the-art security
Secure valuable gaming revenue streams & maintain user trust with our Unity integration
Secure your e-commerce revenue & safeguard data by layering mobile app protection
For developers, reverse engineering is easier than they might think. Attackers use debuggers to understand and tamper with an app’s logic at runtime, iOS and Android developers use the debugger without realizing how the exact same capabilities are used for reverse engineering.
To get a better idea of how developers and attackers use debuggers, let’s look at some of the common use cases for debugger features.
|Breakpoints||Pause to analyze a bug||Pause to understand a specific part of code of an app|
|Memory Watchpoints||Watch/monitor variable value during program execution||Determine which functions modify a certain variable (e.g. Find where the amount of money is subtracted from)|
|Memory Read||Look up variables and arguments to verify an algorithm||Look up variables and arguments to understand the logic of a function|
|Memory Write||Modify a variable to check the results of an algorithm under different conditions||Overwrite a variable’s value (e.g. change the amount of money in a game or unlock an in-app purchase)|
|Stack Backtrace||Find out the code path to a function to make sure it’s invoked correctly||Find out the caller of a function, so that an attacker knows what to analyze next|
|Conditional Breakpoints||Optimize time spent on manual look-ups||Pause to find out how specific variables or arguments influence the execution|
To demonstrate how a debugger can be used to reverse engineering and tamper with a mobile application, let’s bring our adversary: OWASP’s Android Uncrackable Level 1 into the ring! This training app performs Java based root detection. We will attach LLDB, a native debugger, to the app and show you how simple LLDB commands can quickly bend the app’s functionality.
Spoiler Alert: The program’s Java based root detection can be bypassed with a single line in LLDB. On top of that, it can also be used on iOS in the exact same way.
Uncrackable Level 1 is a training app created by the OWASP foundation to teach people about analyzing mobile apps. The app contains a hardcoded secret string inside that users must try to extract. To make it more challenging for the reverse engineers, the app features root detection. Therefore, whenever you open the app on a rooted device, you will immediately see an error message complaining about the rooted device, afterwich the app is exited automatically.
The most popular way to detect a rooted Android device is to search for the
subinary on the filesystem. This file should not be present on non-rooted Android devices to make sure no app or user has higher privileges. Usually, the file is located at
/system/bin/subut its location can change depending on the rooting tool, manufacturer and the OS version of the device. To bypass this check, we need to trick Java APIs into believing that the
sufile doesn’t exist, even though it’s present on the filesystem. This will allow us to bypass the app's root detection.
Uncrackable Level 1 is entirely written in Java, so the app can check for the
subinary using a number of APIs:
Now you might wonder how on earth it is possible to hook Java functions with LLDB (which is a debugger for native code). The answer is simple: you can’t…. However, instead of hooking Java, we can put a breakpoint at the native layer to achieve the same. Because at the very bottom of our software stack no one speaks “Java”, the interface will eventually be native. For example, opening a file in Java or Kotlin source code triggers an eventual native libc
fopencall that tells the kernel what to do; this is the point where we will intercept.
You can think of this approach as a “sink”, where all Java’s API calls pour into a native-land drain hole where we will intercept them one by one.
access(const char *pathname, int mode)is exactly such a sink. It's a standard way of checking whether a file exists on a filesystem by the Java engine. In fact, anytime we use a Java API to create, delete or rename a file, the standard library will use
access(..)to check if it exists beforehand. So, by hooking a single native function, we can intercept multiple Java calls to the system.
This means that it doesn’t really matter how exotic the Java API you use to check for a file's existence is, because all of them will eventually end up at the same place - libc.
Android Studio and Xcode use LLDB under the hood as native code debugger, which developers can interact with via a command line or a fancy GUI, which basically runs LLDB commands for them. In this article, we will use the command line debugger directly because it allows for easy customization but a determined user could perform all the steps from within a GUI as well.
A typical use case for a debugger is to insert breakpoints at specific functions and access the value of variables there. We will show you how you can use this same LLDB feature to bypass root detection in the OWASP Crackme. We will insert a breakpoint at
libc’accessand look up the file path argument. If the file path contains the
sustring, we will simply replace one letter of the file path. This directs the code to check a different (non-existent) file instead of the
After attaching LLDB to the app, you can insert a breakpoint at
libc’access(char*, int)with the following command:
(lldb)$ breakpoint set -n access
Now, whenever we use any Java function to open/check/remove a file, this breakpoint will trigger. The breakpoint will allow us to analyze the function execution with additional LLDB commands.
We need to ensure that the app’s root detection won’t find any
subinary on the file-system. To accomplish this, we’ll use conditional breakpoints. With a conditional breakpoint, we can instruct LLDB to pause at a specific function only if a certain argument satisfies our constraints. For our purposes, the constraint is that a file path must contain a
susubstring, since we don’t care about any other file paths.
In LLDB, you can add a condition to your breakpoint with the
-cflag. LLDB supports the use of C/C++/Objective-C/Swift languages for scripting, so we can use the
strstr(char* string, char *substring)function to check whether
suis in the provided path.If the
suis in the provided path, the
strstr()function returns a pointer to the beginning of the substring, otherwise, it returns zero.
The file path is the first argument of
libc’access(char*, int), so we can find it in the
x0register. The updated command looks like this:
(lldb)$ breakpoint set -n access -c "(char*)strstr($x0, \"su\") != 0x0"
With this in place, LLDB will pause whenever
access(..)is invoked and the file path contains
Finally, we need to replace a letter in the
sufile paths to make sure that
access(...)won’t find those files, which in turn bypasses the root detection of the app
When a breakpoint is hit, users can instruct LLDB to execute some code. This is done by using the
-Cflag. In this example, we will replace the first letter of the file path with an ASCII “0”. LLDB allows you to write to memory with
memory write "address" "new-value". We will use this expression with the
-Cflag to execute it when our conditional breakpoint is hit.
(lldb)$ breakpoint set -n access -c "(char*)strstr($x0, \"su\") != 0x0" -C "mem write $x0 0x30"
To make the whole script more transparent, we can make it print all the file paths that match the condition and will be modified by having LLDB execute one more expression for us. We can add another
-Cflag with mem read
-f -s $x0which will read the path passed to
access(...)and print it for us.
The screenshot below shows the output of LLDB’s combined command when used on Uncrackable Level 1. Using these commands, we not only bypassed the root detection of the app, but also revealed all file paths that were checked.
Remember the error message that originally appeared in the app when it detected a rooted device? It’s gone! The screenshot below shows the application opened without an alert, which means that we successfully bypassed the root detection feature!
Other RE tools vs debuggers
Reverse engineering can be a daunting subject for developers. Many developers don’t realize they already dispose of a great set of skills that translate very well to reverse engineering; debugging their code. In this blog post we explored the parallels between using LLDB to debug an application and leveraging it for reverse engineering.
We showed the potential of LLDB as a reverse engineering toolbox, even for Java applications without any actual native code. By considering that all Java APIs that interact with a system eventually end up in the native world, we can actually speed up the whole tampering process significantly. Rather than having to deal with all possible Java APIs, we can hook a native library at a single location and catch all calls coming from Java that interact with the system.
Obviously, the Uncrackable Level 1 challenge is a basic example. If an app requires more complicated tampering, using LLDB’s command line interface may become cumbersome. When this happens, LLDB Python Scripting is a great alternative; it allows you to create more advanced scripts.
A second takeaway for developers is to keep in mind that even when your code is not native, eventually any high level language API call that interacts with the system has to go through native system libraries. Therefore it’s important to not only protect your code from malicious tools and techniques, but to also consider native tooling that can be used for attacks on lower levels.
Here are a few additional tips you can use to protect your app against debuggers: