Android App Penetration Testing & Frida Hooks

Ghidra SRE for Android NDK: Advanced Static Analysis & Dynamic Augmentation with Frida

Google AdSense Native Placement - Horizontal Top-Post banner

Unveiling Native Secrets: Android NDK Reverse Engineering with Ghidra and Frida

Android applications often leverage the Native Development Kit (NDK) to implement performance-critical components, protect intellectual property, or integrate with existing C/C++ libraries. While Java/Kotlin code is relatively straightforward to decompile, native libraries (.so files) present a significantly tougher challenge. This guide explores a powerful synergistic approach combining Ghidra for advanced static analysis and Frida for dynamic instrumentation, enabling deep insights into even the most obfuscated Android NDK binaries.

Setting Up Your Reverse Engineering Environment

Before diving into the analysis, ensure your environment is configured:

  • Ghidra: Download and install the latest version from the official NSA GitHub.
  • Android SDK/NDK: Essential for obtaining necessary tools like adb and understanding the Android build process.
  • Frida-tools: Install via pip install frida-tools. You’ll also need the Frida server running on your target Android device (rooted or with a patched ROM).
  • Target Device/Emulator: A rooted Android device or an emulator with root access is crucial for Frida’s dynamic capabilities.

Ensure adb is connected to your device and the Frida server is running:

adb shell
su
/data/local/tmp/frida-server &

Ghidra: The Static Analysis Powerhouse for NDK Binaries

Ghidra excels at disassembling and decompiling native binaries. For Android NDK reverse engineering, you’ll typically focus on shared libraries (.so files) extracted from an APK.

1. Extracting and Loading the Native Library

First, extract the APK, then locate the .so file within lib/<architecture>/ (e.g., lib/arm64-v8a/libnative-lib.so). Import this into Ghidra:

  1. Launch Ghidra and create a new project.
  2. File > Import File… > Select your .so file.
  3. Ghidra will prompt for language and endianness. For modern Android, typically AARCH64 or ARM (depending on the device architecture) and little-endian.
  4. After import, double-click the file to open the CodeBrowser.

2. Initial Exploration: JNI_OnLoad and Native Methods

Upon opening, Ghidra performs auto-analysis. Key areas to investigate:

  • JNI_OnLoad: This function is the entry point for the native library, executed when the JVM loads the library. It often registers native methods. Look for calls to RegisterNatives within its decompiled code.
  • Exported Functions: In the Symbol Tree, navigate to “Exports”. You’ll find functions explicitly exported by the library, often including JNI native methods (e.g., Java_com_example_app_MainActivity_nativeFunction).

Let’s consider a simple scenario where a native function Java_com_example_app_MainActivity_checkLicense might be present.

JNIEXPORT jboolean JNICALL Java_com_example_app_MainActivity_checkLicense(
    JNIEnv* env, jobject instance, jstring licenseKey) {
    // ... license key validation logic ...
    if (strcmp(convertedLicenseKey, "CORRECT_KEY_123") == 0) {
        return JNI_TRUE;
    }
    return JNI_FALSE;
}

In Ghidra, locate this function. Analyze its decompiled C code. Identify critical comparisons, string operations, or cryptographic routines. For instance, if you see a call to strcmp or a custom hashing algorithm, this indicates where the logic resides.

3. Deeper Analysis: Data Structures and Control Flow

Ghidra’s decompiler is invaluable. Examine:

  • Local Variables and Parameters: Understand what data the function operates on.
  • Control Flow: Identify conditional branches (if/else, switch), loops, and function calls.
  • Cross-References: Use Ghidra’s “References” window to see where a function is called from or where a specific data item is accessed. This helps in understanding the call graph and data flow.

Frida: Dynamic Augmentation and Runtime Manipulation

Static analysis can reveal much, but runtime behavior often holds the key to bypassing checks or understanding complex interactions. Frida allows you to inject scripts into running processes, enabling powerful dynamic analysis.

1. Identifying Target Functions and Addresses

From your Ghidra analysis, you’ve identified interesting native functions (e.g., checkLicense) and potentially their internal logic. Now, you need their runtime addresses. While Ghidra gives static offsets, Frida uses runtime addresses. For exported functions, Frida can resolve them by name. For internal, non-exported functions, you’ll calculate the base address of the loaded library and add the Ghidra offset.

