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 logcat and 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 the frida tools.
  • The target .apk (referred to as target.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.

DEFENSE IN DEPTH · 4 GUARDS · 3 RUNTIMES #1 · License wrapperJAVA detects install source / license → System.exit(0) #2 · runStealthChecks()NATIVE C detects root + Frida → Toast, then native exit(0) #3 · libapp.so guardCOMPILED DART detects Frida (/proc/self/maps) + root → dart:io exit() #4 · Watchdog (~10s)NATIVE C detects Frida (raw syscall / mem scan) → deliberate null-pointer crash
Fig. 01 - four guards spread across Java, native C, and compiled Dart, each with a different kill style. Beating one only reveals the next.
# 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.

✕ BRING FRIDA - fight every layer ✓ HIDE THE OS - no Frida at all fake the native check (guard 2) scrub frida from /proc/self/maps watchdog catches Frida anyway fighting the tool, not the app Magisk Zygisk DenyList → root hidden kill frida-server (nothing injected) TrickyStore → Play Integrity OK all 4 guards pass · app runs
Fig. 02 - the pivot. Every guard hunts for root or Frida, so bringing Frida means fighting each layer; hiding the environment at the OS level (Zygisk DenyList + integrity spoofing, no Frida) clears all four at once.

🧠 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.FINGERPRINT contains generic/unknown, Build.MODEL says sdk/Emulator/Android SDK built for x86, Build.HARDWARE is goldfish/ranchu, Build.PRODUCT contains sdk.
  • System properties - ro.kernel.qemu=1, ro.hardware=goldfish, ro.bootmode=unknown, qemu.sf.lcd_density set.
  • 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

  1. 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.
  2. Tombstones are gold. /data/tombstones/ told us which layer fired - and twice revealed that our own hooks caused the crash, not the app.
  3. Remove the reason, not the symptom. Blocking exit() failed; making the detection pass worked.
  4. 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.
  5. 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.