( بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ )
CAUTIONFreePalestine
Introduction
In this noob-friendly writeup, I will explain and share some of the advanced Frida detection techniques I have faced during my last pentest engagements. However, due to confidentiality of the apps, we will use an open-source app which I found very matching to some of these advanced bypasses I encountered.
Sample APK: adv_frida_apk
Solver Frida Script: bypass_frida.js
Prerequisites: Understanding Android Layers & Frida Internals
Before digging deep, I will explain some simple basic topics that will help us understand the flow of these detections. I made them into flows, comparios, Q&A to make them easier to understand.
1. Java Level vs Native Level vs Low Level
First, we need to understand the 3 different levels when dealing with Android Apps.
The Three Layers of Android
Java Level (Dalvik/ART)
The topmost layer where your MainActivity.java, Activities, and Services live. This layer uses Android SDK APIs (android.*, java.*) and runs in the Android Runtime (ART) as interpreted or JIT-compiled DEX bytecode (.dex files inside the APK).
Example: String password = editText.getText().toString();
Native Level (C/C++)
Connected to Java via JNI (Java Native Interface). This layer consists of .so libraries (like libnative-lib.so, libc.so) containing compiled ARM64/ARM32 machine code. It provides direct memory access through pointers and is developed using the NDK (Native Development Kit). The libc.so is the C standard library containing functions like printf, malloc, open, read.
Example: int fd = open("/proc/self/maps", O_RDONLY);
Low Level (Kernel)
Accessed from Native level via System Calls (syscall/SVC #0). This is the Linux Kernel handling direct hardware interaction, process/memory/file management. Cannot be bypassed from userspace.
Example: SVC #0 with x8=56 (openat syscall)
How They Communicate
| From | To | Mechanism |
|---|---|---|
| Java → Native | JNI | System.loadLibrary("native") then call native void doCheck() |
| Native → Java | JNI | env->CallVoidMethod(obj, methodID) |
| Native → Kernel | Syscall | libc.so wrapper OR direct SVC #0 instruction |
| Kernel → Native | Return value | Syscall returns result in x0 register |
2. What Happens When Frida Hooks an App?
Frida Injection Process
When you run frida -U -f com.app.target -l script.js, here’s what happens:
When you run Frida, the frida-server first receives commands from your PC while listening on port 27042 (the default port). It then spawns or attaches to the target process using ptrace() to gain control. Next, it injects frida-agent-64.so into the target process, which creates several artifacts in memory: frida-agent-64.so (main Frida agent library), frida-gadget.so (embedded gadget for spawn mode), libfrida-gum.so (Frida’s hooking engine), [anon:gum-js-loop] (V8 JavaScript runtime memory), and various [anon:frida-*] allocations. These all appear in /proc/self/maps (we will learn about this file later).
Frida’s Gum engine then hooks functions by modifying memory. For example, before hooking, connect() might have original instructions like FF 43 00 91 and FD 7B BF A9. After hooking, these are replaced with 50 00 00 58 (LDR X16, #8), 00 02 1F D6 (BR X16 - jump to trampoline), followed by the address of the hook handler. This means memory is modified. Finally, your JavaScript runs in the V8 engine inside the process, where Java.perform() uses ART internals for Java hooks.
Java Runtime Side (ART)
When you use Java.perform() in Frida:
Java.perform(function() {
var Activity = Java.use("android.app.Activity");
Activity.onCreate.implementation = function(bundle) {
console.log("onCreate hooked");
this.onCreate(bundle);
};
});Frida interacts with ART (Android Runtime) internals:
- Uses
art::Runtimeto access class definitions - Modifies ArtMethod structures to redirect method calls
- Replaces method entry points with trampoline code
Native Side (libc, linker64)
Q: What is linker64?
A: Android’s dynamic linker for 64-bit. It’s responsible for loading all .so libraries at runtime.
When you use Interceptor.attach() in Frida:
Interceptor.attach(Module.findExportByName("libc.so", "open"), {
onEnter: function(args) { console.log("open called"); }
});
Frida’s Gum engine:
- Finds function address in memory
- Overwrites first instructions with jump to trampoline
- Saves original bytes for calling the real function
Important Frida Artifacts
These are the artificats that Frida leaves behind when hooking the app APIs or when even idle.
| Artifact | Location | Detection Method | Description |
|---|---|---|---|
Port 27042 listening | Network | connect() to localhost:27042 | frida-server binds to this default TCP port to receive commands from the Frida client on your PC |
frida-agent-64.so | /proc/self/maps | String search for “frida” | The main Frida agent library injected into the target process to execute JavaScript hooks |
| Modified function bytes | libc.so in memory | Checksum disk vs memory | Frida’s Interceptor overwrites function prologues with trampolines (LDR X16; BR X16) to redirect execution |
/data/local/tmp/re.frida.server | Filesystem | File existence check | Directory created by frida-server to store temporary files, gadgets, and agent libraries |
Overview
This app has 3 anti-frida detection mechanisms implemented in its native library libantifrida.so:
Check 1: Frida Port Detection (27042) — Detects Frida by attempting to connect to port 27042.
Check 2: Frida Artifacts in maps — Scans /proc/self/maps for Frida-related strings.
Check 3: libc Checksum Detection — Compares in-memory libc bytes against to the one in the disk to detect tampering. (We will cover this later in another blog).
TIPAnti-frida code often lives in the Native Level because:
- Harder to reverse engineer than Java (no easy decompilation)
- Can use direct syscalls to bypass Frida’s libc hooks
- Lower-level = more control, fewer abstraction layers to trust
Check 1: Frida Port Detection (Port 27042)
Technical Deep Dive
This detection exploits the fact that
frida-serverbinds to TCP port 27042 on localhost (127.0.0.1) by default. The detection code uses theconnect()libc function—which internally triggers theSYS_connectsyscall. On ARM64 Android, the anti-frida library callsconnect()with asockaddr_instructure containingsin_family = AF_INET (2),sin_addr = 127.0.0.1, andsin_port = 27042in network byte order. Network byte order is big-endian, so port 27042 (hex0x69A2) becomes0xA269when the bytes are swapped. The detection reads the port fromsockaddr_inat offset +2 bytes from the structure base (after the 2-bytesin_familyfield). Ifconnect()returns 0 (success), it means something is listening on that port—likely Frida.
NOTEThis is check is not that advanced, however, still a pain in the bass.
How the detection works
The anti-frida code first creates a TCP socket using socket(AF_INET, SOCK_STREAM, 0). It then fills a sockaddr_in structure with sin_family = AF_INET, sin_addr = 127.0.0.1, and sin_port = 27042 (in network byte order). Next, it calls connect(fd, &addr, sizeof(addr)) to attempt a connection. If connect() returns 0 (success), Frida is detected because something is listening on that port. If it returns -1 (fail), no Frida is detected.
CAUTION
27042is not the only port that adevelopercan search for, so you need to read/understand the code to figure out if there is anyportconfigured by the devleopered to be scanned as well.
The Bypass
Our bypass hooks
connect()inlibc.so, intercepts the call, reads the port from the sockaddr structure using pointer arithmetic, converts it from network to host byte order using((port & 0xff) << 8) | (port >> 8), and if it matches 27042, we change it to a random port like 1337, causing the connection to fail harmlessly.
Code
// Find the memory address of the 'connect' function in libc.so
var connectPtr = Module.findExportByName("libc.so", "connect");
// Create a callable wrapper for the original connect function
var connect = new NativeFunction(connectPtr, 'int', ['int', 'pointer', 'int']);
// Replace connect() with our custom implementation
Interceptor.replace(connectPtr, new NativeCallback(function(fd, addr, len) {
// Read address family (AF_INET = 2)
var family = addr.readU16();
// Read port from sockaddr_in structure (offset +2 bytes)
// Port is in network byte order (big-endian)
var port = addr.add(2).readU16();
// Convert network byte order to host byte order (swap bytes)
port = ((port & 0xff) << 8) | (port >> 8);
// If trying to connect to Frida's default port...
if(port == 27042){
console.error(`[+] Bypassing frida port check...`);
// Change to a different port (27043 or any unused port)
var tmp = ((27043 >> 8) & 0xff) | ((27043 & 0xff) << 8);
addr.add(2).writeU16(tmp);
}
// Call original connect with (possibly modified) parameters
var retval = connect(fd, addr, len);
return retval;
}, 'int', ['int', 'pointer', 'int']));
Understanding The Byte Order
Port
27042in hexadecimal is0x69A2. In network order (big-endian), bytes are stored as0x69 0xA2with the high byte first. In host order (little-endian), bytes are stored as0xA2 0x69with the low byte first. The conversion formula((port & 0xff) << 8) | (port >> 8)works by: (1) extracting the low byte withport & 0xff, (2) shifting it to the high position with<< 8, (3) extracting the high byte withport >> 8, and (4) combining both bytes with|. The result is that the bytes are swapped.
NOTEI hate assembly.
Check 2: Frida Artifacts Detection (/proc/maps)
Technical Deep Dive
This detection abuses the Linux
/procfilesystem, specifically/proc/self/maps, which is a virtual file provided by the kernel that lists all memory-mapped regions of the current process. Each line contains:start_addr-end_addr permissions offset dev inode pathname. When Frida injectsfrida-agent-64.sointo the target process, new memory regions appear with telltale names likefrida-agent,frida-gadget,libfrida-gum.so, or anonymous mappings named[anon:gum-js-loop]. The anti-frida library bypasses libc entirely and uses direct syscalls viaSVC #0(ARM64 supervisor call instruction) to read this file—this is why we can’t just hookopen()orfopen()in libc. The library executesopenat(syscall 56) with registerx0=AT_FDCWD (-100),x1=pointer to "/proc/self/maps",x2=O_RDONLY. We find these syscall sites by disassemblinglibantifrida.sousing Radare2 with/asj svcto locate allSVC #0instructions and their offsets (e.g.,{"addr":3868,"name":"openat","sysnum":56}).
Before understanding more about how this detection works, we need to understand more about proc and about the wrappers and syscalls.
What is /proc?
The /proc filesystem is a virtual filesystem in Linux that doesn’t exist on disk—it’s generated by the kernel in real-time to expose process and system information. Every running process has a directory /proc/[pid]/ containing information about it.
/proc/self/maps - Memory Map File
/proc/self/maps shows all memory regions mapped into the current process:
- ADDRESS RANGE (e.g.,
749088f000-749098c000) - PERMS (permissions)
- OFFSET, DEV (device)
- INODE, and PATHNAME.
For example: 749088f000-749098c000 r--p 00000000 fd:00 123456 /system/lib64/libc.so. The permissions field uses the following notation: r = readable, w = writable, x = executable, p = private, and s = shared. This file is useful because it shows every loaded library (including frida-agent-64.so), shows anonymous mappings (including [anon:gum-js-loop]), and cannot be hidden from the kernel—it always shows the truth.
/proc/self/status - Process Status File
/proc/self/status provides detailed process information in a human-readable format:
- Name (
com.example.app) which is the process name - State (
S (sleeping)) showing the current state - Tgid (
12345) the thread group ID (PID) - Pid (
12345) the process ID - PPid (
1234) the parent process ID - TracerPid (
0) indicating who is tracing the process - Uid (
10123 10123 10123 10123) the user IDs - Gid (
10123 10123 10123 10123) the group IDs - VmSize (
1234567 kB) the virtual memory size - VmRSS (
12345 kB) the resident memory - Threads (
15) the number of threads.
TIPFor debugging/frida detection, the key field is
TracerPid: if it’s non-zero, something is debugging/tracing this process. When Frida attaches via ptrace,TracerPidwill equal frida-server’s PID.
What is a Wrapper Function?
A wrapper is a convenient function in libc.so that prepares arguments and calls the kernel:
When your code calls open("/proc/maps", 0), it goes through libc.so’s wrapper function before reaching the kernel. The wrapper performs several tasks: it validates the arguments, then sets up the CPU registers with the appropriate values—x0 = AT_FDCWD, x1 = "/proc/maps", x2 = O_RDONLY, and x8 = 56 (the syscall number). Finally, it executes the SVC #0 instruction to trigger the kernel’s syscall handler. Frida can hook the libc wrapper function, but it cannot hook inside the kernel itself.
What is a Syscall?
A syscall (system call) is the interface between userspace and the kernel. On ARM64 (it differs depending on the arch):
On ARM64, syscalls use specific registers:
x8holds the syscall number (which kernel function to call)x0-x5hold the arguments (1st through 6th), and theSVC #0instruction (Supervisor Call) triggers kernel mode. After the syscall returnsx0contains the return value.
Resource:: syscalls.md
Common syscalls include: 56 (openat) to open a file relative to a directory fd, 57 (close) to close a file descriptor, 62 (lseek) to move the file read/write position, 63 (read) to read bytes from a file descriptor, and 64 (write) to write bytes to a file descriptor.
WARNINGdo not forget to check for the arch you are using :“D
Why Anti-Frida Code Uses Direct Syscalls
Normal App (Uses libc Wrapper):
When a normal app calls int fd = open("/proc/self/maps", O_RDONLY);, the execution flow is: Code → libc.so::open() → SVC #0 → Kernel. Frida hooks at the libc.so::open() level, where it can see arguments, modify them, and log calls.
Anti-Frida Code (Direct Syscall):
Anti-frida code bypasses libc entirely by using inline assembly:
mov x8, #56 // syscall number for openat
mov x0, #-100 // AT_FDCWD
ldr x1, ="/proc/self/maps"
mov x2, #0 // O_RDONLY
svc #0 // Direct to kernelThe execution flow becomes: Code → SVC #0 → Kernel. Frida cannot hook by name because there is no function name—just a raw instruction. You must hook the SVC instruction itself at a specific offset.
so the solution was to find SVC #0 offsets in libantifrida.so using Radare2:
r2 libantifrida.so
/asj svc # Search for all SVC instructionsExample output: {"addr":3868,"name":"openat","sysnum":56}
Then hook using: Interceptor.attach(base.add(3868), {...})
WARNINGdo not forget the base address :“D
How the detection works
When Frida injects into a process, it loads libraries and creates memory regions that appear in /proc/self/maps. Anti-frida code scans this file looking for telltale strings.
What Frida leaves behind in memory maps
Normal entries...
7a1234000-7a1235000 r-xp /system/lib64/libc.so
...
╔════════════════════════════════════════════════════════════╗
║ FRIDA ARTIFACTS - THESE REVEAL FRIDA IS PRESENT ║
╠════════════════════════════════════════════════════════════╣
║ 7b5000000-7b5100000 r-xp frida-agent-64.so ║
║ 7b5100000-7b5200000 rw-p frida-agent-64.so ║
║ 7b6000000-7b6001000 r--p frida-gadget.so ║
║ 7b7000000-7b7010000 rw-p [anon:gum-js-loop] ║
║ 7b8000000-7b8100000 r-xp libfrida-gum.so ║
╚════════════════════════════════════════════════════════════╝
Detection Flow
The artifacts detection flow works as follows: The anti-frida code first calls
openat("/proc/self/maps")to open the memory maps file. It then usesread()to process the file line by line. For each line, it searches for suspicious strings including"frida","gadget","gum-js-loop", and"frida-agent". If any of these strings are found, Frida is detected. If not found, the app concludes there is no Frida present.
The Bypass
Actually in here we got 2 Strategies, we will dive into each one of them alone. Lets see which one will work.
Strategy 1
This bypass hooks these specific offsets using
Interceptor.attach(base_addr.add(offset), {...}). In theonEntercallback, we check ifx1contains"self/maps"and redirect it to/data/local/tmp/maps—a fake maps file we created that contains no Frida artifacts.
SO, we will:
- Run the app with
fridaattached, no scripts attached, dump the maps file. - Transfer the
mapsfile to your PC andreplaceall strings that has the stringfrida. - Push back the
mapsfile to/data/local/tmp/mapsand give itchmod 777. Spawnthe app with the solverscriptbelow.- When anti-frida opens
/proc/self/maps, redirect to ourfakefile.
Code
// Inside the syscall hook for openat (syscall 56)
case 56:
// Check if opening /proc/self/maps
if(this.context.x1.readCString().indexOf("self/maps") >= 1){
console.error(`[+] bypassing maps...`);
// Redirect to our clean fake maps file
this.context.x1.writeUtf8String("/data/local/tmp/maps");
}
break;
Bypass Flow
When the anti-frida code calls openat("/proc/self/maps"), our Frida hook intercepts the call. The hook checks if the path contains "self/maps", and if so, redirects it to "/data/local/tmp/maps". This causes the actual syscall to open our fake maps file instead.
The fake maps file contents contain only normal entries with no Frida strings:
7a1234000-7a1235000 /system/libc.so
... normal entries only ...When the app searches for "frida" in the redirected file, it finds nothing. The app concludes: “No Frida detected”.
However, this is not what happened :”(, the app kept
crashing, or stuck at thesplashscreen. Thats why we madeStrategy 2:“D My thought is this is happening as we are forcing the system to read the maps file from another directory, while the frida logs show that it does open it, it takes too long to show that it closed it, which why something went wrong inside while reading/parsing it.
Strategy 2
In this bypass we evade basic Frida artifact checks (e.g.
/proc/pid/maps,/data/local/tmp/re.frida.server) by patching the Androidfrida-serverbinary so all visible agent/server names become custom (brida-bgent-*).
I found this article with this github issue which talks about this idea/solution very helpful.
1. Download and unpack frida-server
Pick the right version/arch and unpack:
wget https://github.com/frida/frida/releases/download/16.5.6/frida-server-16.5.6-android-arm64.xz
unxz frida-server-16.5.6-android-arm64.xz
mv frida-server-16.5.6-android-arm64 frida-server
chmod +x frida-serverQuick recon of embedded strings:
strings frida-server | grep -i 'frida-agent'
strings frida-server | grep -i 're.frida.server'2. Python patcher (frida → brida-bgent)
from pathlib import Path
IN_PATH = Path("frida-server")
OUT_PATH = Path("brida-bgent-server") # you can change this to whatever you want.
REPLACEMENTS = {
# Concrete agent .so names
b"frida-agent-32.so": b"brida-bgent-32.so",
b"frida-agent-64.so": b"brida-bgent-64.so",
b"frida-agent-arm.so": b"brida-bgent-arm.so",
b"frida-agent-arm64.so": b"brida-bgent-arm64.so",
# Generic template string
b"frida-agent-<arch>.so": b"brida-bgent-<arch>.so",
# Container / helper names
b"frida-agent-container": b"brida-bgent-container",
# Raw agent libs
b"libfrida-agent-raw.so": b"libbrida-bgent-raw.so",
# Directory name
b"re.frida.server": b"re.brida.server",
}
data = IN_PATH.read_bytes()
for orig, repl in REPLACEMENTS.items():
if len(orig) = len(repl):
raise ValueError(f"Length mismatch: {origr} vs {replr}")
count = data.count(orig)
if count == 0:
print(f"[] Pattern not found: {origr}")
continue
print(f"[+] Replacing {count} occurrence(s) of {origr} with {replr}")
data = data.replace(orig, repl)
OUT_PATH.write_bytes(data)
OUT_PATH.chmod(0o755)
print(f"[+] Wrote patched server to {OUT_PATH}")Run:
python3 brida-patch.py3. Verify patched artifacts
strings brida-bgent-server | grep -i 'frida-agent'
strings brida-bgent-server | grep -i 'brida-bgent'
strings brida-bgent-server | grep -i 're.frida.server'
strings brida-bgent-server | grep -i 're.brida.server'Expected: only the harmless error message still contains frida-agent, all real artifacts are brida-bgent-* and re.brida.server.
4. Deploy on device
adb push brida-bgent-server /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "su -c /data/local/tmp/frida-server &"Attach from PC as usual (same frida CLI / version). On target app:
adb shell "grep -i brida /proc/$(pidof <package>)/maps"
adb shell "grep -i frida /proc/$(pidof <package>)/maps"
adb shell "ls -R /data/local/tmp | grep -i brida"And this strategy worked like a charm. Now /proc/pid/maps and /data/local/tmp expose brida-bgent-*.so and re.brida.server, so naive Frida name-based detections on frida-agent.so / re.frida.server no longer fire.
TIPcan you think of another way to bypass this ?
Some Questions that came to my mind
Q: Why use /proc/self/maps?
A: This special file shows ALL memory regions mapped into the current process. It’s provided by the Linux kernel and always shows what’s really loaded, so, triying to remove such file will cause the app even the system to crash
Q: What strings does anti-frida look for?
A: Common ones include:
gadget- frida-gadget.sogum- frida’s Gum engineagent- frida-agent.so
Q: Why can’t we just search by module and hook directly?
A: Because
libantifrida.sodoesn’t exist in memory when our script starts. It’s loaded later by the app at runtime.
Q: Why Direct Syscall Hooking is Necessary?
Normal hooking by function name won’t work because
libantifrida.souses direct syscalls, bypassinglibcentirely.
Q: Why not just hook by function name like openat, read, close?
A: Because anti-frida code doesn’t call libc functions. It uses direct
syscalls.
Q: What’s the difference?
Normal App
App code → libc.so (openat function) → kernel syscall
↑
Frida can hook hereAnti-Frida Code
App code → SVC #0 instruction (direct syscall) → kernel
↑
No function to hook Must hook the SVC instruction itself.Lessons Learned
Sometimes you can’t just hook everything or fake/block every check—doing so will lead to crashes, timeouts, unintended behaviors, and an unstable runtime. Instead, it’s often better to use a workaround and view the detection from the other side. This approach typically requires less effort, less overthinking, and yields better results.
Credits & Resources
This writeup is based on the work of fatalSec. The sample APK and solver scripts used in this guide are his creations—full credit goes to him.
- YouTube Tutorial: Advanced Frida Detection Bypass
- GitHub Repository: android_in_app_protections
Shoutout to @happyjesterr for helping me thru it.
