Android App Penetration Testing & Frida Hooks

Cracking SafetyNet & Magisk: Comprehensive Frida Strategies for Android Root Hiding

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction: The Evolving Cat-and-Mouse Game

In the realm of Android security, a perpetual arms race exists between app developers aiming to secure their applications against rooted devices and users seeking full control over their hardware. Google’s SafetyNet Attestation API, now largely superseded by the Play Integrity API, stands as a primary defense mechanism, designed to verify the integrity of an Android device and its software environment. Magisk, the de-facto standard for Android root, offers powerful features like Magisk Hide (or its successor, Zygisk with DenyList), attempting to conceal root from apps. However, sophisticated apps, especially those in banking, gaming, or DRM-protected content, often employ advanced root detection techniques that even Magisk struggles to circumvent. This is where Frida, a dynamic instrumentation toolkit, becomes an indispensable tool for penetration testers and researchers.

This article delves into advanced strategies for bypassing Android root detection mechanisms using Frida. We’ll explore common detection vectors and craft targeted Frida scripts to dynamically alter application behavior, effectively hiding root and allowing apps to function on a compromised device.

Understanding Android Root Detection Vectors

Before we can bypass root detection, we must understand how applications typically identify a rooted environment. Detection methods can vary widely in complexity and location (Java vs. Native code):

1. File-Based Checks

  • Presence of su binary: Checking paths like /system/bin/su, /system/xbin/su, /sbin/su.
  • Magisk-specific directories: Looking for /data/adb, /magisk.
  • Other common root files: /system/app/Superuser.apk, /data/local/tmp/su, etc.

2. Property-Based Checks

  • System properties: Querying ro.debuggable (should be ‘0’ for production), ro.boot.flash.locked (should be ‘1’ for locked bootloader), ro.build.tags (should not contain ‘test-keys’).

3. Package-Based Checks

  • Installed root management apps: Searching for packages like com.topjohnwu.magisk (Magisk Manager), eu.chainfire.supersu (SuperSU).

4. Library/Framework Checks

  • Detecting Xposed/LSPosed/Zygisk: Identifying injected modules or frameworks that modify the Android runtime.

5. Native Code Checks

  • System call hooking: Intercepting low-level functions like access(), stat(), open() to check for root files, often performed in C/C++ libraries to evade Java-level instrumentation.
  • Integrity checks: Verifying library checksums or code integrity.

6. SafetyNet / Play Integrity API Attestation

  • Server-side validation: This is the hardest to bypass as it relies on Google’s remote servers to verify device integrity. Client-side Frida can only prevent the app from *triggering* the attestation or modify its *response* if not properly signed.

Setting Up Your Frida Environment

To begin, ensure you have Frida installed on your host machine and frida-server running on your Android device (rooted or using an emulator):

# On your host machine (assuming Python and pip are installed)pip install frida-tools# On your Android device (rooted)adb push /path/to/frida-server /data/local/tmp/frida-serverchmod 755 /data/local/tmp/frida-serveradb shell /data/local/tmp/frida-server

Identify the target application’s package name (e.g., com.example.bankingapp) and ensure it’s running or ready to launch.

Frida Strategies: Bypassing Common Root Checks

1. Hiding Root-Related Files and Directories

Apps often call java.io.File.exists() or similar methods to check for root binaries. We can hook these methods to return false for known root paths.

Java.perform(function() {    var File = Java.use('java.io.File');    File.exists.implementation = function() {        var path = this.getPath();        console.log('File.exists() called for: ' + path);        if (path.includes('/su') || path.includes('/magisk') || path.contains('/data/adb')) {            console.warn('Blocking File.exists() for root path: ' + path);            return false;        }        return this.exists();    };    File.canRead.implementation = function() {        var path = this.getPath();        console.log('File.canRead() called for: ' + path);        if (path.includes('/su') || path.includes('/magisk') || path.contains('/data/adb')) {            console.warn('Blocking File.canRead() for root path: ' + path);            return false;        }        return this.canRead();    };});

To run this script: frida -U -f com.example.bankingapp -l hide_files.js --no-pause

2. Spoofing System Properties

Applications check system properties to determine if the device is debuggable or has an unlocked bootloader. We can manipulate these values.