Java.perform(function() {
    var libnative = Module.findBaseAddress("libnative-lib.so");
    if (libnative) {
        console.log("libnative-lib.so loaded at: " + libnative);
        // If Ghidra shows function 'my_internal_func' at offset 0x1234
        // var myInternalFuncPtr = libnative.add(0x1234);
    }
});

2. Crafting a Frida Hook for Native Methods

Let’s assume our Java_com_example_app_MainActivity_checkLicense function is an exported symbol. We can directly hook it by name.

Java.perform(function () {
    console.log("Starting Frida script...");
    var targetLib = "libnative-lib.so"; // Replace with your library name

    // Hooking an exported JNI function
    var checkLicensePtr = Module.findExportByName(targetLib, "Java_com_example_app_MainActivity_checkLicense");

    if (checkLicensePtr) {
        console.log("Found checkLicense at: " + checkLicensePtr);
        Interceptor.attach(checkLicensePtr, {
            onEnter: function (args) {
                console.log("Java_com_example_app_MainActivity_checkLicense called!");
                // args[2] would be the jstring licenseKey (JNIEnv*, jobject, jstring)
                var jstring_key = new Java.wrappers.JNIEnv(this.context.r0).getStringUtfChars(args[2], null).readCString();
                console.log("License Key provided: " + jstring_key);
                // Optionally modify arguments:
                // Java.perform(function(){
                //     var newKey = Java.use("java.lang.String").$new("BYPASS_KEY");
                //     args[2] = newKey.get  // This requires more complex JNIEnv interaction
                // });
            },
            onLeave: function (retval) {
                console.log("Original return value: " + retval);
                // Force the function to return true (JNI_TRUE = 1)
                retval.replace(1);
                console.log("Modified return value to: " + retval);
            }
        });
    } else {
        console.log("checkLicense function not found in " + targetLib);
    }
    console.log("Frida script loaded successfully.");
});

To run this script:

frida -U -l your_script.js -f com.example.app --no-pause

Where com.example.app is the package name of your target application.

Synergy: Bridging Ghidra and Frida

The real power emerges when you use Ghidra and Frida iteratively:

  1. Ghidra for Initial Reconnaissance: Use Ghidra to get a high-level overview, identify potential target functions, and understand their logic from decompiled C.
  2. Frida for Confirmation & Dynamic Context: Use Frida to hook these functions, observe their actual inputs and outputs at runtime, and validate your static analysis assumptions. This is critical for understanding dynamically generated values or encrypted data.
  3. Ghidra for Refined Analysis: If Frida reveals unexpected behavior or values, return to Ghidra to re-examine the relevant code paths with newfound dynamic context. This might involve looking at function calls that were previously overlooked or understanding how specific values are derived.
  4. Frida for Manipulation & Bypass: Once you understand the function’s purpose, use Frida to modify arguments, return values, or even inject new code to bypass security checks or alter application flow.
  5. Handling Obfuscation: When code is heavily obfuscated, Ghidra might struggle to produce clean decompilation. Frida can help by hooking functions *before* or *after* obfuscation routines, allowing you to see clear-text data or function calls that Ghidra couldn’t resolve.

Advanced Tip: Address Calculation for Non-Exported Functions

Many interesting functions are not exported. Ghidra provides their offsets relative to the start of the .so file. To hook these, you need to calculate their runtime address:

Java.perform(function() {
    var baseAddress = Module.findBaseAddress("libnative-lib.so");
    if (baseAddress) {
        // Ghidra reports 'internal_func' at offset 0x1A2B0
        var internalFuncAddress = baseAddress.add(0x1A2B0);
        console.log("Internal function address: " + internalFuncAddress);
        Interceptor.attach(internalFuncAddress, {
            onEnter: function(args) {
                console.log("internal_func called!");
            }
        });
    }
});

Conclusion

Reverse engineering Android NDK binaries is a challenging but rewarding endeavor. By masterfully combining Ghidra’s powerful static analysis capabilities with Frida’s dynamic instrumentation framework, security researchers and penetration testers can gain unprecedented visibility into native code. This synergy allows for efficient identification of vulnerabilities, bypass of security mechanisms, and a deeper understanding of complex Android applications, making it an indispensable toolkit for advanced mobile app penetration testing.

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