Android App Penetration Testing & Frida Hooks

Advanced Android NDK Root Detection Bypass: A Frida & Ghidra Deep Dive into Native Checks

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to Native Root Detection and Bypass

Android applications, especially those handling sensitive data or digital rights management (DRM), frequently implement root detection mechanisms to prevent unauthorized access and manipulation. While many checks occur at the Java layer, more sophisticated applications offload these critical checks to native libraries (NDK), making them significantly harder to bypass. This article provides an expert-level guide on how to reverse engineer and bypass native root detection using a powerful combination of static analysis with Ghidra and dynamic instrumentation with Frida.

Why Native Root Detection?

Native root detection bypasses many common Java-level hooking frameworks and obfuscation techniques. By implementing checks in C/C++, developers gain finer control over system calls and memory, making the analysis and manipulation more challenging for attackers.

Understanding Native Root Detection Techniques

NDK-based root checks often leverage system calls and file system operations that are difficult to intercept from the Java layer. Common techniques include:

  • File/Directory Presence Checks: Searching for known root-related files and directories (e.g., /system/bin/su, /sbin/su, /system/xbin/su, /data/local/tmp/su, /system/app/Superuser.apk).
  • System Property Checks: Examining ro.build.tags for “test-keys” or ro.secure values.
  • Process Checks: Looking for running processes associated with root tools (e.g., magiskd, daemonsu).
  • ptrace Detection: Detecting if a debugger is attached by attempting to ptrace itself or checking /proc/self/status for TracerPid.
  • Mount Information: Checking /proc/mounts for suspicious mounts (e.g., /dev/tmpfs on /magisk).

Phase 1: Static Analysis with Ghidra

Our journey begins with static analysis using Ghidra to understand the native library’s structure and identify potential root check functions.

Locating the Native Library

First, extract the APK and locate the relevant native library (e.g., libnative-lib.so) typically found in lib/armeabi-v7a/ or lib/arm64-v8a/.

Loading into Ghidra

Open Ghidra, create a new project, and import the .so file. Ensure the correct architecture (ARM/AArch64) is selected. Allow Ghidra to analyze the binary.

Identifying Root Check Functions

In Ghidra’s Symbol Tree, look for exported functions that might indicate root checks (e.g., Java_com_example_app_RootChecker_isRootedNative, checkRootStatus). If no clear symbols exist, resort to string searching:

  1. Navigate to Search -> For Strings.
  2. Search for common root indicators like /system/bin/su, /sbin/su, test-keys, magisk.
  3. Analyze the cross-references (Xrefs) to these strings to find the functions that use them.

Let’s assume we find a function named isRootedCheck using string analysis. Its pseudocode might look something like this:

