Bypassing Root Detection and Emulator Checks on Android
- Android Pentest Lab Build
- Android SSL Pinning Bypass with Burp Suite
- Frida Method Hooking for Android App Analysis
- Hooking Native Libraries with Frida Interceptor
- From APK to Source: Complete Android Reverse Engineering Workflow
- Bypassing Root Detection and Emulator Checks on Android
- Attacking Android IPC: Intents, Content Providers, and Broadcast Receivers
This is part six of my Android pentest series. We’ve covered the lab, SSL pinning, method hooking, native hooking, and APK reverse engineering. Now let’s deal with the thing that stops most of those techniques from working in the first place — root detection and emulator checks.
Almost every app I assess on mobile engagements has some form of environment detection. Banking apps, healthcare apps, anything handling sensitive data. The app launches, detects you’re on a rooted device or emulator, and either exits or degrades functionality. This is annoying when you need to actually test the app.
Let’s break all of it.
How Root Detection Works
Root detection isn’t one technique — it’s a pile of heuristics. Apps check multiple indicators and combine the results. Here’s what they typically look for.
File-Based Checks
The most common approach. Apps look for files that exist on rooted devices:
// Typical root detection code you'll find when decompiling
private boolean isRooted() {
String[] paths = {
"/system/app/Superuser.apk",
"/system/xbin/su",
"/system/bin/su",
"/sbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/data/local/su",
"/su/bin/su",
"/system/bin/.ext/.su",
"/system/etc/.has_su_daemon",
"/system/etc/.installed_su_daemon"
};
for (String path : paths) {
if (new File(path).exists()) return true;
}
return false;
}
Package-Based Checks
Apps query the package manager for known root management apps:
private boolean hasRootPackages() {
String[] packages = {
"com.topjohnwu.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.thirdparty.superuser",
"com.yellowes.su",
"com.noshufou.android.su"
};
PackageManager pm = getPackageManager();
for (String pkg : packages) {
try {
pm.getPackageInfo(pkg, 0);
return true;
} catch (NameNotFoundException e) {}
}
return false;
}
Command Execution
Some apps try to actually run su:
private boolean canRunSu() {
try {
Runtime.getRuntime().exec("su");
return true;
} catch (Exception e) {
return false;
}
}
System Properties
Checking build properties for test-keys, custom ROMs, or debug builds:
private boolean hasTestKeys() {
String buildTags = android.os.Build.TAGS;
return buildTags != null && buildTags.contains("test-keys");
}
private boolean isDebuggable() {
return (getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
SafetyNet / Play Integrity
The nuclear option. Google’s server-side attestation checks device integrity, bootloader status, and software state. This is the hardest to bypass because the attestation happens server-side.
Emulator Detection
Apps use overlapping techniques to detect emulators:
Build Properties
private boolean isEmulator() {
return Build.FINGERPRINT.contains("generic")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.BRAND.startsWith("generic")
|| Build.DEVICE.startsWith("generic")
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| Build.PRODUCT.contains("sdk")
|| Build.PRODUCT.contains("vbox86p");
}
Hardware Indicators
// Sensor check - emulators have fewer/fake sensors
SensorManager sm = (SensorManager) getSystemService(SENSOR_SERVICE);
int sensorCount = sm.getSensorList(Sensor.TYPE_ALL).size();
// Real devices typically have 15+ sensors, emulators have < 5
// Telephony check
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
String phoneNumber = tm.getLine1Number();
// Emulators often return "15555215554" or similar
// Battery check - emulators report specific battery states
Timing and Performance Checks
Some sophisticated apps measure execution timing. Emulators are slower at certain operations like floating point math or file I/O, giving them away.
Bypassing Everything with Frida
Now the fun part. We have a few approaches depending on how much time you want to spend.
Quick Win: Hook the Detection Methods Directly
If you’ve decompiled the APK and found the detection methods, just override them:
Java.perform(function() {
var rootCheck = Java.use("com.target.security.RootDetector");
rootCheck.isRooted.implementation = function() {
console.log("[*] isRooted() called - returning false");
return false;
};
rootCheck.isEmulator.implementation = function() {
console.log("[*] isEmulator() called - returning false");
return false;
};
});
This works when the detection is in one class. But most serious implementations spread checks across multiple classes or use obfuscated names.
Comprehensive: Hook the Underlying APIs
Instead of finding each detection method, hook the system APIs that all detection relies on:
File.exists() bypass:
Java.perform(function() {
var File = Java.use("java.io.File");
var blacklist = [
"su", "Superuser.apk", "magisk", "busybox",
".has_su_daemon", ".installed_su_daemon"
];
File.exists.implementation = function() {
var path = this.getAbsolutePath();
for (var i = 0; i < blacklist.length; i++) {
if (path.indexOf(blacklist[i]) !== -1) {
console.log("[*] File.exists(" + path + ") -> false");
return false;
}
}
return this.exists();
};
});
PackageManager bypass:
Java.perform(function() {
var PM = Java.use("android.app.ApplicationPackageManager");
var blockedPkgs = [
"com.topjohnwu.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.noshufou.android.su",
"com.thirdparty.superuser"
];
PM.getPackageInfo.overload("java.lang.String", "int").implementation = function(pkg, flags) {
if (blockedPkgs.indexOf(pkg) !== -1) {
console.log("[*] Hiding package: " + pkg);
throw Java.use("android.content.pm.PackageManager$NameNotFoundException").$new(pkg);
}
return this.getPackageInfo(pkg, flags);
};
});
Build properties bypass:
Java.perform(function() {
var Build = Java.use("android.os.Build");
// Spoof to look like a real device
Build.FINGERPRINT.value = "google/walleye/walleye:11/RP1A.200720.009/6720564:user/release-keys";
Build.MODEL.value = "Pixel 2";
Build.MANUFACTURER.value = "Google";
Build.BRAND.value = "google";
Build.DEVICE.value = "walleye";
Build.PRODUCT.value = "walleye";
Build.HARDWARE.value = "walleye";
Build.TAGS.value = "release-keys";
console.log("[*] Build properties spoofed to Pixel 2");
});
Runtime.exec bypass (block su execution):
Java.perform(function() {
var Runtime = Java.use("java.lang.Runtime");
Runtime.exec.overload("java.lang.String").implementation = function(cmd) {
if (cmd.indexOf("su") !== -1 || cmd.indexOf("which") !== -1) {
console.log("[*] Blocked exec: " + cmd);
throw Java.use("java.io.IOException").$new("Permission denied");
}
return this.exec(cmd);
};
// Also catch the String[] overload
Runtime.exec.overload("[Ljava.lang.String;").implementation = function(cmds) {
var cmdStr = cmds.join(" ");
if (cmdStr.indexOf("su") !== -1) {
console.log("[*] Blocked exec: " + cmdStr);
throw Java.use("java.io.IOException").$new("Permission denied");
}
return this.exec(cmds);
};
});
The Combined Script
In practice I use a single script that combines all these bypasses. Save this as bypass-detection.js:
Java.perform(function() {
console.log("[*] Root/Emulator detection bypass loaded");
// ===== File.exists bypass =====
var File = Java.use("java.io.File");
var blacklistedFiles = [
"su", "Superuser", "magisk", "busybox", "daemonsu",
".has_su_daemon", ".installed_su_daemon", "supersu",
"frida", "xposed"
];
File.exists.implementation = function() {
var path = this.getAbsolutePath();
for (var i = 0; i < blacklistedFiles.length; i++) {
if (path.toLowerCase().indexOf(blacklistedFiles[i]) !== -1) {
console.log("[*] Hiding file: " + path);
return false;
}
}
return this.exists();
};
// ===== Build property spoofing =====
var Build = Java.use("android.os.Build");
Build.FINGERPRINT.value = "google/walleye/walleye:11/RP1A.200720.009/6720564:user/release-keys";
Build.MODEL.value = "Pixel 2";
Build.MANUFACTURER.value = "Google";
Build.BRAND.value = "google";
Build.DEVICE.value = "walleye";
Build.PRODUCT.value = "walleye";
Build.HARDWARE.value = "walleye";
Build.TAGS.value = "release-keys";
Build.HOST.value = "wphr1.hot.corp.google.com";
console.log("[*] Build properties spoofed");
// ===== Package manager bypass =====
var PM = Java.use("android.app.ApplicationPackageManager");
var hiddenPkgs = [
"com.topjohnwu.magisk", "eu.chainfire.supersu",
"com.koushikdutta.superuser", "com.noshufou.android.su",
"com.thirdparty.superuser", "com.yellowes.su",
"com.zachspong.temprootremovejb", "com.ramdroid.appquarantine",
"de.robv.android.xposed.installer", "com.saurik.substrate"
];
PM.getPackageInfo.overload("java.lang.String", "int").implementation = function(pkg, flags) {
if (hiddenPkgs.indexOf(pkg) !== -1) {
console.log("[*] Hiding package: " + pkg);
throw Java.use("android.content.pm.PackageManager$NameNotFoundException").$new(pkg);
}
return this.getPackageInfo(pkg, flags);
};
// ===== Runtime.exec bypass =====
var Runtime = Java.use("java.lang.Runtime");
var blockedCmds = ["su", "which su", "busybox", "magisk"];
Runtime.exec.overload("java.lang.String").implementation = function(cmd) {
for (var i = 0; i < blockedCmds.length; i++) {
if (cmd.indexOf(blockedCmds[i]) !== -1) {
console.log("[*] Blocked exec: " + cmd);
throw Java.use("java.io.IOException").$new("Permission denied");
}
}
return this.exec(cmd);
};
// ===== System.getProperty bypass =====
var System = Java.use("java.lang.System");
System.getProperty.overload("java.lang.String").implementation = function(key) {
if (key === "ro.debuggable" || key === "ro.secure") {
console.log("[*] Spoofed property: " + key);
return key === "ro.secure" ? "1" : "0";
}
return this.getProperty(key);
};
console.log("[*] All bypasses active");
});
Run it:
frida -U -f com.target.app -l bypass-detection.js --no-pause
Dealing with Commercial Frameworks
When you run into apps that use commercial protection SDKs, the generic bypass above might not cut it. Here’s what to look for.
RootBeer
Popular open-source root detection library. Easy to spot because the package name is com.scottyab.rootbeer.
Java.perform(function() {
var RootBeer = Java.use("com.scottyab.rootbeer.RootBeer");
RootBeer.isRooted.implementation = function() { return false; };
RootBeer.isRootedWithBusyBoxCheck.implementation = function() { return false; };
RootBeer.detectRootManagementApps.implementation = function() { return false; };
RootBeer.detectPotentiallyDangerousApps.implementation = function() { return false; };
RootBeer.detectTestKeys.implementation = function() { return false; };
RootBeer.checkForBusyBoxBinary.implementation = function() { return false; };
RootBeer.checkForSuBinary.implementation = function() { return false; };
RootBeer.checkSuExists.implementation = function() { return false; };
RootBeer.checkForRWPaths.implementation = function() { return false; };
RootBeer.checkForDangerousProps.implementation = function() { return false; };
RootBeer.checkForRootNative.implementation = function() { return false; };
RootBeer.detectRootCloakingApps.implementation = function() { return false; };
RootBeer.isSelinuxFlagInEnabled.implementation = function() { return false; };
RootBeer.checkForMagiskBinary.implementation = function() { return false; };
console.log("[*] RootBeer fully bypassed");
});
ProGuard/R8 Obfuscated Detection
When root detection is obfuscated, you won’t find obvious class names. The approach changes:
-
Search for string constants — Decompile with jadx and grep for
"/system/xbin/su"or"test-keys". Even with obfuscated method names, the strings are usually still there. -
Trace File.exists — Run the comprehensive File.exists hook and watch what paths get checked at startup. The detection code will reveal itself.
-
Hook at the decision point — Find where the app shows the “rooted device detected” dialog or calls
finish(). Hook that and prevent it.
// Find the kill switch
Java.perform(function() {
var Activity = Java.use("android.app.Activity");
Activity.finish.implementation = function() {
console.log("[!] finish() blocked on: " + this.getClass().getName());
console.log(Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Exception").$new()
));
// Don't call finish - keep the app alive
};
});
The stack trace will show you exactly which class triggered the kill, even if it’s obfuscated.
Frida Detection
Some apps detect Frida itself. Common methods:
- Scanning
/proc/self/mapsfor frida libraries - Checking for frida-server on default port (27042)
- Looking for frida-agent in memory
- Checking for the frida thread name
Bypass Frida detection:
// Hide frida from /proc/self/maps
var openPtr = Module.findExportByName("libc.so", "open");
var readPtr = Module.findExportByName("libc.so", "read");
Interceptor.attach(openPtr, {
onEnter: function(args) {
this.path = Memory.readUtf8String(args[0]);
this.isMaps = this.path && this.path.indexOf("/proc/") !== -1
&& this.path.indexOf("/maps") !== -1;
},
onLeave: function(retval) {}
});
// Change frida-server default port
// Start frida-server on a non-default port:
// ./frida-server -l 0.0.0.0:1337
// Then connect: frida -H 127.0.0.1:1337 -f com.target.app
For stubborn anti-Frida checks, start frida-server with a renamed binary:
# On the device
cp frida-server /data/local/tmp/myserver
chmod 755 /data/local/tmp/myserver
/data/local/tmp/myserver &
MagiskHide and Shamiko
If you’re on a real rooted device (not an emulator), Magisk’s built-in hide features handle a lot of this automatically.
Zygisk + DenyList (Magisk 24+):
- Enable Zygisk in Magisk settings
- Configure the DenyList with the target app
- The app won’t see root
Shamiko (Magisk module):
- Mounts over root-indicating files
- Hides Magisk itself from detection
- Works alongside Zygisk DenyList
For most assessments I use Magisk + Shamiko as the baseline and layer Frida hooks on top for anything that gets through.
Practical Workflow
Here’s how I approach root/emulator detection on a real engagement:
-
Install the app and launch it — See what happens. Does it crash? Show a warning? Silently degrade?
-
Decompile and search —
grep -r "isRooted\|rootbeer\|safetynet\|isEmulator" jadx-output/to find the detection logic. -
Try the generic bypass script — Run
bypass-detection.js. This handles ~70% of apps. -
If that fails, trace the APIs — Hook
File.exists,Runtime.exec,PackageManager.getPackageInfoand log everything at startup. You’ll see exactly what the app checks. -
Hook the decision point — Find where the app acts on the detection result and neutralize it there.
-
For SafetyNet/Play Integrity — Use a real rooted device with Magisk + Shamiko. Emulators can’t pass hardware attestation.
Most apps fall to step 3 or 4. The rest require targeted hooking but the techniques above give you everything you need to find the right hooks.
Related Posts
- Frida Method Hooking — Fundamentals of hooking Java methods
- Native Hooking with Frida — For when detection logic is in native code
- APK Reverse Engineering — Finding the detection code in the first place
- Frida Cheat Sheet — Quick reference for all the Frida commands
If you enjoyed this post please consider subscribing to the feed!