November 2, 2022

    How Development Skills Translate to Reverse Engineering: Using LLDB to Reverse Engineer an Android App

    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.

    Debugger Feature Developer Attacker
    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.

    Cracking uncrackable Level 1

    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.

    How development skills translate to reverse engineering, using LLDB to reverse engineer an Android app_diagram-1

    What is root detection?

    The most popular way to detect a rooted Android device is to search for thesubinary 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 thesufile doesn’t exist, even though it’s present on the filesystem. This will allow us to bypass the app's root detection.

    Our plan of attack

    Uncrackable Level 1 is entirely written in Java, so the app can check for thesubinary using a number of APIs:

    • new File(“/system/bin/su”)).exists()
    • Files.exists(Paths.get(“/system/bin/su”))
    • Context.openFileInput(String filename)
    • … and probably many more …

    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 libcfopencall 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.

    How development skills translate to reverse engineering, using LLDB to reverse engineer an Android app_diagram-2

    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 useaccess(..)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.

    LLDB - the Android Studio debugger

    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 atlibc’accessand look up the file path argument. If the file path contains thesustring, we will simply replace one letter of the file path. This directs the code to check a different (non-existent) file instead of thesufile.

    Inserting a breakpoint

    After attaching LLDB to the app, you can insert a breakpoint atlibc’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.

    Conditional breakpoints

    We need to ensure that the app’s root detection won’t find anysubinary 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 asusubstring, 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 thestrstr(char* string, char *substring)function to check whethersuis in the provided path.If thesuis in the provided path, thestrstr()function returns a pointer to the beginning of the substring, otherwise, it returns zero.

    The file path is the first argument oflibc’access(char*, int), so we can find it in thex0register. The updated command looks like this:

    (lldb)$ breakpoint set -n access -c "(char*)strstr($x0, \"su\") != 0x0"

    With this in place, LLDB will pause wheneveraccess(..)is invoked and the file path containssu.

    Modifying arguments on the fly

    Finally, we need to replace a letter in thesufile paths to make sure thataccess(...)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 withmemory 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 toaccess(...)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.

    How development skills translate to reverse engineering, using LLDB to reverse engineer an Android app_diagram-3

    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!

    How development skills translate to reverse engineering, using LLDB to reverse engineer an Android app_diagram-4

    Other RE tools vs debuggers

    The whole attack could also have been performed with other reverse engineering tools, such as Frida, in a very similar way. But since LLDB is a part of many development tools, it’s more intuitive for developers without knowledge of hooks, trampolines and JavaScript scripting. What’s more, developers can easily use C/C++/Swift with LLDB, which makes it even more powerful in their hands because they can simply use languages they are already familiar with. LLDB also offers Python scripting in case more advanced scripting is needed.

    Conclusion

    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.

    What can you do?

    Here are a few additional tips you can use to protect your app against debuggers:

    • At runtime, check if there is any type of debugger attached to your app.
    • Make sure that an attacker doesn’t replace the app’s Manifest or Entitlements because that would allow them to debug the app on a non-rooted, non-jailbroken device.
    • On a rooted device, an attacker doesn’t need to modify your app to attach a debugger, so you might want to implement root/jailbreak detections as an additional protection.
    • Keep your sensitive variables encrypted. This prevents an attacker from easily finding them in the app’s memory and using a debugger watchpoint to locate the functions that update them.
    Tag(s): Android , Technical

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in