BOOL isRootedCheck() {    FILE *__s;    BOOL bVar1;    __s = fopen("/system/bin/su", "r");    if (__s != (FILE *)0x0) {        fclose(__s);        bVar1 = TRUE;    }    else {        __s = fopen("/sbin/su", "r");        if (__s != (FILE *)0x0) {            fclose(__s);            bVar1 = TRUE;        }        else {            // ... other checks ...            bVar1 = FALSE;        }    }    return bVar1;}

From this, we see the function opens specific paths, suggesting file presence checks.

Phase 2: Dynamic Instrumentation with Frida

Once we’ve identified the native functions and logic, Frida comes into play for dynamic analysis and runtime patching.

Setting up Frida

Ensure you have a rooted Android device/emulator with Frida-server running and ADB configured. Install Frida on your host machine: pip install frida-tools.

Basic Frida Script Structure

To interact with native libraries, we use Module.findExportByName or Module.findBaseAddress and then offset functions. Let’s target the isRootedCheck function we found in Ghidra.

Java.perform(function() {    var module_name = "libnative-lib.so";    var target_module = Module.findExportByName(module_name, "isRootedCheck"); // If exported    if (!target_module) {        // If not exported, find by base address + offset        var base_address = Module.findBaseAddress(module_name);        if (base_address) {            // Replace '0x1234' with the actual offset found in Ghidra            target_module = base_address.add(0x1234);        }    }    if (target_module) {        console.log("[*] Hooking isRootedCheck at: " + target_module);        Interceptor.attach(target_module, {            onEnter: function(args) {                console.log("[+] isRootedCheck called!");            },            onLeave: function(retval) {                console.log("[*] Original return value: " + retval);                retval.replace(0); // Force return FALSE                console.log("[+] Modified return value to: " + retval);            }        });    } else {        console.log("[-] Could not find isRootedCheck in " + module_name);    }});

Run with: frida -U -l your_script.js -f com.your.package.name --no-pause

Bypassing Specific Native Checks

1. File Presence Checks (fopen, access, stat)

Instead of hooking the application’s internal check function, you can hook the underlying system calls it uses. This is more robust as it catches all file system checks, not just specific functions.

Java.perform(function() {    var fopenPtr = Module.findExportByName(null, "fopen");    if (fopenPtr) {        Interceptor.attach(fopenPtr, {            onEnter: function(args) {                this.filePath = Memory.readUtf8String(args[0]);                if (this.filePath.includes("/su") || this.filePath.includes("magisk")) {                    console.log("[+] fopen called for suspicious path: " + this.filePath);                    this.isSuspicious = true;                }            },            onLeave: function(retval) {                if (this.isSuspicious) {                    console.log("[*] Blocking fopen for " + this.filePath + ". Original return: " + retval);                    retval.replace(0); // Return NULL (failure to open file)                }            }        });    }});

Similarly, you can hook access, stat, lstat, etc., to return specific error codes or manipulate their return values.

2. ptrace Detection Bypass

Applications might try to ptrace themselves to detect if a debugger is already attached. We can hook ptrace to make it always succeed or fail as desired.

Java.perform(function() {    var ptracePtr = Module.findExportByName(null, "ptrace");    if (ptracePtr) {        Interceptor.attach(ptracePtr, {            onEnter: function(args) {                var request = args[0].toInt32();                var pid = args[1].toInt32();                console.log("[+] ptrace called: request=" + request + ", pid=" + pid);                // PTRACE_TRACEME (0) is common for anti-debugging                if (request === 0) {                    console.log("[*] Forcing PTRACE_TRACEME to fail to hide debugger.");                    this.skipCall = true; // Skip the original call                }            },            onLeave: function(retval) {                if (this.skipCall) {                    retval.replace(-1); // Return -1 (failure) or 0 (success) depending on desired effect                    // To truly hide, sometimes returning 0 (success) is better if the app expects ptrace to attach.                    // Experimentation needed for specific implementation.                    console.log("[+] ptrace return value modified to " + retval + ".");                }            }        });    }});

3. System Property Checks

If an app reads ro.build.tags, it might use __system_property_get. Hooking this can control the output.

Java.perform(function() {    var propGetPtr = Module.findExportByName(null, "__system_property_get");    if (propGetPtr) {        Interceptor.attach(propGetPtr, {            onEnter: function(args) {                var propName = Memory.readUtf8String(args[0]);                this.propValuePtr = args[1];                if (propName === "ro.build.tags") {                    console.log("[+] Intercepted __system_property_get for: " + propName);                    this.isTargetProp = true;                }            },            onLeave: function(retval) {                if (this.isTargetProp) {                    Memory.writeUtf8String(this.propValuePtr, "release-keys");                    console.log("[+] Modified ro.build.tags to 'release-keys'.");                }            }        });    }});

Phase 3: Advanced Bypass with Memory Patching

For more permanent or aggressive bypasses, especially when function hooks are detected or insufficient, direct memory patching can be used. This involves identifying the exact instruction in Ghidra that determines the root check outcome and then using Frida to modify it at runtime.

Identifying Patch Points in Ghidra

Revisit Ghidra’s disassembly view of our isRootedCheck function. Look for conditional jump instructions (e.g., BEQ, BNE, CMP followed by JMP) immediately after the root check logic. For example, if a check sets a register to 0 or 1, and then a CMP instruction checks that register, you might find something like:

0xXXXXXXXX: CMP R0, #0    ; Compare R0 (result of check) with 00xYYYYYYYY: BNE LAB_XXXXXXXX ; Branch if Not Equal (if R0 is 1, branch to 'rooted' logic)

To bypass, we want to ensure the branch is never taken (or always taken, depending on logic). We can NOP out the branch or change its condition.

Frida Memory Patching

Assuming the BNE instruction is at 0xYYYYYYYY relative to the module base, we can patch it using Frida. For ARM, a NOP instruction is 0xF000F000 or 0x1EE0A000 (specific to Thumb/ARM mode). For AArch64, it’s 0xD503201F.

Java.perform(function() {    var module_name = "libnative-lib.so";    var base_address = Module.findBaseAddress(module_name);    if (base_address) {        // Replace 0xYYYYYYYY with the actual offset of the instruction to patch        var target_address = base_address.add(0xYYYYYYYY);        console.log("[+] Patching instruction at: " + target_address);        // Example for AArch64 (4 bytes NOP)        Memory.patchCode(target_address, 4, function(code) {            var writer = new Arm64Writer(code, { pc: target_address });            writer.putNop(); // Write a NOP instruction            writer.flush();        });        // Example for ARM (4 bytes NOP, adjust based on Thumb/ARM)        // Memory.patchCode(target_address, 4, function(code) {        //     var writer = new ArmWriter(code, { pc: target_address });        //     writer.putNop();        //     writer.flush();        // });        console.log("[+] Patch applied successfully.");    } else {        console.log("[-] Could not find base address of " + module_name);    }});

This approach directly modifies the machine code, making the branch instruction effectively disappear or change its behavior, thus altering the root detection logic at its core.

Conclusion

Bypassing advanced Android NDK root detection requires a systematic approach combining static and dynamic analysis. Ghidra empowers us to deeply understand the native code and identify critical functions and vulnerable instructions. Frida then provides an unparalleled platform for dynamically intercepting, modifying, and even patching these native checks at runtime. By mastering these tools and techniques, security researchers and penetration testers can effectively analyze and overcome even the most sophisticated native anti-tampering measures, ensuring comprehensive security assessments of Android 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