Unleashing the Debugging Magic: Cracking Android Native Shared Libraries

Debraj Basak
7 min readJun 25, 2024

--

Debugging native code in Android applications can often feel like venturing into a maze. However, armed with the right tools and steps, you can transform this journey into a fascinating and educational experience. In this article, we’ll walk through the entire process of debugging Android native shared libraries, creating an application, analyzing it, and showcasing the results. Let’s embark on this adventure!

Setting the Scene

Before we dive into the code, let’s ensure we have our toolkit ready. You’ll need:

  • Android Studio : The IDE for Android development.
  • NDK (Native Development Kit) : To develop native C/C++ code for Android.
  • adb (Android Debug Bridge) : For communicating with your Android device.
  • gdb (GNU Debugger) : To debug the native code.

Step 1: Preparing the Environment

Start by setting up your development environment. Open your terminal and set the necessary paths:

export NDK_PATH=/home/debraj/ndk
export PATH=$PATH:$NDK_PATH/toolchains/llvm/prebuilt/linux-x86_64/bin

Step 2: Creating an Example Application

We’ll create a simple Android application that utilizes a native library. Open Android Studio and create a new project. Choose a basic activity and name your project `NativeDebugApp`.

Adding Native Code

Navigate to the `app` directory and create a `jni` folder. Inside this folder, create a file named `native-lib.cpp` and add the following code:

#include <jni.h>
#include <string>
#include <vector>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_nativedebugapp_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_nativedebugapp_MainActivity_hiddenPIN(
JNIEnv* env,
jobject /* this */) {
std::string pin = "1234";
return env->NewStringUTF(pin.c_str());
}
extern "C" JNIEXPORT jint JNICALL
Java_com_example_nativedebugapp_MainActivity_calculateSum(
JNIEnv* env,
jobject /* this */,
jint a,
jint b) {
return a + b;
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_example_nativedebugapp_MainActivity_getStringArray(
JNIEnv* env,
jobject /* this */) {
std::vector<std::string> strings = {"Hello", "from", "native", "code"};
jobjectArray jstrings = env->NewObjectArray(strings.size(), env->FindClass("java/lang/String"), env->NewStringUTF(""));
for (int i = 0; i < strings.size(); i++) {
env->SetObjectArrayElement(jstrings, i, env->NewStringUTF(strings[i].c_str()));
}
return jstrings;
}

Modify your `CMakeLists.txt` to include this new file:

cmake_minimum_required(VERSION 3.4.1)
add_library( native-lib SHARED
native-lib.cpp )
find_library( log-lib
log )
target_link_libraries( native-lib
${log-lib} )

Update your `MainActivity.java` to call these native methods:

package com.example.nativedebugapp;
import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("native-lib");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TextView tv = findViewById(R.id.sample_text);
tv.setText(stringFromJNI());
// Display the hidden PIN in the log
String pin = hiddenPIN();
android.util.Log.d("NativeDebugApp", "Hidden PIN: " + pin);
// Calculate sum
int sum = calculateSum(5, 10);
android.util.Log.d("NativeDebugApp", "Sum: " + sum);
// Get string array
String[] stringArray = getStringArray();
for (String s : stringArray) {
android.util.Log.d("NativeDebugApp", s);
}
}
public native String stringFromJNI();
public native String hiddenPIN();
public native int calculateSum(int a, int b);
public native String[] getStringArray();
}

Step 3: Building the Application

Build your project by clicking on the Build button in Android Studio. This will compile your native code along with the rest of the application.

Step 4: Preparing for Debugging

To debug our native library, we need to ensure it’s built with debug symbols. Modify your `CMakeLists.txt` to include the debug flag:

set(CMAKE_BUILD_TYPE Debug)

Rebuild your project to generate the necessary debug symbols.

Step 5: Deploying to Device

Use `adb` to push the compiled native library to your Android device:

adb push /home/debraj/NativeDebugApp/app/build/intermediates/merged_native_libs/debug/out/lib/armeabi-v7a/libnative-lib.so /data/local/tmp/

Step 6: Launching the Application

Start your application and locate the process ID (PID) of your running app:

adb shell ps | grep com.example.nativedebugapp

Step 7: Setting Up gdbserver

Launch `gdbserver` on your device, attaching it to your app’s process:

adb shell gdbserver :5039 - attach <PID>

Step 8: Connecting gdb to gdbserver

In a new terminal, forward the port and connect `gdb` to `gdbserver`:

adb forward tcp:5039 tcp:5039
gdb

Step 9: Loading Symbols and Debugging

Within `gdb`, load the symbols for your native library:

(gdb) target remote :5039
(gdb) symbol-file /home/debraj/NativeDebugApp/app/build/intermediates/merged_native_libs/debug/out/lib/armeabi-v7a/libnative-lib.so
(gdb) b Java_com_example_nativedebugapp_MainActivity_hiddenPIN
(gdb) b Java_com_example_nativedebugapp_MainActivity_calculateSum
(gdb) b Java_com_example_nativedebugapp_MainActivity_getStringArray
(gdb) c

Step 10: Analyzing and Debugging

Now you are set to debug your native code. Let’s perform a detailed analysis:

Setting Breakpoints

