What you'll learn
By the end you'll understand, hands-on:
- How to tell a deliberate self-destruct apart from a normal crash.
- How to read
logcatand tombstones to find why an app dies. - How a modern app spreads its defenses across three runtimes (Java, native C/C++, and compiled Dart) - and why each needs a different approach.
- The basics of Frida (spawn vs attach, hooking Java and native functions).
- Why, for a Frida-aware app, Frida is often the wrong tool - and what to use instead (Magisk's Zygisk DenyList).
- How to recognize and bypass emulator detection (Build fields, system properties, qemu device files) - the trap most beginners hit first.
Each section has a 🧠 Concept box explaining the idea for newcomers, then the actual commands and code.
The setup
- A rooted phone (this walkthrough used a Galaxy A51, Android 13) with Magisk (Zygisk + DenyList enabled), plus modules TrickyStore (spoofs Google's integrity checks) and trustusercerts (auto-trusts a proxy CA for traffic interception).
- A PC with
adb,jadx,python,node/npm, and thefridatools. - The target
.apk(referred to astarget.apk).
🧠 Concept - what is RASP? Normal apps don't care if your phone is rooted.
"Hardened" apps add self-checks: is there a su binary? is Frida injected? am
I being debugged? If yes, they refuse to run. Bypassing RASP means making the
app believe it's on a clean, normal phone.
Command cheat-sheet
| Task | Command / note |
|---|---|
| Decompile fast | jadx --no-res -d out target.apk |
| List native libs | unzip -l target.apk | grep '\.so$' |
| Watch a launch | adb logcat -c; am start -n pkg/.Activity; adb logcat -d |
| Read a crash | su -c 'cat /data/tombstones/tombstone_<n>' |
| frida-server (root, detached) | su -c 'setsid /data/local/tmp/frida-server -D >/dev/null 2>&1 </dev/null' & |
Frida 17: Java global |
gone - npm i frida-java-bridge, import, frida-compile |
| Frida 17: find a C function | Module.findGlobalExportByName(name) (old: findExportByName(null,name)) |
| Hide root from one app | magisk --denylist add <package> |
| Spoof a property (emulator/device) | su -c 'resetprop ro.kernel.qemu 0' |
Step 1 - Reproduce and observe (don't guess)
Install the app the usual rooted way:
adb push target.apk /data/local/tmp/
adb shell
su
pm install -i com.android.vending /data/local/tmp/target.apk
🧠 Concept - -i com.android.vending. -i sets the installer name. Many
apps check "was I installed from the Play Store?" Passing
com.android.vending makes a sideloaded app claim it came from Play. Cheap
trick, often needed.
The app installed, showed a splash screen, flashed a Toast (a little popup message), and disappeared. It also triggered a Play Protect "app not recognised" dialog - which turns out to be a distraction.
The single most important habit: before reaching for any tool, watch the system log while the app launches.
adb logcat -c # clear the log
adb shell monkey -p com.acme.app -c android.intent.category.LAUNCHER 1
sleep 4
adb logcat -d | grep -iE "flutter|FATAL|exit|Toast|process"
The key lines:
I/flutter ( ... ): Using the Impeller rendering backend <- it's a Flutter app
W/NotificationService: Toast already killed. pkg=com.acme.app
I/Zygote : Process 28171 exited cleanly (0) <- clean exit, code 0
🧠 Concept - crash vs self-destruct. A crash leaves a violent trace: a
FATAL EXCEPTION Java stack, or a native "tombstone" with SIGSEGV. Here we
got exited cleanly (0) right after a Toast - calm, intentional, zero. That
is the fingerprint of a guard doing detect → show message → exit(0). It's not
a bug. The app is killing itself on purpose.
We also learned it's Flutter (Google's cross-platform UI toolkit). That matters because Flutter apps put their real logic in compiled native files, not just Java.
Step 2 - Find the real package name
What you double-click isn't always the package id. List installed packages:
adb shell pm list packages | grep -i acme
# package:com.acme.app
And the launch log shows the activity can live under a different internal namespace than the app id:
START ... cmp=com.acme.app/com.acme.core.MainActivity
So: app id = com.acme.app, main screen = com.acme.core.MainActivity.
Note both; you'll need them.
Step 3 - Decompile to map the defenses
🧠 Concept - decompiling. An APK is a zip. Inside are classes.dex files
(compiled Java/Kotlin) and lib/.../*.so files (compiled C/C++ and Dart).
jadx turns the .dex back into readable Java so you can find the guards.
jadx --no-res -d app_jadx target.apk # --no-res skips images/layouts (faster)
Look at the native libraries first:
unzip -l target.apk | grep '\.so$'
# lib/arm64-v8a/libflutter.so <- Flutter engine
# lib/arm64-v8a/libapp.so <- YOUR app's logic, compiled Dart (no symbols!)
# lib/arm64-v8a/libnative-lib.so <- a custom native library = likely where checks live
Grep the decompiled Java strings for protection keywords:
grep -aoiE "rootbeer|frida|xposed|isRooted|playIntegrity|magisk|supersu" app_jadx -r \
| sort | uniq -c | sort -rn
You'll see hits for root detection, Frida detection, Xposed detection, and Play Integrity. Now find which code actually pulls the trigger:
grep -rliE "killProcess|System.exit|finishAffinity" app_jadx/sources \
| xargs grep -liE "root|integrity|license"
This is how we found four separate guards. Let's meet them.
Guard 1 - the licensing wrapper (Java)
The app's Application class (the very first code that runs) calls a license
check:
// com/acme/wrapper/Application.java
public class Application extends RealApp {
protected void attachBaseContext(Context context) {
LicenseClient.checkLicense(context); // runs before ANY screen
super.attachBaseContext(context);
}
}
LicenseClient checks the install source, then phones the Play licensing
service. If it doesn't like the answer it pops an error dialog and calls
System.exit(0). (This is a Google-provided protection wrapper. The lightweight
variant used here does not verify the APK signature - good to know, because
it means re-signing the app wouldn't trip an extra trap.)
A red herring - Crashlytics
// Looks scary, but it's Firebase Crashlytics just TAGGING crash reports:
new File("/system/app/Superuser.apk").exists();
new File("/system/xbin/su").exists();
This checks for root but never kills the app. Recognizing dead ends saves hours - you'll see these exact paths probed at startup and it means nothing.
Guard 2 - a native check (C/C++ via JNI)
The main screen declares a native method and a Toast helper:
// com/acme/core/MainActivity.java
static { System.loadLibrary("native-lib"); } // loads libnative-lib.so
private final native String runStealthChecks(); // implemented in C, not Java
// invoked from a Flutter "method channel" named com.acme.core/syscall:
public static void handle(MainActivity a, Call call, Result result) {
if (!call.method.equals("runStealthChecks")) { result.notImplemented(); return; }
a.runStealthChecks(); // <-- return value is IGNORED
result.success("OK"); // <-- Java always reports "OK" back to Dart
}
public final void showUnsupportedMessage(String message) { /* shows the Toast */ }
🧠 Concept - JNI / native methods. native means the function body is
compiled C/C++ inside libnative-lib.so, not visible in the decompiled Java.
The Java side just calls it. Here, the C code does the detection, calls
showUnsupportedMessage (the Toast) back through JNI, then exits the process
itself. Because Java ignores the return value, faking the method to just
return "OK" is enough - Dart receives "OK" and never knows.
Guards 3 & 4 - we'll discover these by running it
The decompiled Java only shows half the story. The other two guards live in the
compiled Dart (libapp.so) and the native library, and we'll find them by
observing behavior, below.
Step 4 - Meet Frida
🧠 Concept - what is Frida? Frida is a tool that injects into a running app and lets you rewrite functions on the fly with JavaScript - change return values, skip code, log arguments. Two modes:
- attach - hook an app that's already running (too late if it dies on launch).
- spawn - start the app paused, install your hooks, then let it go. This is what you want for startup guards.
Get frida-server running on the phone as root (this trips people up):
# WRONG (runs as the wrong user -> "need Gadget to attach on jailed Android"):
adb shell "su -c 'frida-server &'"
# RIGHT (detached, root, survives the shell closing):
adb shell "su -c 'setsid /data/local/tmp/frida-server -D >/dev/null 2>&1 </dev/null' &"
adb shell "su -c 'ps -A -o PID,USER,NAME | grep frida'" # confirm USER = root
Frida 17 gotcha: the Java helper is no longer built in. If you see
ReferenceError: 'Java' is not defined, you must compile it in:
npm install frida-java-bridge
frida-compile bypass.js -o bypass.compiled.js
import Java from 'frida-java-bridge'; // first line of every script now
A small Python driver to spawn the app, load a script, and watch if it survives:
import frida, time
PKG = "com.acme.app"
device = frida.get_usb_device(timeout=10)
pid = device.spawn([PKG]) # start paused
session = device.attach(pid)
script = session.create_script(open("bypass.compiled.js", encoding="utf-8").read())
script.on("message", lambda m, d: print(" ", m.get("payload", m)))
script.load() # install hooks
device.resume(pid) # let it run
for i in range(1, 21):
time.sleep(1)
alive = any(p.pid == pid for p in device.enumerate_processes())
print(f"[t+{i:2d}s] alive: {alive}")
if not alive: break
Step 5 - Beat guards 1 and 2
Our first script: neutralize the license check, fake the native check, and as a
safety net, block System.exit.
🧠 Concept - deferred hooks. At spawn time, MainActivity isn't loaded yet,
so Java.use("...MainActivity") fails. We retry every 40 ms until the class
exists.
import Java from 'frida-java-bridge';
Java.perform(function () {
// Guard 1: license wrapper
Java.use("com.acme.wrapper.LicenseClient").checkLicense.implementation = function (c) {
console.log("[bypass] license check neutralised");
};
// safety net
Java.use("java.lang.System").exit.implementation = function (c) {
console.log("[blocked] System.exit(" + c + ")");
};
// Guard 2: native check (class loads late -> keep trying)
var done = false;
var iv = setInterval(function () { Java.perform(function () {
try {
var M = Java.use("com.acme.core.MainActivity");
M.runStealthChecks.implementation = function () { console.log("[bypass] runStealthChecks -> OK"); return "OK"; };
M.showUnsupportedMessage.implementation = function (m) {}; // swallow the Toast
done = true; clearInterval(iv);
} catch (e) {}
}); }, 40);
});
Run it. Progress - but a new death appears:
[bypass] license check neutralised
[bypass] runStealthChecks -> OK
[t+ 5s] alive: True
[t+ 7s] alive: False <- something ELSE killed it ~7s in
The two known guards are down. There's a third.
Step 6 - Find guard 3 (the Dart layer)
We need to know what code is exiting now. Hook the low-level exit functions and print a backtrace (the chain of callers):
['exit', '_exit'].forEach(function (name) {
var p = Module.findGlobalExportByName(name); // Frida 17 name (was findExportByName)
if (p) Interceptor.attach(p, { onEnter: function () {
console.log("[" + name + "] called from:\n " +
Thread.backtrace(this.context, Backtracer.FUZZY)
.map(DebugSymbol.fromAddress).join("\n "));
}});
});
The backtrace points into libapp.so - the compiled Dart:
[_exit] called from:
libflutter.so!...
libapp.so!_kDartIsolateSnapshotInstructions+0x827b44 <- Dart code decided to exit
🧠 Concept - compiled Dart has no symbols. Flutter compiles your Dart into
libapp.so with the function names stripped. You can't hook "the root-check
function" by name. So instead we watch what files it touches before dying, by
hooking the C library calls every file access goes through:
[['openat',1],['open',0],['access',0],['fopen',0]].forEach(function (e) {
var p = Module.findGlobalExportByName(e[0]);
if (p) Interceptor.attach(p, { onEnter: function (a) {
var s = a[e[1]].readUtf8String();
if (/su|magisk|frida|maps|gum/i.test(s)) console.log("[" + e[0] + "] " + s);
}});
});
Right before it exits:
[fopen] /proc/self/maps <- reading its OWN memory layout = looking for Frida
[access] /sbin/su <- looking for root
[access] /system/bin/su
🧠 Concept - /proc/self/maps. This file lists every code library currently
loaded in the app's memory. When you inject with Frida, the "frida-agent" shows
up here. So the app scans this list for the word frida. This is the catch:
we need Frida to fake guard 2, but the app detects Frida by reading this file.
To win, we must make Frida invisible: scrub frida out of that file, and make
su look absent.
Step 7 - Hide Frida and root (and two mistakes to learn from)
Mistake #1 - blocking exit the brute-force way
// DON'T do this. exit()/_exit()/abort() never return by design.
// Forcing them to return drops the program into garbage -> instant SIGSEGV.
Interceptor.replace(Module.findGlobalExportByName('_exit'),
new NativeCallback(function () { return 0; }, 'int', ['int']));
🧠 Lesson: don't fight a symptom (the exit call). Remove the reason the app wants to exit (it found Frida). If the guard passes, it never calls exit.
Mistake #2 - hooking read() globally
To scrub the maps file, the tempting move is to intercept every read() and
delete frida from the bytes. But read() is one of the busiest functions in
the whole system. Hooking it destabilized an Android background thread and
crashed the app a different way. We found this from the tombstone:
adb shell "su -c 'cat /data/tombstones/tombstone_NN'" | grep -iE "signal|#0"
# #00 libperfetto_hprof.so ArtPlugin_Initialize <- NOT the app's guard; our hook caused this
🧠 Concept - tombstones. When a native crash happens, Android writes a
detailed report to /data/tombstones/. It tells you the exact library and
address that crashed. It is the single best tool for "what just killed my app?"
The right way - hand the app a clean copy of the maps file
🧠 Concept - memfd. A memfd is a file that lives only in memory. We read
the real /proc/self/maps, replace frida with xxxxx (same length, so
all the memory addresses stay valid and no parser breaks), write that into a
memfd, and when the app opens the maps file we hand it the memfd instead. No
busy-function hooking needed.
var _open = new NativeFunction(Module.findGlobalExportByName('open'), 'int', ['pointer','int']);
var _read = new NativeFunction(Module.findGlobalExportByName('read'), 'long', ['int','pointer','ulong']);
var _write = new NativeFunction(Module.findGlobalExportByName('write'), 'long', ['int','pointer','ulong']);
var _lseek = new NativeFunction(Module.findGlobalExportByName('lseek'), 'long', ['int','long','int']);
var _close = new NativeFunction(Module.findGlobalExportByName('close'), 'int', ['int']);
var _memfd = new NativeFunction(Module.findGlobalExportByName('memfd_create'), 'int', ['pointer','uint']);
var FRIDA_KW = ['frida','gadget','gum-js','gmain','gdbus','pool-frida','linjector','re.frida','agent-64'];
function sanitize(t) {
FRIDA_KW.forEach(function (kw) { t = t.replace(new RegExp(kw,'gi'), Array(kw.length+1).join('x')); });
return t; // same length keeps every address/offset intact
}
function cleanMaps(path) {
var fd = _open(Memory.allocUtf8String(path), 0); if (fd < 0) return -1;
var sz = 1<<20, buf = Memory.alloc(sz), txt = '', n;
while ((n = _read(fd, buf, sz).valueOf()) > 0) txt += buf.readUtf8String(n);
_close(fd);
var mfd = _memfd(Memory.allocUtf8String('m'), 0);
var data = Memory.allocUtf8String(sanitize(txt));
_write(mfd, data, sanitize(txt).length); _lseek(mfd, 0, 0);
return mfd;
}
[['openat',1],['open',0]].forEach(function (e) {
Interceptor.attach(Module.findGlobalExportByName(e[0]), {
onEnter: function (a) { try { this.path = a[e[1]].readUtf8String(); } catch (x) {} },
onLeave: function (ret) {
if (ret.toInt32() < 0 || !/\/proc\/.*\/maps/i.test(this.path || '')) return;
var mfd = cleanMaps(this.path);
if (mfd >= 0) { _close(ret.toInt32()); ret.replace(ptr(mfd)); } // swap in clean copy
}
});
});
// make su paths look missing - via access() only.
// NEVER hook stat()/statx(): Android's runtime depends on them and faking -1 corrupts it.
[['access',0],['faccessat',1]].forEach(function (e) {
Interceptor.attach(Module.findGlobalExportByName(e[0]), {
onEnter: function (a) { try { this.block = /\/su$|magisk|superuser|busybox/i.test(a[e[1]].readUtf8String()); } catch (x) {} },
onLeave: function (ret) { if (this.block) ret.replace(ptr('-1')); } // -1 = "not found"
});
});
Now the app survives longer, renders its real UI… and dies again at ~10s:
[bypass] runStealthChecks -> OK
I/flutter: Using the Impeller rendering backend
[t+10s] alive: True
[t+11s] alive: False
F/libc: Fatal signal 11 (SIGSEGV), fault addr 0x0
Step 8 - Guard 4: the watchdog
The tombstone:
signal 11 (SIGSEGV) Cause: null pointer dereference fault addr 0x0
backtrace:
#00 pc 0000000000002cb8 /.../base.apk <- libnative-lib.so, ONE frame only
🧠 Concept - crash-on-detection. A single-frame crash that jumps to address
0 on purpose is an anti-tamper trick. Instead of a clean exit() (which is
easy to spot and hook), the app deliberately crashes itself when it catches
you. It looks like a random bug and gives a useless stack trace.
This is a watchdog: a background thread that re-runs the Frida check every
~10 seconds. And it found Frida through a path that doesn't use the open()
function we hooked (it likely makes the raw system call directly, or scans its
own memory). Our maps trick never saw it.
This is the turning point. Every new layer detects Frida itself. We could keep escalating, but we're now fighting the tool, not the app.
| # | Guard | Where it lives | What it detects | How it kills |
|---|---|---|---|---|
| 1 | License wrapper | Java, in Application.attachBaseContext |
install source / license | error dialog + System.exit(0) |
| 2 | runStealthChecks() |
native libnative-lib.so (via a Flutter channel) |
root + Frida | Toast, then native exit(0) |
| 3 | Dart guard | compiled Dart libapp.so |
Frida (/proc/self/maps) + root (access on su) |
dart:io exit() |
| 4 | Watchdog | native libnative-lib.so, every ~10s |
Frida (raw syscall / memory scan) | deliberate null-pointer crash |
Step 9 - The pivot: stop bringing Frida
Step back and notice the pattern across guards 2, 3, and 4: they all look for root or Frida. So the clean solution is:
Don't run Frida at all. Hide root at the operating-system level instead.
🧠 Concept - Magisk Zygisk DenyList. Magisk can make root invisible to a chosen app: for that app, it un-mounts its modifications so the app sees a stock, unrooted phone. No need to hook anything inside the app.
Check what the phone supports:
adb shell "su -c 'magisk -V'" # 28100
adb shell "su -c 'magisk --sqlite \"SELECT * FROM settings\"'" # zygisk|1 denylist|1
adb shell "su -c 'ls /data/adb/modules'" # tricky_store trustusercerts ...
- Zygisk + DenyList → hide root per app.
- TrickyStore → already handles Google's Play Integrity attestation.
- trustusercerts → already trusts the proxy CA for traffic capture.
The decisive test - add the app to the DenyList, kill Frida, launch normally:
magisk --denylist add com.acme.app
pkill -9 -f frida-server
am start -n com.acme.app/com.acme.core.MainActivity
I/Magisk: zygisk64: [com.acme.app] is on the denylist
...
ps -A -o PID,ETIME,NAME | grep acme
18802 01:02 com.acme.app <- 62 seconds and counting, UI on screen
It just works:
- Guards 2/3/4 (root + Frida checks) → pass, because root is hidden and no Frida is present.
- Play Integrity → handled by TrickyStore.
- Traffic interception → trustusercerts already trusts the proxy CA.
- The license wrapper → never lethally fired (the install-source spoof satisfied its quick check, and it survived past its delayed-shutdown timer).
No patching, no re-signing, no Frida. The app runs indefinitely, ready to test.
The final method, start to finish
# 1. install, spoofing the installer (satisfies the quick license check)
pm install -i com.android.vending /data/local/tmp/target.apk
# 2. hide root from this one app (Zygisk un-mounts root for it)
magisk --denylist add com.acme.app
# 3. make sure NO frida-server is running
pkill -9 -f frida-server
# 4. launch normally
am start -n com.acme.app/com.acme.core.MainActivity
# Play Integrity handled by TrickyStore; proxy CA trusted by trustusercerts.
# Undo later with: magisk --denylist rm com.acme.app
Step 10 - Bonus: bypassing emulator detection
We won on a physical rooted phone, so emulator checks never fired. But most beginners start on an emulator (Android Studio AVD, Genymotion), and the same class of RASP almost always includes an "am I running in an emulator?" guard. If you test on an emulator and the app dies before any root/Frida check even runs, this is usually why. Here's how to recognize and defeat it.
🧠 Concept - how apps spot an emulator. Emulators leak their identity through dozens of tiny tells. The common ones:
- Build properties -
Build.FINGERPRINTcontainsgeneric/unknown,Build.MODELsayssdk/Emulator/Android SDK built for x86,Build.HARDWAREisgoldfish/ranchu,Build.PRODUCTcontainssdk. - System properties -
ro.kernel.qemu=1,ro.hardware=goldfish,ro.bootmode=unknown,qemu.sf.lcd_densityset. - Tell-tale files/devices -
/dev/qemu_pipe,/dev/socket/qemud,/dev/socket/baseband_genyd(Genymotion),/system/lib/libc_malloc_debug_qemu.so. - Fake telephony / sensors - IMEI all zeros, phone number
15555215554, no real accelerometer.
You'll often find these in the same decompiled helper as the root check, e.g.:
// classic emulator heuristic you'll see in the decompiled Java
static boolean isEmulator() {
if (Build.PRODUCT.contains("sdk")) return true;
String hw = Build.HARDWARE;
return hw.contains("goldfish") || hw.contains("ranchu");
}
There are two layers to spoof - the Java Build fields and the native
getprop reads - because a thorough check uses both.
Option A - Frida (covers Java and native in one script)
🧠 Concept - two read paths. Java code reads android.os.Build.* fields and
calls SystemProperties.get(...). Native (C) code reads the same properties via
__system_property_get. A real check reads both, so we hook both.
import Java from 'frida-java-bridge';
// values a normal Pixel-class phone would report
var SAFE = {
FINGERPRINT: "google/redfin/redfin:13/TQ3A.230805.001/10316531:user/release-keys",
MODEL: "Pixel 5", MANUFACTURER: "Google", BRAND: "google", DEVICE: "redfin",
PRODUCT: "redfin", HARDWARE: "redfin", BOARD: "redfin", HOST: "abfarm", TAGS: "release-keys"
};
Java.perform(function () {
// 1) overwrite the static android.os.Build fields
var Build = Java.use("android.os.Build");
Object.keys(SAFE).forEach(function (f) {
try { Build[f].value = SAFE[f]; } catch (e) {}
});
// 2) lie to SystemProperties.get(...) for emulator-revealing keys
var SP = Java.use("android.os.SystemProperties");
var FAKE = {
"ro.kernel.qemu": "0", "ro.hardware": "redfin", "ro.product.model": "Pixel 5",
"ro.bootmode": "unknown".replace("unknown", "real"), "ro.secure": "1"
};
var get1 = SP.get.overload('java.lang.String');
get1.implementation = function (k) { return FAKE[k] !== undefined ? FAKE[k] : get1.call(this, k); };
var get2 = SP.get.overload('java.lang.String', 'java.lang.String');
get2.implementation = function (k, d) { return FAKE[k] !== undefined ? FAKE[k] : get2.call(this, k, d); };
});
// 3) native side: lie to __system_property_get and hide qemu files
var spg = Module.findGlobalExportByName("__system_property_get");
if (spg) Interceptor.attach(spg, {
onEnter: function (a) { this.key = a[0].readUtf8String(); this.out = a[1]; },
onLeave: function (ret) {
var fake = { "ro.kernel.qemu": "0", "ro.hardware": "redfin", "ro.product.model": "Pixel 5" };
if (fake[this.key] !== undefined) {
this.out.writeUtf8String(fake[this.key]);
ret.replace(ptr(fake[this.key].length)); // return value = new string length
}
}
});
// hide emulator device files from access()/open()/stat()
var EMU = ['qemu_pipe', 'qemud', 'genyd', 'libc_malloc_debug_qemu', 'vbox', 'goldfish'];
[['access', 0], ['open', 0], ['__openat', 1]].forEach(function (e) {
var p = Module.findGlobalExportByName(e[0]); if (!p) return;
Interceptor.attach(p, {
onEnter: function (a) { try { var s = a[e[1]].readUtf8String() || ""; this.block = EMU.some(function (k) { return s.indexOf(k) >= 0; }); } catch (x) {} },
onLeave: function (ret) { if (this.block) ret.replace(ptr('-1')); } // "file not found"
});
});
Compile and run it the same way as before (frida-compile, then the Python
driver). Caveat: if the app is Frida-aware (like ours), running this on an
emulator still exposes you to the Frida checks - which is exactly why Option B
is better when you can use it.
Option B - spoof at the OS level (no Frida, most robust)
🧠 Concept - resetprop. Magisk's resetprop changes what
getprop/__system_property_get return for the whole system - so both the
Java and native read paths see the spoofed value with nothing injected into
the app. This is the emulator-detection equivalent of the DenyList trick: hide
the environment, don't fight the check.
# make an emulator's properties look like a real device
adb shell "su -c 'resetprop ro.kernel.qemu 0'"
adb shell "su -c 'resetprop ro.hardware redfin'"
adb shell "su -c 'resetprop ro.product.model \"Pixel 5\"'"
adb shell "su -c 'resetprop ro.product.manufacturer Google'"
adb shell "su -c 'resetprop ro.bootmode real'"
# then relaunch the app
For a permanent fix, ship these as a tiny Magisk module (post-fs-data.sh with
the resetprop lines), or just test on a physical device - which sidesteps
emulator detection entirely and is the recommended path for hardened apps.
Takeaway: emulator detection is the same game as root/Frida detection - the robust answer is to make the environment look normal at the OS level (
resetprop, real device), not to out-hook every individual check.
The big lessons
- Read the crash before choosing a tool. "Clean exit after a Toast" told us it was a guard, not a bug, in the first five minutes.
- Tombstones are gold.
/data/tombstones/told us which layer fired - and twice revealed that our own hooks caused the crash, not the app. - Remove the reason, not the symptom. Blocking
exit()failed; making the detection pass worked. - Match the tool to the defense. For a Frida-aware app, bringing Frida creates the very problem you're solving. Hiding the environment (Magisk Zygisk DenyList + integrity spoofing) defeats every root/Frida layer at once, and is far more stable than out-hooking a watchdog.
- Defense in depth is real. Four guards across Java, native C, and compiled
Dart, with three different kill styles (
System.exit,dart:io exit, and a null-pointer crash). Beating one only revealed the next.
🧠 Final tip for beginners: progress in this kind of work is iterative. You won't see all four guards up front. You beat one, the app dies a new way, you read the log/tombstone, and you learn the next layer. Patience and careful observation beat clever one-liners every time.