Android App Penetration Testing & Frida Hooks

Zero to Hero: Crafting Custom Frida Bypasses for Unique Android Root Detection Implementations

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to Android Root Detection and Frida

In the evolving landscape of mobile security, many Android applications, particularly those handling sensitive data like banking, payment, or DRM-protected content, implement sophisticated root detection mechanisms. These checks aim to prevent their execution on rooted devices, which could expose them to various security risks. For penetration testers and security researchers, bypassing these checks is a critical step in assessing an application’s true security posture.

Enter Frida, a dynamic instrumentation toolkit that allows you to inject scripts into running processes on Android (among other platforms). Frida empowers you to hook into functions, inspect memory, modify behavior, and ultimately, bypass many security controls, including root detection. This guide will take you from understanding common root detection strategies to crafting custom Frida scripts to neutralize them.

Understanding Common Root Detection Mechanisms

Before we can bypass root detection, we must understand how applications typically identify a rooted environment. Developers employ various techniques, often combining several for a more robust defense:

1. File and Path Existence Checks

This is one of the simplest and most common methods. Applications check for the presence of files or directories commonly associated with rooting tools or binaries. Examples include checking for /system/bin/su, /system/xbin/su, /sbin/su, /data/local/tmp/su, or files related to Magisk like /data/adb/magisk.

2. Package Name and Signature Checks

Apps might scan for known package names of root management applications (e.g., com.noshufou.android.su, eu.chainfire.supersu, com.topjohnwu.magisk) or even verify their signatures to ensure they are legitimate (though signature checks are less common for root detection specifically).

3. Dangerous Properties and Environment Variables

Android system properties can reveal if a device is rooted or modified. For instance, checking ro.build.tags for “test-keys” (which indicates custom ROMs or unofficial builds), or ro.secure not being equal to ‘1’. Environment variables might also be checked, although this is less frequent.

4. Command Execution and Output Analysis

Applications can execute commands like which su or su -c id and then parse the output or exit code to determine if the su binary is functional and if the current user has root privileges. This often involves Java’s Runtime.exec() method.

5. Native Library and SELinux Status Checks

More sophisticated apps might use native code (JNI) to perform root checks, making them harder to trace with Java-level hooks. This could involve checking for specific native libraries loaded by root solutions or querying the SELinux enforcement status, as some root solutions might modify it.

Setting Up Your Frida Environment

To begin, ensure you have Frida set up on your host machine and your Android device (physical or emulator).

  • Host Machine: Install Frida tools via pip:

    pip install frida-tools
  • Android Device: Download the appropriate frida-server for your device’s architecture (e.g., frida-server-*-android-arm64) from Frida’s GitHub releases. Push it to your device and run it:

    adb push frida-server /data/local/tmp/adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"

Verify Frida is running by listing processes:

frida-ps -U

Identifying Root Detection Logic: The Reconnaissance Phase

Before writing a bypass, you need to know what to bypass. This involves both static and dynamic analysis.

1. Static Analysis with Jadx/Ghidra

Decompile the target APK using tools like Jadx or Ghidra. Look for suspicious keywords in the Java/Smali code:

  • su, root, magisk, busybox
  • File paths like /system/bin/, /sbin/, /xbin/
  • Class names like java.io.File, android.content.pm.PackageManager, java.lang.Runtime
  • Specific method calls such as File.exists(), getPackageInfo(), Runtime.exec()

For example, you might find code similar to this:

public boolean isRooted() {    String[] paths = {"/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su"};    for (String path : paths) {        if (new File(path).exists()) {            return true;        }    }    try {        // Check for specific packages        PackageManager pm = getPackageManager();        pm.getPackageInfo("com.noshufou.android.su", 0);        return true;    } catch (PackageManager.NameNotFoundException e) {        // Package not found, continue other checks    }    // Further checks...    return false;}

2. Dynamic Analysis with frida-trace

frida-trace is invaluable for pinpointing which functions are being called during root detection. You can trace common Android APIs:

frida-trace -U -f com.example.app -i "java.io.File.exists" -i "android.app.ApplicationPackageManager.getPackageInfo" -i "java.lang.Runtime.exec"

This command will attach to com.example.app (-f to spawn, use -F to attach to a running app), and instrument calls to File.exists(), getPackageInfo(), and Runtime.exec(). When the app performs a root check, you’ll see output in your terminal, indicating the specific calls made.

Crafting Custom Frida Bypasses: Practical Examples

Once you’ve identified the root detection vectors, you can write targeted Frida scripts.

1. Bypassing File/Path Existence Checks

Hook java.io.File.exists() and return false if the path is related to root binaries.

Java.perform(function () {    var File = Java.use("java.io.File");    File.exists.implementation = function () {        var path = this.getAbsolutePath();        var rootPaths = [            "/system/bin/su", "/system/xbin/su", "/sbin/su",            "/data/local/tmp/su", "/data/adb/magisk",            "/system/app/Superuser.apk", "/su/bin/su"        ];        for (var i = 0; i  false");                return false;            }        }        // Call original method for non-root related paths        return this.exists();    };});

2. Bypassing Package Manager Checks

Hook android.app.ApplicationPackageManager.getPackageInfo() and throw a NameNotFoundException for root-related package names, mimicking a non-existent package.

Java.perform(function () {    var PackageManager = Java.use("android.app.ApplicationPackageManager");    PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (packageName, flags) {        var rootPackages = [            "com.noshufou.android.su", "eu.chainfire.supersu",            "com.topjohnwu.magisk"        ];        for (var i = 0; i  NameNotFoundException");                // Throw a specific exception to simulate the package not being found                throw Java.use("android.content.pm.PackageManager$NameNotFoundException").$new(packageName);            }        }        return this.getPackageInfo(packageName, flags);    };    // Also consider other overloads like getApplicationInfo if needed    PackageManager.getApplicationInfo.overload("java.lang.String", "int").implementation = function (packageName, flags) {        var rootPackages = [            "com.noshufou.android.su", "eu.chainfire.supersu",            "com.topjohnwu.magisk"        ];        for (var i = 0; i  NameNotFoundException");                throw Java.use("android.content.pm.PackageManager$NameNotFoundException").$new(packageName);            }        }        return this.getApplicationInfo(packageName, flags);    };});

3. Bypassing Runtime.exec() and Command Output Analysis

Hook java.lang.Runtime.exec() to intercept and modify command execution. For instance, if an app executes su -c id, you can change it to a harmless command like id without root, or even return a fake Process object.

Java.perform(function () {    var Runtime = Java.use('java.lang.Runtime');    // Hook for exec(String[] cmdarray, String[] envp, File dir)    Runtime.exec.overload('[Ljava.lang.String;', '[Ljava.lang.String;', 'java.io.File').implementation = function (cmdarray, envp, dir) {        var cmd = Java.array(Java.use('java.lang.String'), cmdarray);        if (cmd.length > 0 && (cmd[0].indexOf("su") != -1 || cmd[0].indexOf("which") != -1)) {            console.log("[*] Frida Bypass: Intercepted Runtime.exec for: " + cmd[0]);            // Replace with a non-root command or a command that yields a non-root output            var fakeCmd = Java.array(Java.use('java.lang.String'), ['/system/bin/id']); // Or 'echo'            return this.exec(fakeCmd, envp, dir);        }        return this.exec(cmdarray, envp, dir);    };    // Hook for exec(String cmd)    Runtime.exec.overload('java.lang.String').implementation = function (cmd) {        if (cmd.indexOf("su") != -1 || cmd.indexOf("which") != -1) {            console.log("[*] Frida Bypass: Intercepted Runtime.exec for: " + cmd);            return this.exec("echo"); // Execute a harmless command        }        return this.exec(cmd);    };    // Add other relevant exec overloads as found via frida-trace or static analysis});

4. Bypassing Native Checks (Advanced)

Native checks often involve system calls like stat(), access(), readlink(), or custom native code. You can use Interceptor.attach to hook these functions. For example, to bypass stat() calls for root-related paths:

Interceptor.attach(Module.findExportByName(null, "stat"), {    onEnter: function (args) {        this.pathname = args[0].readUtf8String();        if (this.pathname && (this.pathname.includes("su") || this.pathname.includes("magisk") || this.pathname.includes("busybox"))) {            console.log("[*] Frida Bypass: Native stat() intercepted for: " + this.pathname);            // You might need to return ENOENT (-1) by modifying retval in onLeave.            // For simplicity, we just log here. A full bypass would require returning a specific error code.            // Or, if the app checks for specific file properties, you might need to modify the stat struct.        }    },    onLeave: function (retval) {        // If a root-related path was detected in onEnter, you can modify the return value.        // For example, to simulate 'file not found' (ENOENT, which is -1 in C, represented as 0xFFFFFFFF)        // if (this.pathname && (this.pathname.includes("su") || this.pathname.includes("magisk"))) {        //     retval.replace(ptr(0xFFFFFFFF)); // Simulate stat() failing        // }    }});

Note that bypassing native checks can be more complex, requiring careful analysis of the arguments and return values of the hooked function to ensure a robust bypass.

Automating and Combining Bypasses

For a comprehensive bypass, you’ll often need to combine multiple hooks into a single Frida script (e.g., bypass_root.js). Then, inject it into the target application:

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

The -f flag spawns the app suspended, -l loads your script, and --no-pause resumes execution immediately after script injection.

Conclusion

Frida is an incredibly powerful tool for dynamic analysis and bypassing security controls on Android. While root detection mechanisms continue to evolve, understanding their underlying principles and leveraging Frida’s capabilities allows security professionals to effectively analyze and bypass even unique implementations. Remember that this knowledge should always be used ethically, primarily for security research, penetration testing, and improving application security. The cat-and-mouse game between detection and bypass is continuous, requiring constant learning and adaptation.

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