Set breakpoints at key functions to inspect their runtime behavior. In this case, we set breakpoints at `hiddenPIN`, `calculateSum`, and `getStringArray` functions to monitor their behavior:

(gdb) b Java_com_example_nativedebugapp_MainActivity_hiddenPIN
(gdb) b Java_com_example_nativedebugapp_MainActivity_calculateSum
(gdb) b Java_com_example_nativedebugapp_MainActivity_getStringArray

Stepping Through the Code

Use the `next` command to step through the code line by line. This helps in understanding how the code executes and identifying any anomalies:

(gdb) n # Step to the next line
(gdb) p pin # Print the value of the 'pin' string
(gdb) p a # Print the value of the first integer
(gdb) p b # Print the value of the second integer
(gdb) p result # Print the result of the sum calculation
(gdb) p strings # Print the string array

Inspecting Variables

By inspecting the `pin`, `a`, `b`, `result`, and `strings` variables, we can verify the values and ensure they are correct. This is crucial for verifying that the native code is functioning as expected:

(gdb) p pin
$1 = std::string = "1234"
(gdb) p a
$2 = 5
(gdb) p b
$3 = 10
(gdb) p result
$4 = 15
(gdb) p strings
$5 = {"Hello", "from", "native", "code"}

Analyzing Memory

Check the memory allocation of the variables to ensure they are correctly allocated and not causing segmentation faults:

(gdb) info proc mappings

This command provides details about the memory mappings of the process, helping identify any memory-related issues.

Validating Memory Access

Ensure that the variables are accessed correctly and there are no out-of-bounds accesses. This helps in preventing segmentation faults and other memory access errors.

Inspecting Call Stack

Review the call stack to trace the function calls leading up to the breakpoint. This provides context on how the code execution reached the current state:

(gdb) backtrace

The call stack is essential for understanding the sequence of function calls and identifying any unexpected behavior.

Example Analysis Output

Let’s analyze a scenario where our application crashes due to an issue in the `getStringArray` method. Using gdb, we can pinpoint the issue to an incorrect array handling operation:

(gdb) n
(gdb) p strings
$5 = {"Hello", "from", "native", "code"}
(gdb) c
Program received signal SIGSEGV, Segmentation fault.

In this scenario, the debugger helps us identify that the crash occurs due to a segmentation fault, likely caused by an invalid memory access in our array handling. We can then go back to our native code, inspect the array handling, and correct the issue.

Detailed Debugging Steps

1. Identifying the Faulty Line: Use the `list` command to identify the exact line in the source code causing the issue.

(gdb) list

2. Examining Variables: Use the `print` command to examine the values of all relevant variables at the time of the crash.

(gdb) p variable_name

3. Checking Registers: Inspect the CPU registers to understand the state of the application at the time of the crash.

(gdb) info registers

4. Memory Dumps: Use the `x` command to perform memory dumps around the faulty memory address to get a better understanding of the memory layout.

(gdb) x/10x $esp

5. Conditional Breakpoints: Set conditional breakpoints to break execution only when certain conditions are met, helping in isolating the issue.

(gdb) break file.cpp:line if condition

6. Watchpoints: Use watchpoints to monitor the changes to specific variables or memory addresses.

(gdb) watch variable_name

Visualization

Below is a diagram that illustrates the interaction between Java and native code in our application:

Comprehensive Debugging Analysis

During debugging, we follow a structured approach to identify, analyze, and resolve issues. Here’s a detailed step-by-step analysis:

1. Reproducing the Issue: First, we reproduce the issue consistently to understand the conditions under which it occurs. This involves running the application and interacting with it to trigger the bug.

2. Setting Up Debugging Tools: Next, we set up our debugging environment using gdb and gdbserver, ensuring we have access to the necessary symbols and debug information.

3. Isolating the Faulty Code: We then use breakpoints and step-through commands to isolate the faulty code. This involves setting breakpoints at suspected locations and stepping through the code line by line.

4. Inspecting Variables and Memory: During this process, we inspect variables and memory to identify any anomalies. We check for null pointers, out-of-bounds accesses, and invalid memory operations.

5. Analyzing Call Stack: We review the call stack to trace the sequence of function calls leading up to the crash. This helps in understanding the context and identifying any unexpected behavior.

6. Verifying Fixes: After identifying the issue, we make the necessary fixes in the code and rebuild the application. We then repeat the debugging process to verify that the issue is resolved and no new issues are introduced.

Example Debugging Scenario

In our example, the application crashes due to a segmentation fault when accessing the `strings` array. By following the steps outlined above, we identify that the issue is caused by an invalid memory access. We fix the code by ensuring proper memory allocation and access, and verify the fix by re-running the application and observing the expected behavior.

Conclusion

Debugging native shared libraries on Android might seem challenging initially, but with these steps, you can confidently tackle any issues that arise. Remember, debugging is an iterative process of identifying, analyzing, and resolving issues. With practice, you’ll become proficient at navigating the complexities of native code. Happy debugging!

--

--

Debraj Basak

OSCP | CRTL | CRTO | CRTP | LPT | CPENT | CEH | IOT Security | OT/SCADA | Active Directory Exploitation | Reverse Engineer & Malware Analyst | Red Teamer | VAPT