Introduction: The Evolving Landscape of Android Root Detection
Android application penetration testing often involves bypassing root detection mechanisms. While basic checks for `su` binaries or specific package names are easily circumvented, advanced applications leverage sophisticated techniques to detect rooted environments, often focusing on Magisk and Zygisk. These newer detection methods frequently involve native checks, filesystem integrity scans, and even early `zygisk` callback detection, making traditional Java-layer Frida hooks insufficient. This guide delves into crafting advanced custom Frida hooks, focusing on early native interception to bypass even the most resilient root detection.
Understanding how Magisk and Zygisk operate is crucial. Magisk systemlessly modifies the boot image, allowing for root access and module loading without altering the `/system` partition directly. Zygisk, an evolution of MagiskHide, operates by running within the Zygote process, allowing modules to run code in nearly every Android app process. This early execution point makes detection and bypass more challenging, as apps can perform checks before typical Frida scripts have fully initialized or even before specific libraries are loaded.
Understanding Advanced Detection Vectors
Modern root detection isn’t just about looking for `/system/bin/su`. Applications employ a multi-layered approach:
- File System Checks: Looking for common Magisk/root artifacts like `/dev/magisk`, `/sbin/magisk`, `magisk.img`, or checking properties of files like `/system/build.prop` for known root indicators.
- Process/Package Checks: Identifying root-related processes (e.g., `magiskd`) or packages (e.g., `com.topjohnwu.magisk`).
- Native Library Integrity: Checking hashes or modifications of system libraries, or attempting to load specific libraries that indicate root.
- `getprop` Abuse: Checking `ro.boot.verifiedbootstate` or other system properties that might be altered by custom ROMs or root solutions.
- `ptrace` & Debugger Detection: Using `ptrace` or similar mechanisms to detect if the app is being debugged or tampered with, often by Frida itself.
- Early Native Hooks/Callbacks: The most challenging. Apps might load a native library very early in their lifecycle (e.g., during `JNI_OnLoad`), and this library performs critical root checks by hooking system calls (`open`, `read`, `stat`, `access`) or by registering callbacks with `zygisk` that report environment status.
Our focus will be on the last point: defeating early native detection that can bypass typical Frida `Java.perform` hooks.
The Challenge of Early Native Detection and Frida’s Limitations
When you attach Frida to an Android application, your JavaScript code executes within the context of the application’s process. Typically, `Java.perform` is used to interact with Java classes. However, by the time `Java.perform` executes, the application might have already completed its critical native root checks if they occur early in `JNI_OnLoad` of a native library, or if they hook system calls at a very low level. For instance, an application’s native library might hook `libc.so`’s `stat` function to detect the presence of `/dev/magisk` before any of your Java hooks can take effect.
Consider an app with a native library `libdetection.so` that, during its `JNI_OnLoad` call, performs a check like this:
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { // ... other JNI setup ... if (is_root_detected_native()) { // Trigger shutdown or modify app behavior } return JNI_VERSION_1_6;}bool is_root_detected_native() { struct stat st; if (stat("/dev/magisk", &st) == 0) { return true; } if (stat("/sbin/magisk", &st) == 0) { return true; } // ... more checks ... return false;}
If this `JNI_OnLoad` executes before your Frida script has the opportunity to attach an `Interceptor` to `libc.so!stat`, the detection will succeed.
Strategy: Early Native Hooking with Frida
To overcome this, we need to inject our hooks as early as possible into the native execution flow. This involves:
- Targeting System Calls: Intercepting low-level system calls like `open`, `read`, `stat`, `access`, `execve` that are commonly used by detection mechanisms.
- Targeting Specific Native Functions: If static analysis (e.g., with Ghidra or IDA Pro) reveals specific root-checking functions within the app’s native libraries (e.g., `isRootPresent()` in `libdetection.so`), we can directly hook those.
- Early Injection: Ensuring our hooks are in place before the critical detection logic runs. This is often achieved by hooking `dlopen` or `android_dlopen_ext` to intercept library loading, or simply attaching to well-known, frequently called system functions (like those in `libc.so`) which are almost always loaded early.
Example 1: Bypassing `stat` or `access` Checks
Let’s bypass a common `stat` check for `/dev/magisk`. We’ll target `libc.so!stat`. Note that `stat` has multiple variants (`stat`, `fstat`, `lstat`), and it’s prudent to hook them all if unsure. We’ll start with `stat`.
First, identify the base address of `libc.so` in the target process. Frida handles this automatically.
import fridaimport sysdef on_message(message, data): print("[+] [" + message['type'] + "] " + str(message['payload']))def bypass_root_detection(): script_code = """ var libc_base = Module.findBaseAddress('libc.so'); if (libc_base) { console.log('[+] libc.so base address: ' + libc_base); // Hooking stat function var stat_ptr = Module.findExportByName('libc.so', 'stat'); if (stat_ptr) { console.log('[+] Hooking stat @ ' + stat_ptr); Interceptor.attach(stat_ptr, { onEnter: function (args) { this.path = args[0].readUtf8String(); // console.log('stat("' + this.path + '") called'); if (this.path && (this.path.includes('/dev/magisk') || this.path.includes('/sbin/magisk'))) { console.log('[*] Detected stat("' + this.path + '"). Bypassing.'); this.bypass = true; } }, onLeave: function (retval) { if (this.bypass) { // Pretend the file does not exist retval.replace(ptr(-1)); // -1 typically indicates file not found // Alternatively, set errno to ENOENT (2) if needed // this.context.errno = 2; // Not directly supported by Interceptor, but can be done with NativeCallback } } }); } else { console.log('[-] Could not find stat export in libc.so'); } // Optionally hook access as well, similar logic // var access_ptr = Module.findExportByName('libc.so', 'access'); // if (access_ptr) { // console.log('[+] Hooking access @ ' + access_ptr); // Interceptor.attach(access_ptr, { // onEnter: function(args) { // this.path = args[0].readUtf8String(); // if (this.path && (this.path.includes('/dev/magisk') || this.path.includes('/sbin/magisk'))) { // console.log('[*] Detected access("' + this.path + '"). Bypassing.'); // this.bypass = true; // } // }, // onLeave: function(retval) { // if (this.bypass) { // retval.replace(ptr(-1)); // Return -1 to indicate error (e.g., file not found) // } // } // }); // } } else { console.log('[-] Could not find libc.so base address.'); } """ try { process = frida.get_usb_device().attach("com.target.application"); script = process.create_script(script_code); script.on('message', on_message); print('[+] Script loaded successfully'); script.load(); sys.stdin.read(); } except Exception as e: print(e)if __name__ == '__main__': bypass_root_detection();
In this script, we attach to the `stat` function in `libc.so`. If the path being checked contains `/dev/magisk` or `/sbin/magisk`, we intercept the call and modify its return value (`retval.replace(ptr(-1))`) to simulate a
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 →