June 2, 2026
Bypassing Anti-Frida in a Flutter Android App with 12 Lines of Smali
Hi Hackers,
Muhammad Mater
6 min read
I came across several anti-analysis protections that needed to be bypassed before continuing the assessment.
When I started testing an Android app, I encountered multiple security layers, including
- Emulator Detection
- Root Detection
- Debugging Detection
- Frida Detection
I successfully bypassed all of them.
This blog post explains how I bypassed the anti-Frida protection using APK patching
When I injected Frida into the app or just ran Frida on the device, an alert immediately appeared saying, "Tracing Tool Detected "
I closed it the app is running normally with no blocks.
How Flutter Apps Usually Implement Anti-Frida
Before jumping into the patch, it helps to understand the architecture — because the architecture is the bypass.
In a Flutter Android app, Dart code doesn't have direct access to low-level OS primitives like /proc/self/maps or raw TCP sockets That means security-sensitive checks have to live in Java/Kotlin plugins, which the Flutter engine exposes to Dart through aMethodChannel.
The data flow looks like this:
The Java plugin does the real checks. The Dart side just trusts the boolean it gets back. That gap MethodChannel is where the bypass happens. Change what the Java method returns, and you control everything.
Starting to See The Logic of the App
I started by decompiling the APK with JADX:
jadx app.apk -d jadx_outjadx app.apk -d jadx_outjadx turns the DEX bytecode into readable Java sources.
Then grepped for anything Frida related:
grep -rli "frida" jadx_out/sources/ Or Search on GUI
That led me straight to SecurityDetection the app's anti-tamper plugin.
The first thing that stood out was the constants at the top of the class. The developer had hardcoded every Frida default in plain text:
Scrolling down, I found the predicate that ties everything together:
Every value here is an upstream Frida default: the agent SO, the default port, and the most common deployment path. If you're running Frida without changing any of these (and most testers don't), every check is going to fire.
Scrolling further, I found the predicate that ties it all together: five helper methods, OR'd in sequence. Each one targets a different Frida fingerprint:
checkFridaLibraries() → /proc/self/maps → substring: frida-agent, frida-gadget, frida-server
checkFridaPorts() → TCP socket connect → ports: 27042, 27043
checkFridaFiles() → File.exists() → /data/local/tmp/frida-server
checkFridaNamedPipes() → ls /proc/self/fd → substring: "frida"
checkFridaThreads() → /proc/self/task/<tid>/comm → frida, gum-js-loop, gdbuscheckFridaLibraries() → /proc/self/maps → substring: frida-agent, frida-gadget, frida-server
checkFridaPorts() → TCP socket connect → ports: 27042, 27043
checkFridaFiles() → File.exists() → /data/local/tmp/frida-server
checkFridaNamedPipes() → ls /proc/self/fd → substring: "frida"
checkFridaThreads() → /proc/self/task/<tid>/comm → frida, gum-js-loop, gdbusFive different fingerprints but none of them compute anything.
They're pure string matching against hardcoded constants. Cross-referencing with libapp.so the AOT Dart blob confirmed that the Dart side reads this via1 MethodChannel("security_detection").invokeMethod("checkFrida") and treats ittrue as "tamper detected so kill the app."
The entire Java-side Frida check is one method. That's exactly where the patch goes.
A Quick Smali intro
Before the patch makes sense, it helps to know what smali actually is.
Android apps don't ship Java source code or .class files — they ship Dalvik bytecode, packaged in classes.dex files inside the APK. This is what Android Runtime (ART) executes on the device. It's register-based bytecode designed for mobile hardware, quite different from the JVM's stack-based model.
Dalvik bytecode is binary not human-readable on its own. Smali is the assembly notation for it: a text format that maps one-to-one onto Dalvik instructions, designed specifically to be edited by humans and reassembled back into working DEX. The pipeline looks like this:
# what ships in the APK
.java source --> javac/d8 --> classes.dex (binary)
# for reading
classes.dex --> jadx --> .java (read only, can't recompile)
classes.dex --> apktool --> .smali (read + edit + rebuild)
# after editing
.smali --> apktool b --> classes.dex (new, working)# what ships in the APK
.java source --> javac/d8 --> classes.dex (binary)
# for reading
classes.dex --> jadx --> .java (read only, can't recompile)
classes.dex --> apktool --> .smali (read + edit + rebuild)
# after editing
.smali --> apktool b --> classes.dex (new, working)jadx is faster to read and great for analysis, but its Java output is a one-way transformation — you can't compile it back into a working APK. For patching, I switched to apktool, which produces smali files I can edit and reassemble.
If you've ever patched a binary in IDA or Ghidra turning a jnz into a, this is the same idea.
The instructions are just higher-level (invoke-virtual, move-result, return), and the syntax is text rather than raw opcodes.
Decoding with apktool
apktool d app.apk -o out
This unpacks the APK and disassembles every DEX inside it. The relevant file lands at:
out/smali/com/<pkg>/SecurityDetectionPlugin.smaliout/smali/com/<pkg>/SecurityDetectionPlugin.smaliFinding the target method is a one-liner:
grep -n "^\.method.*detectFrida" out/smali/com/<pkg>/SecurityDetectionPlugin.smali
# 1256:.method private final detectFrida()Zgrep -n "^\.method.*detectFrida" out/smali/com/<pkg>/SecurityDetectionPlugin.smali
# 1256:.method private final detectFrida()ZReading the Original Smali
Here's the actual detectFrida() method as apktool produced it (debug markers stripped; they're source position annotations for debuggers they don't execute):
smali
.method private final detectFrida()Z
.locals 1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaLibraries()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaPorts()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaFiles()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaNamedPipes()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaThreads()Z
move-result v0
if-eqz v0, :cond_0
goto :goto_0
:cond_0
const/4 v0, 0x0
goto :goto_1
:cond_1
:goto_0
const/4 v0, 0x1
:goto_1
return v0
.end method.method private final detectFrida()Z
.locals 1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaLibraries()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaPorts()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaFiles()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaNamedPipes()Z
move-result v0
if-nez v0, :cond_1
invoke-direct {p0}, Lcom/<pkg>/SecurityDetectionPlugin;->checkFridaThreads()Z
move-result v0
if-eqz v0, :cond_0
goto :goto_0
:cond_0
const/4 v0, 0x0
goto :goto_1
:cond_1
:goto_0
const/4 v0, 0x1
:goto_1
return v0
.end methodFor reference, here's a quick decoder for the instruction types you'll encounter:
The Patch
With the structure clear, the patch is straightforward. I replaced the entire method body with this:
.method private final detectFrida()Z
.locals 1
const/4 v0, 0x0
return v0
.end method.method private final detectFrida()Z
.locals 1
const/4 v0, 0x0
return v0
.end methodThat's it.
Two instructions of running code, wrapped in the method's structural boilerplate. Here's what each line does:
.method private final detectFrida()Z— Unchanged. Same signature, same visibility, same return type. Any existing caller still resolves this method..locals 1— Declares registerv0. Consistent with the original.const/4 v0, 0x0— Loads the 4-bit immediate0x0intov0. In Dalvik,0x0is the canonicalfalsefor aZ(boolean) return.return v0— Returnsfalseto the caller..end method— Closes the method.
Why it works the way it does: The five helper methods checkFridaLibraries, checkFridaPorts, checkFridaFiles, checkFridaNamedPipes, checkFridaThreads are completely untouched in their own .method blocks. They still scan /proc/self/maps. They still try to connect to TCP 27042. They're still intact as DEX bytecode. But nothing calls them anymore. The new detectFrida() body loads 0x0 and returns before it ever reaches an invoke-direct to any of them making the helper methods dead code that the runtime never executes.
This is the key insight: I didn't need to remove the helpers, neutralize the constants, or hook anything at runtime. I only needed to make the single entry point that Dart talks to return false. Everything downstream becomes irrelevant.
With the patch applied, the call chain looks like this:
Dart → invokeMethod("checkFrida")
→ MethodCallHandler dispatches to detectFrida()
→ const/4 v0, 0x0 ; v0 = false
→ return v0
→ result.success(false) back to Dart
→ Dart sees false → no tamper dialog, no SystemNavigator.pop()Dart → invokeMethod("checkFrida")
→ MethodCallHandler dispatches to detectFrida()
→ const/4 v0, 0x0 ; v0 = false
→ return v0
→ result.success(false) back to Dart
→ Dart sees false → no tamper dialog, no SystemNavigator.pop()The same four-line stub applied to three more methods retires the rest of the Java-side defense:
detectTracer()checksTracerPid:in/proc/self/status. Same patch.detectDebugger()combinesDebug.isDebuggerConnected(),Debug.waitingForDebugger(), and theTracerPidre-check. Same patch.blockDebugger()lives in the second plugin (debugger_detectionchannel). This one's actually clever: it returnstruewhen the manifest hasandroid:debuggable="true", which catches the lazy "flip-debuggable-and-repackage" bypass.- Same patch force it to
false.
Four methods. Four identical stubs. Twelve lines of new smali total.
Rebuild, Zipalign, Sign, Install
With the smali edited, apktool rebuilds the APK from the patched tree,
reencodes everything back into fresh DEX, and repackages the ZIP:
bash
apktool b out -o patched.apkapktool b out -o patched.apkThe result is unsigned. Android refuses to install unsigned APKs every install goes through signature verification at the PackageManager level. Resigning is mandatory because the moment any file inside the APK changes, the original signature no longer verifies.
Modern Android uses three layered signing schemes:
v1 (JAR signing) META-INF/*.SF, *.RSA → Pre-Android 7.0 (legacy) v2 APK Signing Block Android 7.0+ baseline v3 APK Signing Block + key rotation → Android 9.0+
For a modern install, you want v2 + v3 at minimum. uber-apk-signer handles zipalign v1, v2, and v3 in one shot with an auto-generated debug keystore, exactly what you need for a pentest install:
java -jar uber-apk-signer-1.3.0.jar -a patched.apk --overwrite
Output:
zipalign success
sign success
signature verified [v2, v3]
Subject: C=US, O=Android, CN=Android Debug zipalign success
sign success
signature verified [v2, v3]
Subject: C=US, O=Android, CN=Android DebugOne note on zipalign: v2/v3 signing mandates 4-byte page alignment of all archive entries.
Without it, signing fails. uber-apk-signer runs zipalign internally before signing, so there's no need to call it separately.
Finally, install over a clean uninstall. Android won't upgrade an existing install if the signing certificate has changed that's by design, how the OS distinguishes a legitimate app update from a different app masquerading as the same package:
adb uninstall com.<redacted>
adb install patched.apkadb uninstall com.<redacted>
adb install patched.apkThe Result
I launched the patched app with frida-server running in its default configuration binary at /data/local/tmp/frida-server, listening on port 27042, with default worker thread names (gum-js-loop). Every signature the original detectFrida() would have caught was live on the device.
No tamper dialog. No SystemNavigator.pop().
The app went straight to its real UI.
Thank you. I just want to share a real case
Note:_ Unlike most banking or fintech applications I've assessed, this app had no shielding solution (e.g., DexGuard, Guardsquare, Promon SHIELD) and no APK signing or tampering checks in place._
For a non-financial application, this is less surprising but still worth noting as context for why the bypass documented here was relatively straightforward.