Android Hacking, Sandboxing, & Security Exploits

Tracing Native Execution: From APK to ARM Assembly with Android NDK

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction

Android applications often extend beyond the Java/Kotlin realm, incorporating performance-critical or platform-specific logic written in C/C++ via the Native Development Kit (NDK). For security researchers, reverse engineers, and exploit developers, understanding and tracing this native execution flow is paramount. This guide will walk you through the process of dissecting an Android Package (APK), extracting its native libraries, disassembling the ARM assembly, and dynamically tracing its execution.

Understanding Android Native Libraries

Why Native Code?

Native code offers several advantages for Android developers:

  • Performance: Computationally intensive tasks, like graphics rendering, signal processing, or complex algorithms, can run significantly faster in native code.
  • Platform Features: Access to low-level hardware or OS features not exposed through the Java APIs.
  • Code Reusability: Leveraging existing C/C++ codebases from other platforms.
  • Obfuscation: While not a primary security measure, native code can sometimes be harder to reverse engineer than Java bytecode.

NDK Architecture Overview

When an Android application uses the NDK, it includes shared object files (.so) within its APK. These libraries are typically compiled for specific CPU architectures (e.g., armeabi-v7a, arm64-v8a, x86, x86_64). The Java Native Interface (JNI) acts as the bridge, allowing Java/Kotlin code to call functions implemented in native libraries and vice-versa. JNI functions in native code follow a specific naming convention, usually starting with Java_PackageName_ClassName_MethodName.

Extracting Native Libraries from an APK

Locating the .so Files

An APK is essentially a ZIP archive. Native libraries are typically found within the lib/ directory inside the APK, organized by architecture. For instance, an ARMv7-A library for a package might be at lib/armeabi-v7a/libnative-lib.so.

Tools for Extraction

You can extract these files using standard ZIP utilities or a command-line tool like unzip.

unzip your_app.apk -d extracted_apk

Navigate to extracted_apk/lib/ to find the architecture-specific subdirectories and the .so files.

Disassembly and Static Analysis

Choosing a Disassembler

For ARM assembly, powerful disassemblers are essential. Popular choices include:

  • Ghidra: Free, open-source, and highly capable, developed by NSA. Excellent for decompilation.
  • IDA Pro: Industry standard, powerful, but commercial.
  • radare2 (r2): Command-line centric, highly scriptable, open-source.

Initial Disassembly with Ghidra/IDA

Load your target .so file into your chosen disassembler. The tool will analyze the binary and present you with a view of its functions and their corresponding assembly code. Pay attention to the Exports window, which often lists JNI functions or other publicly accessible symbols.

Here’s a snippet of what a simple JNI function might look like in Ghidra’s decompiler view:

undefined4 Java_com_example_nativeapp_MainActivity_stringFromJNI(void){  // Some native logic...  return 0;}

In the disassembly view, you would see the ARM instructions for this function.

Identifying Key Functions

Look for functions matching the JNI naming convention. These are your entry points from the Java layer. From these entry points, you can then trace calls to other internal native functions. Also, search for strings, API calls (e.g., open, read, write, mmap), and cryptographic constants that might indicate sensitive operations.

Dynamic Tracing and Debugging

Setting up the Environment

Dynamic analysis involves running the app on a device or emulator and attaching a debugger. You’ll need:

  • ADB (Android Debug Bridge): For device interaction.
  • gdbserver: A debugging server binary for Android, usually found in your NDK installation (e.g., android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/lib/gcc/aarch64-linux-android/4.9.x/gdbserver). Push it to your device:
adb push path/to/gdbserver /data/local/tmp/gdbserver
  • GDB (GNU Debugger): The client debugger, also found in the NDK toolchain (e.g., android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android-gdb).

Attaching GDB to a Process

First, find the process ID (PID) of your target application:

adb shell ps | grep your.app.package

Then, start gdbserver on the device, attaching it to the target PID and listening on a port (e.g., 1234):

adb shell /data/local/tmp/gdbserver :1234 --attach YOUR_APP_PID

Forward the port from your host machine to the device:

adb forward tcp:1234 tcp:1234

Finally, launch GDB on your host machine and connect to the forwarded port:

path/to/aarch64-linux-android-gdbpath/to/aarch64-linux-android-gdb(gdb) target remote :1234

Load the symbolic information for your library within GDB:

(gdb) add-symbol-file /path/to/extracted_apk/lib/arm64-v8a/libnative-lib.so 0xXXXXXXXXXXXX // base address from /proc/YOUR_APP_PID/maps

You’ll need to find the base load address of your .so file using cat /proc/YOUR_APP_PID/maps on the device.

Setting Breakpoints and Examining Registers

Once attached, you can set breakpoints on native functions:

(gdb) b Java_com_example_nativeapp_MainActivity_stringFromJNI

Continue execution:

(gdb) c

When a breakpoint is hit, you can examine registers, memory, and step through assembly instructions:

  • info registers: View register values.
  • x/10i $pc: Examine 10 instructions at the program counter.
  • si / ni: Step instruction / Next instruction.

Practical Example: Tracing a Simple JNI Call

Compiling a Sample NDK App

Let’s assume a simple Android Studio NDK project with a JNI method stringFromJNI() in MainActivity.java and its implementation in native-lib.cpp:

// native-lib.cpp#include <jni.h>#include <string>extern "C" JNIEXPORT jstring JNICALLJava_com_example_nativeapp_MainActivity_stringFromJNI(JNIEnv* env, jobject /* this */) {    std::string hello = "Hello from C++";    return env->NewStringUTF(hello.c_str());}

Tracing the JNI Function

  1. Build and install the app on your device/emulator.
  2. Find the app’s PID using adb shell ps.
  3. Push gdbserver and start it attached to the PID.
  4. Forward TCP port 1234.
  5. Start aarch64-linux-android-gdb and connect.
  6. Determine the base address of libnative-lib.so from /proc/YOUR_APP_PID/maps.
  7. Load symbols: (gdb) add-symbol-file /path/to/libnative-lib.so 0xBASE_ADDRESS.
  8. Set a breakpoint: (gdb) b Java_com_example_nativeapp_MainActivity_stringFromJNI.
  9. Continue execution: (gdb) c.
  10. Interact with the app on the device (e.g., click a button that calls stringFromJNI()).
  11. GDB will hit the breakpoint. You can now use si to step through the assembly, observing the JNIEnv* and jobject arguments, and tracing the string creation and return.

Conclusion

Tracing native execution from an APK to ARM assembly is a fundamental skill for Android security professionals. By combining static analysis with powerful disassemblers and dynamic debugging techniques using GDB, you can gain deep insights into an application’s hidden logic. This mastery is crucial for identifying vulnerabilities, understanding malware behavior, or simply unraveling the complexities of NDK-powered applications.

Android Mobile Specs & Compare Directory

Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!

Compare Devices Specs →
Google AdSense Inline Placement - Content Footer banner