Java.perform(function() {    var SystemProperties = Java.use('android.os.SystemProperties');    SystemProperties.get.overload('java.lang.String').implementation = function(key) {        console.log('SystemProperties.get() called for: ' + key);        if (key === 'ro.debuggable') {            console.warn('Spoofing ro.debuggable to 0');            return '0';        }        if (key === 'ro.boot.flash.locked') {            console.warn('Spoofing ro.boot.flash.locked to 1');            return '1';        }        if (key === 'ro.build.tags') {            console.warn('Spoofing ro.build.tags to release-keys');            return 'release-keys';        }        return this.get(key);    };    // For Build.TAGS, which might be cached or accessed directly    var Build = Java.use('android.os.Build');    Object.defineProperty(Build, 'TAGS', {        get: function() {            console.warn('Spoofing Build.TAGS to release-keys');            return 'release-keys';        }    });});

Run with: frida -U -f com.example.bankingapp -l spoof_props.js --no-pause

3. Concealing Root Management Packages

Apps use PackageManager to list installed applications. We can filter out known root packages.

Java.perform(function() {    var PackageManager = Java.use('android.app.ApplicationPackageManager');    PackageManager.getPackageInfo.overload('java.lang.String', 'int').implementation = function(packageName, flags) {        console.log('getPackageInfo() called for: ' + packageName);        if (packageName === 'com.topjohnwu.magisk' || packageName === 'eu.chainfire.supersu') {            console.warn('Blocking getPackageInfo() for root package: ' + packageName);            // Throw an exception as if the package doesn't exist            throw Java.use('android.content.pm.PackageManager$NameNotFoundException').$new(packageName + ' not found');        }        return this.getPackageInfo(packageName, flags);    };    // Also hook queryIntentActivities for broader coverage    PackageManager.queryIntentActivities.overload('android.content.Intent', 'int').implementation = function(intent, flags) {        var result = this.queryIntentActivities(intent, flags);        var filteredResult = Java.use('java.util.ArrayList').$new();        for (var i = 0; i < result.size(); i++) {            var info = result.get(i);            if (info.activityInfo.packageName !== 'com.topjohnwu.magisk' && info.activityInfo.packageName !== 'eu.chainfire.supersu') {                filteredResult.add(info);            }        }        if (filteredResult.size() !== result.size()) {            console.warn('Filtered out root-related activities from queryIntentActivities.');        }        return filteredResult;    };});

Run with: frida -U -f com.example.bankingapp -l hide_packages.js --no-pause

4. Native Hooking for Deeper Evasion

For checks implemented in native libraries (e.g., using libc.so functions like access, stat, open), Frida’s Interceptor.attach() is crucial. This requires identifying the specific native functions being called. For instance, to block access() calls to /system/bin/su:

Interceptor.attach(Module.findExportByName('libc.so', 'access'), {    onEnter: function(args) {        this.path = Memory.readUtf8String(args[0]);        // console.log('access() called for: ' + this.path);        if (this.path.includes('/su') || this.path.includes('/magisk') || this.path.includes('/data/adb')) {            console.warn('Blocking native access() to root path: ' + this.path);            this.block = true;        }    },    onLeave: function(retval) {        if (this.block) {            retval.replace(0); // Return 0 (success) or -1 (failure) with errno ESRCH (No such process)            // Or just return 0 to indicate success for the file not existing check            // Usually, returning -1 and setting errno to ENOENT (No such file or directory) is more realistic            var errno = Module.findExportByName('libc.so', '__errno');            if (errno) {                Memory.writeS32(errno, 2); // ENOENT            }            retval.replace(-1);        }    }});

This example demonstrates how to intercept native calls. Identifying the exact native functions and their arguments often requires reverse engineering the native library using tools like Ghidra or IDA Pro.

Dealing with Play Integrity API (Formerly SafetyNet)

Bypassing Play Integrity API’s server-side attestation is exceedingly difficult, as Google’s servers are the ultimate arbiters of device integrity. Frida’s role here is primarily client-side: to prevent the application from *triggering* the attestation in the first place, or to modify any client-side components that might react to the attestation result.

  • Hooking Google Play Services APIs: Identify and hook calls to SafetyNetApi or PlayIntegrityManager within the app. By returning a pre-defined

    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