Many modern Android applications are highly advanced and operate in sensitive domains like banking, money transfers, and other critical services people rely on daily. A rooted environment gives attackers elevated access to the device, which they can abuse in order to bypass the app’s restrictions, extract sensitive data, and interfere with the app’s behavior.
To counter these threats, many apps implement protections against common tools and techniques used in reverse engineering. These include anti-root, anti-hook, and anti-debug mechanisms, which are designed to make an attacker’s job significantly more difficult—often requiring considerable effort and creativity to bypass.
- Anti-Root – Detects whether the device has been rooted.
- Anti-Hook – Detects function hooking attempts using tools like Frida.
- Anti-Debug – Detects whether the application is being debugged.
A practical look at protection mechanisms
This article provides other security researchers with a practical look at how some of these protection mechanisms are implemented and how they can be bypassed during penetration testing or security research.
Additionally, by understanding how these protections work, developers can build stronger, more resilient security mechanisms into their apps and even extend these ideas to develop more advanced and effective techniques.
Furthermore, during this research, an application called TamperLab was created. It includes various protection mechanisms to detect whether a device is rooted, whether any hooking is in place, or whether the application is being debugged. The project is fully open-source, and contributions are welcome.
It can be found on the following GitHub page for you to check out:
https://github.com/infinum/cs-tamperlab
Anti-Root Protections
In this section, we will look at some commonly used root detection techniques and their implementations, and immediately follow each one with an example of how it can be bypassed during testing or reverse engineering.
The “One Function” Folly
The most common way of implementing protection measures such as anti-root, anti-hook, and anti-debug is by placing all checks inside a single function. This is a common mistake, as it gives attackers the opportunity to bypass all protections within seconds.For example, in the following scenario, the RootBeer library is used to quickly demonstrate why placing all checks in a single function can be problematic. As shown in the code snippet below, the isRooted() function is called.
boolean isRooted = rootBeer.isRooted();
showRootStatusDialog(isRooted);
Such a function includes various methods to detect if the device is rooted.
public boolean isRooted() {
return detectRootManagementApps() ||
detectPotentiallyDangerousApps() ||
checkForBinary(BINARY_SU) ||
checkForDangerousProps() ||
checkForRWPaths()
[...SNIP...]
}
Although the function includes many ways to detect if the device is rooted, it is not sufficient because the isRooted() function itself can be hooked, allowing an attacker to bypass all detections with a single hook.
Using the following simple Frida script, we can do exactly that. We obtain a reference to the RootBeer class and hook the isRooted() function, immediately returning false.
Java.perform(() => {
const rootbeer = Java.use("com.scottyab.rootbeer.RootBeer");
rootbeer.isRooted.implementation = function() {
console.log("[+] isRooted() hooked -> returning false");
return false;
}
});
Using the following command, the targeted application can be started with the hook applied.
frida -U -l evade_root.js -f com.hacking.tamperlab
As shown in the following image, the previously created script successfully bypasses all the checks because they are contained within a single function.
Testing this inside TamperLab shows that we successfully bypass the protection measure, and a green checkmark is displayed.
SU Binary Check (Shell)
All rooted Android devices contain the su binary, and any application requiring root access must use su to elevate privileges and perform privileged actions on the device.
When present on a device, applications can detect the existence of the binary and conclude that the device is rooted. A quick way to check for it is by programmatically executing the which su system command.In my application, I created a simple HelperClass that contains various functionalities and detection methods. One of its purposes is to execute system commands.
String output = HelperClass.executeCommand("which su");
The executeCommand() function essentially passes the given command as an argument to the exec() function, which executes it on the device.
Process process = Runtime.getRuntime().exec(command);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String output = reader.readLine();
reader.close();
return output;
If the su binary exists, the command will return its full system path, otherwise, an error code will be returned.
Using Frida, we can bypass this technique by hooking the exec() function itself. If we detect a command such as which su, we can replace it at runtime with another non-existent command.
Java.perform(() => {
const Runtime = Java.use("java.lang.Runtime");
Runtime.exec.overload("java.lang.String").implementation = function(command) {
if (command.trim() === "which su") {
console.log("\n[i] called exec(" + command +")");
console.log("\t[+] returning a fake binary.");
command = "which DoesNotExist";
return this.exec(command);
}
return this.exec(command);
}
});
As demonstrated, by using this technique, we can target specific exec() parameters after reverse engineering the application and bypass the detection mechanisms in place.
SU Binary Check (Native C/C++)
Another way developers may check for the su binary or for rooted devices in general is by writing native C/C++ code using JNI. In case you are unfamiliar, JNI (Java Native Interface) is a framework that allows Java code running on the Android platform to interact with native applications and libraries written in C or C++.
For example, we can check for the su binary by iterating through common system paths where it might exist, and then using the std::ifstream function to check if the file can be opened.
extern "C" JNIEXPORT jboolean JNICALL
Java_com_hacking_tamperlab_NativeChecks_CheckSuBinaryNative(JNIEnv *env, jclass clazz) {
const char* paths[] = {
"/data/local/",
"/data/local/bin/",
"/data/local/xbin/",
"/sbin/",
"/su/bin/",
"/system/bin/",
"/system/bin/.ext/",
"/system/bin/failsafe/",
"/system/sd/xbin/",
"/system/usr/we-need-root/",
"/system/xbin/",
"/cache/",
"/data/",
"/dev/"
};
// Scan directories for "su" binary, if found return true.
for (const char* path: paths) {
std::string fullPath = std::string(path) + "su";
std::ifstream file(fullPath);
if (file.good()) {
file.close();
return JNI_TRUE;
}
}
return JNI_FALSE;
}
A small Java class is then created to serve as a bridge between the Java code and the native C/C++ code in the application. It uses JNI (Java Native Interface) to perform the native task.
public class NativeChecks {
static {
System.loadLibrary("tamper-lib");
}
public static native boolean CheckSuBinaryNative();
}
Under normal circumstances, you wouldn’t have access to the source code. However, a common way to reverse engineer the application is by decompiling it using JADX, which can be done with the following command.
$ jadx -d $(pwd)/TamperLab_Decompiled $(pwd)/TamperLab.apk
Once decompiled, your application may contain various libraries. You can easily filter through them by searching for files with the .so extension, which indicates native libraries.
$ find TamperLab_Decompiled -type f -name "*.so"
TamperLab_Decompiled/resources/lib/arm64-v8a/libtamper-lib.so
TamperLab_Decompiled/resources/lib/arm64-v8a/libtoolChecker.so
The decompiled code shows that ifstream is first used (1) to check whether the file exists. Based on this check, the function returns \x01 (JNI_TRUE) (2) if the su binary is found, otherwise, it returns \x00 (JNI_FALSE) (3).
Since this is a JNI function and is exposed using extern “C”, it appears in the symbol table with the exact name observed in Ghidra.
Java_com_hacking_tamperlab_NativeChecks_CheckSuBinaryNative
Bypassing such checks is relatively straightforward. To hook a native function using Frida, we use Interceptor.attach() and specify the target function using Module.findExportByName().
Interceptor.attach(Module.findExportByName("libtamper-lib.so", "Java_com_hacking_tamperlab_NativeChecks_CheckSuBinaryNative"), {
onEnter: function (args) {
console.log("\n[i] CheckSuBinaryNative() called");
},
onLeave: function (retval) {
console.log("\t[+] Overriding return with JNI_FALSE");
retval.replace(0);
}
});
Since we are intercepting a function from the native library, we need to attach to the process while it is already running by using the -n parameter with the following command.
frida -U -l evade_root.js -n tamperlab
As shown in the script’s output, we successfully bypass the check even though it resides within JNI code.
Another clever approach is to hook the open() function instead of the entire native function implemented in the application.
Since std::ifstream() ultimately invokes the open() system call, we can hook open() to intercept any attempt to access a binary whose path contains “su”. We can then substitute that path with a fake one to bypass the check.
Interceptor.attach(Module.getExportByName(null, "open"), {
onEnter: function (args) {
const path = args[0].readUtf8String();
if (path.includes("su")) {
console.log("[i] called open(" + "'" + path + "'" + ");");
console.log("\t[i] App searching for the 'su' binary");
console.log("\t[+] Overriding with random path value.");
const newPath = Memory.allocUtf8String("/dev/null");
args[0] = newPath;
}
}
});
As shown in the image, this technique effectively bypasses native checks early by using the -f flag to hook before execution, unlike the -n flag, which attaches the hook while the process is already running.
Spoofing /proc/mounts
Nowadays, many Android devices are commonly rooted using Magisk, which comes with a variety of built-in features.
The /proc/mounts file contains a list of all currently mounted filesystems on the device. Additionally, when a device is rooted using Magisk, it modifies the boot image and mounts partitions dynamically by default, so you may find traces of Magisk there as well.
An implementation to detect this might look like the following, where an application reads the contents of /proc/mounts in search of Magisk.
public static boolean isMagiskInMounts() {
try {
BufferedReader reader = new BufferedReader(new FileReader("/proc/mounts"));
String line;
while ((line = reader.readLine()) != null) {
if (line.contains("magisk") ||
line.contains("/sbin/.magisk") ||
line.contains("/dev/") && line.contains("magisk")) {
return true;
}
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
To bypass this, we can create a script like the following, where we define a list of mounts that don’t contain Magisk traces or any other suspicious entries, for that matter.
The first interceptor hooks the open() system call to capture the file descriptor used when accessing /proc/mounts.
The second interceptor hooks read(), checks if the file descriptor corresponds to /proc/mounts, ensures the content hasn’t already been spoofed (to prevent repeated modifications and potential crashes), and finally replaces the buffer with our fake mount data defined at the top.
const fake_mounts = `
/dev/block/dm-8 / ext4 ro,seclabel,relatime 0 0
tmpfs /dev tmpfs rw,seclabel,nosuid,relatime,size=3896612k,nr_inodes=974153,mode=755 0 0
devpts /dev/pts devpts rw,seclabel,relatime,mode=600,ptmxmode=000 0 0
proc /proc proc rw,relatime,gid=3009,hidepid=2 0 0
sysfs /sys sysfs rw,seclabel,relatime 0 0
selinuxfs /sys/fs/selinux selinuxfs rw,relatime 0 0
tmpfs /mnt tmpfs rw,seclabel,nosuid,nodev,noexec,relatime,size=3896612k,nr_inodes=974153,mode=755,gid=1000 0 0
tmpfs /mnt/installer tmpfs rw,seclabel,nosuid,nodev,noexec,relatime,size=3896612k,nr_inodes=974153,mode=755,gid=1000 0 0
`;
const fakeMountBuffer = Memory.allocUtf8String(fake_mounts);
let fakeMountFds = new Set();
let hasSpoofed = false;
Interceptor.attach(Module.getExportByName(null, 'open'), {
onEnter: function(args) {
this.path = Memory.readUtf8String(args[0]);
},
onLeave: function(retval) {
if (this.path === "/proc/mounts" && retval.toInt32() > 0) {
console.log("[i] /proc/mounts opened -> file descriptor:", retval.toInt32());
fakeMountFds.add(retval.toInt32());
}
}
});
Interceptor.attach(Module.getExportByName(null, 'read'), {
onEnter: function(args) {
this.fd = args[0].toInt32();
this.buf = args[1];
this.count = args[2].toInt32();
},
onLeave: function(retval) {
if (fakeMountFds.has(this.fd)) {
if (!hasSpoofed) {
const length = fake_mounts.length;
if (length <= this.count) {
hasSpoofed = true;
console.log("\t[+] Spoofing read() from /proc/mounts with fake one.");
Memory.copy(this.buf, fakeMountBuffer, length);
retval.replace(length);
} else {
console.log("\t[-] Buffer too small to spoof.");
}
} else {
console.log("\t[+] /proc/mounts already spoofed, continuing...");
retval.replace(0);
}
}
}
});
By running the hook, we successfully spoof the contents of /proc/mounts with our fake data, effectively bypassing the detection check.
It’s important to note that these techniques can be implemented and bypassed in various ways. Consequently, multiple solutions and countermeasures exist to address them.
Anti-Hooking Protections
Hooking is a powerful technique used during mobile penetration tests because it allows intercepting, modifying, and monitoring an application’s behavior at runtime. It provides direct access to function calls, arguments, return values, and the internal logic of methods.
To perform such attacks, Frida is a widely used tool that enables pentesters and attackers to bypass security controls or extract potentially sensitive data.
Frida Port Detection
A common way to detect Frida is by checking for open frida-server ports, since Frida communicates using WebSockets. Simple checks often look for ports like 27042 or 27043, which are the default ports used by Frida.
Such checks can be easily bypassed by simply starting the Frida server on a different port using the following command.
./frida-server-16.5.9-android-arm64 -l 0.0.0.0:1337 &
A more advanced detection method involves scanning all ports by sending specific HTTP requests and checking for a 101 Switching Protocols response, which indicates the presence of a Frida server.An example of such native code would involve sending a WebSocket upgrade request to every open port on the device. If the response contains 101 Switching Protocols, it confirms that a Frida server has been successfully detected.
[...SNIP...]
for (int i = 1; i < 65535; i++) {
[...SNIP...]
if (connect(sock, (const struct sockaddr*)&addr, sizeof(addr)) == 0) {
snprintf(req, sizeof(req),
"GET /ws HTTP/1.1\r\n"
"Host: %s:%d\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: CpxD2C5REVLHvsUC9YAoqg==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"User-Agent: Frida\r\n\r\n",
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
write(sock, req, strlen(req));
ssize_t bytes_read = read(sock, res, sizeof(res) - 1);
if (bytes_read > 0) {
res[bytes_read] = '\0';
if (strstr(res, "101 Switching Protocols")) {
close(sock);
return JNI_TRUE;
}
[...SNIP...]
This check can be bypassed by hooking the read() calls and inspecting the buffer content after the call completes. If the buffer contains 101 Switching Protocols, indicating a Frida server query, we can modify the response to something benign such as HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n to evade detection.
Interceptor.attach(Module.findExportByName(null, "read"), {
onEnter: function(args) {
this.buffer = args[1];
this.size = args[2].toInt32();
this.curr_retval = 0;
},
onLeave: function(retval) {
this.curr_retval = retval.toInt32();
if (this.curr_retval > 0) {
var response = this.buffer.readCString();
if (response.includes("101 Switching Protocols")) {
console.log("\n[i] Application detected Frida server via WebSocket");
console.log("\t[+] Modifying the WebSocket response");
var modifiedResponse = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
this.buffer.writeUtf8String(modifiedResponse);
retval.replace(modifiedResponse.length);
}
}
}
});
As demonstrated, once the hook is executed, the check is successfully bypassed.
Frida Threads Detection
In Linux-based environments (including Android), each process has a task directory located at /proc/self/task, containing a subdirectory for each thread within the process. Each of these subdirectories includes a comm file that holds the name of the corresponding thread.
When Frida is injected into a process, it typically creates several threads for its internal operations. These threads often have distinctive names such as frida, gum-js-loop, gmain, gdbus or some other which can be used to detect Frida’s presence.
To better understand this, we can run the following command on the device to retrieve the names of all threads for a specified process.
PPID=$(pidof com.hacking.tamperlab); for i in /proc/$PPID/task/*; do cat "$i/comm" 2>/dev/null; done
As shown in the following snippet, this is the output of the thread list when no Frida hooks are active.
[...SNIP...]
cking.tamperlab
Signal Catcher
perfetto_hprof_
ADB-JDWP Connec
Jit thread pool
mali-utility-wo
mali-cmar-backe
ged-swd
[...SNIP...]
When a Frida hook is applied to the process, threads named gmain and gdbus appear both associated with Frida’s runtime. This allows us to detect Frida based on the presence of these thread names.
[...SNIP...]
cking.tamperlab
perfetto_hprof_
ADB-JDWP Connec
Jit thread pool
mali-cmar-backe
RenderThread
cking.tamperlab
gmain
gdbus
[...SNIP...]
To detect this, we can use C++ code like the following, which loops through each comm file in the /proc/self/task directory to retrieve thread names and checks them against a list of common thread names used by Frida.
[...SNIP...]
DIR *dir = opendir("/proc/self/task");
[...SNIP...]
struct dirent *entry;
char path[PATH_MAX];
char comm[256];
while ((entry = readdir(dir)) != NULL) {
[...SNIP...]
snprintf(path, sizeof(path), "/proc/self/task/%s/comm", entry->d_name);
[...SNIP...]
if (fgets(comm, sizeof(comm), fp) != NULL) {
comm[strcspn(comm, "\n")] = 0;
if (strstr(comm, "frida") ||
strstr(comm, "gum") ||
strstr(comm, "gmain")) {
fclose(fp);
closedir(dir);
return JNI_TRUE;
}
}
fclose(fp);
}
closedir(dir);
return JNI_FALSE;
[...SNIP...]
One intriguing approach to evade detection is to patch the entire frida-server binary by replacing all occurrences of strings like gmain. By searching for gmain in Ghidra, we can observe the following results.
By selecting one of the instances, we can examine the disassembled code to pinpoint its exact location.
We can now use Ghidra’s built-in hex editor to modify these strings, replacing gmain with another string, such as hackr.
After injecting the Frida hook, we can see that we have successfully bypassed the detection, as it no longer identifies Frida’s threads.
If we loop through the thread names again for the application we are attacking, we can see that it now successfully contains the hackr thread name.
[...SNIP...]
cking.tamperlab
ReferenceQueueD
FinalizerDaemon
FinalizerWatchd
binder:7261_1
binder:7261_2
hackr
gdbus
[...SNIP...]
Of course, there are other detectable strings, but this simple example demonstrates one way to bypass detection by directly patching the frida-server binary yourself. This way you could build an entirely different frida-server to avoid detections or modify the original source code as well.
Anti-Debug Protections
Anti-debug protections are another way to protect your Android application from reverse engineering. These mechanisms aim to detect or prevent debugging attempts, stopping attackers from stepping through the app’s code instruction by instruction to understand its internal logic.
Like most security measures, anti-debug mechanisms can also be bypassed using Frida hooks. However, I want to show you how you can defeat these protections using a debugger itself.
Defeating Anti-Debug using Debugger
isDebuggerConnected()
This is a simple, ready-made function that checks if a debugger is connected to the application. It returns true if a debugger is detected, otherwise, it returns false.
Implementing such a function is straightforward, and you can call it as follows:
Debug.isDebuggerConnected()
To use the debugger and bypass this check, we can utilize JADX’s integrated debugger. Before launching it, ensure the application is already running.
Another way to start an application is by using the following ADB command, which will launch the app but pause and wait for the debugger to attach.
adb shell am set-debug-app -w com.hacking.tamperlab
As shown, when you launch the application, it will display a message indicating that it is waiting for a debugger.
In my case, it doesn’t work, but I can open the application without it waiting for the debugger. However, your application might require this behavior, so it’s important to test both methods.
Additionally, whenever you’re done or close the application, make sure to run the following command to remove the app from waiting for a debugger.
adb shell am clear-debug-app
Once ready, you can open JADX and load the APK file of the target application. You can also pull the APK from your device using ADB.
Once loaded into JADX, select the green bug icon, which opens a dialog prompting you to choose the application to debug along with its process ID.
After the debugger attaches, the application’s execution is automatically paused inside the MainActivity. You can then press the green play button to continue running the application.
Once you find the correct position to set a breakpoint, you can do so by pressing the F2 key. For this example, I set the breakpoint exactly where the isDebuggerConnected() function is called.
The following instruction essentially calls the isDebuggerConnected() function using the invoke-static opcode.
invoke-static {}, Landroid/os/Debug;->isDebuggerConnected()Z # method@0008
Then, the move-result instruction moves the return value of the function (either true or false) into the v0 register.
move-result v0
By stepping over these two instructions, we can observe that the v0 register contains the value 1, indicating that a debugger has been detected.
To bypass this check, we can simply set the value to 0 using the debugger, as shown below:
Continuing the application’s execution, we successfully bypass the check by manipulating the variables during debugging.
Detection via USB & ADB
Another interesting way to detect debugging is by checking if the device is connected via USB with ADB enabled. While this isn’t a direct debugger detection method, it remains a useful check nonetheless.
A simple implementation might look like the following, where the app dynamically listens for the USB_STATE broadcast and parses the connected and adb boolean extras from the received intent.
[...SNIP...]
IntentFilter filter = new IntentFilter("android.hardware.usb.action.USB_STATE");
BroadcastReceiver usbReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent intent) {
boolean connected = intent.getBooleanExtra("connected", false);
boolean adbEnabled = intent.getBooleanExtra("adb", false);
callback.accept(connected && adbEnabled);
ctx.unregisterReceiver(this);
}
};
context.registerReceiver(usbReceiver, filter);
Intent sticky = context.registerReceiver(null, filter);
if (sticky != null) {
usbReceiver.onReceive(context, sticky);
}
[...SNIP...]
We can also bypass this check by using the debugger to place breakpoints at the appropriate locations.
In this case, I set a breakpoint on the if-eqz instructions, which checks whether the values in registers v0 and v2 are equal to zero. These registers correspond to the connected and adbEnabled flags.
When the breakpoint is hit, we observe that both values are set to 1 (true), indicating that USB is connected and ADB is enabled.
We can now simply set these two values to 0 (false), causing the check to fail and allowing the app to continue execution.
Conclusion
We have explored several common anti-root, anti-hook, and anti-debug techniques and demonstrated how each can ultimately be bypassed. However, it is important to recognize that these protections still play a critical role in Android security.
No protection is entirely foolproof. Given enough time and the right tools, a determined attacker can often find a way around most defenses. However, the goal of these mechanisms isn’t to create an unbreakable application, but rather to increase the complexity of attacks. This raises the time, effort, and skill required for an attacker to compromise the application, which can deter casual attackers and slow down more advanced ones.
In short, while these security checks can be bypassed, they remain a valuable part of a defense-in-depth strategy. Labs like TamperLab that we have created, provide a practical environment where you can practice implementing these detection mechanisms and learn how to bypass them. It’s about making it hard enough that breaking the security is no longer worth the effort.
At Infinum, we help you stay ahead with tailored cybersecurity services, including penetration testing and security assessments. Whether launching new products or protecting existing ones, we identify weaknesses before attackers do so you can focus on what matters.Learn more about how we keep your digital world secure on our cybersecurity page.