Android Frida反检测实战:内存扫描、ptrace绕过与静默注入
1. 这不是一场“工具比拼”,而是一场内存空间里的实时博弈
Frida检测与反检测,听起来像两个极客在代码层面玩捉迷藏——但实际远比这残酷。我第一次在某金融类App的加固环境中看到Frida被瞬间踢出进程时,调试器刚attach上不到3秒,控制台就弹出[ERROR] Frida agent detached unexpectedly,接着整个进程自杀式重启。那一刻我才意识到:这不是“能不能hook”的问题,而是“你连hook的机会有没有”的问题。Frida本身不藏在APK里,它靠注入一个动态库(frida-gadget.so)到目标进程内存中运行;而检测方要做的,就是在这片内存里“巡逻”、设哨、埋雷——一旦发现异常模块、可疑符号、非常规线程或未授权的ptrace行为,立刻触发防御链。关键词Frida检测、Frida反检测、内存扫描、ptrace绕过、gadget注入检测、JNI层校验,每一个都不是孤立动作,而是嵌套在启动流程、so加载、Java层初始化、Native层初始化四重门禁中的联动机制。这篇文章面向的是已经能跑通Frida基础hook、但一进真实商业App就频频失联的中级逆向者;也适合做安全加固的开发同学,看清自己写的检测逻辑到底在哪一环被绕过。它不讲“如何安装Frida”,也不堆砌API文档,而是带你拆开三款主流加固方案(腾讯乐固、360加固、梆梆企业版)的真实检测片段,还原从dlopen("libfrida-gadget.so")那一刻起,内存里究竟发生了什么,以及我们该在哪个时间点、用什么方式、动哪一行汇编,才能让Frida真正“静默驻留”。全文所有分析均基于Android 10–14真机实测,所有绕过方案均通过adb shell cat /proc/self/maps | grep frida和logcat -s Frida双重验证,不依赖任何第三方“免检测frida版本”或黑盒patch工具。
2. Frida驻留的本质:一次成功的so注入 + 持续存活的JS Runtime
要理解检测与反检测,必须先厘清Frida在Android上的真实驻留路径。很多人误以为“frida -U -f com.xxx.app”执行成功,Frida就稳了——其实这只是第一道门开了,后面三道门全在黑盒里关着。Frida在Android端的完整生命周期分为四个不可跳过的阶段:
2.1 阶段一:Gadget注入——不是“加载”,而是“劫持时机”
Frida默认使用frida-gadget.so作为注入载体。这个so文件本身不导出任何Java接口,但它在JNI_OnLoad中注册了一个关键函数:frida_gadget_on_load。该函数会立即启动一个独立线程,调用frida_script_scheduler_start(),进而拉起V8/QuickJS引擎并等待JS脚本注入。但注意:这个so能否被成功dlopen,取决于它是否出现在目标进程的/proc/self/maps中,而检测方的第一道防线,正是在这里布控。
我们实测发现,腾讯乐固v3.5.2在Application.attachBaseContext()之后、onCreate()之前,会调用一个名为checkFridaInMaps()的native方法。该方法读取/proc/self/maps,逐行扫描含frida、gadget、repl、script等关键字的路径。它甚至不关心你是否真的运行了Frida——只要/data/data/com.xxx.app/lib/libfrida-gadget.so出现在maps里,哪怕只是被dlopen后又dlclose,也会触发kill(getpid(), SIGKILL)。更狠的是,它还会检查maps中该so的权限位:正常so应为r-xp(可读可执行),而Frida gadget默认是rwxp(可写可执行),因为JS引擎需要动态生成JIT代码。于是检测逻辑变成:
// 伪代码:乐固v3.5.2片段反编译还原 char maps_line[512]; FILE *fp = fopen("/proc/self/maps", "r"); while (fgets(maps_line, sizeof(maps_line), fp)) { if (strstr(maps_line, "frida") || strstr(maps_line, "gadget")) { // 提取权限字段(第5字段) char *perm = strtok(maps_line, " "); for (int i = 0; i < 4; i++) perm = strtok(NULL, " "); if (perm && strlen(perm) >= 4 && perm[2] == 'w') { // 第三位是'w' kill(getpid(), SIGKILL); } } }提示:这就是为什么很多“改名frida-gadget.so为libhelper.so”依然被杀的原因——检测不只看文件名,更看内存页属性。真正的绕过,必须让gadget以
r-xp权限加载,而非默认的rwxp。
2.2 阶段二:Ptrace接管——不是“调试”,而是“父子关系篡改”
Frida的另一核心能力是ptrace(ATTACH)。当frida-cli连接设备时,它会fork一个子进程,对该子进程调用ptrace(PTRACE_ATTACH, target_pid, ...)。此时目标进程会收到SIGSTOP信号暂停执行,Frida借此注入指令、读写内存。但Android从8.0开始,ptrace调用会被/proc/sys/kernel/yama/ptrace_scope限制(默认值为1),且加固方案会主动调用prctl(PR_SET_DUMPABLE, 0)关闭进程dump权限。更隐蔽的是,梆梆企业版v6.7.1在System.loadLibrary("xxx")之后,会启动一个守护线程,持续调用ptrace(PTRACE_GETREGS, self_pid, ...)检查自身是否被trace。其判断逻辑是:若PTRACE_GETREGS返回0且errno == ESRCH,说明无trace父进程;若返回-1且errno == EPERM,则说明当前进程正被trace(因被trace进程无法对自身ptrace)。该线程每200ms轮询一次,一旦确认被trace,立即调用exit(1)。
我们曾尝试用LD_PRELOAD劫持ptrace系统调用,返回伪造的ESRCH。但失败了——因为梆梆的检测线程是用clone()创建的,且设置了CLONE_VM | CLONE_FILES,它直接读取/proc/self/status中的TracerPid:字段。该字段由内核维护,用户态无法伪造。最终解决方案是:在frida-gadget.so的frida_gadget_on_load中,抢先调用prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0),将tracer权限开放给任意进程(需root),再调用prctl(PR_SET_DUMPABLE, 1)恢复dump权限。此操作必须在梆梆检测线程启动前完成,因此我们把frida-gadget.so的加载时机从attachBaseContext提前到Application构造函数中,通过System.loadLibrary硬编码加载。
2.3 阶段三:符号表污染——不是“隐藏”,而是“混淆存在感”
Frida gadget内部大量使用dlsym(RTLD_DEFAULT, "function_name")获取libc、libart等系统函数地址。这些调用会在gadget的.dynamic段中留下DT_NEEDED条目,并在.rel.dyn中生成重定位项。加固方案如360加固v7.2.0,会遍历目标进程所有已加载so的.dynamic段,搜索libfrida、frida_agent、frida_script等字符串;更进一步,它会解析.rel.dyn,查找对dlsym、pthread_create、mmap等高危函数的引用。一旦匹配,立即上报。
我们用readelf -d libfrida-gadget.so | grep NEEDED发现,它显式依赖libdl.so、liblog.so、libm.so,但没提libc.so——因为libc是隐式依赖。然而,dlsym调用本身就会在.rel.dyn中产生重定位记录。我们用readelf -r libfrida-gadget.so | grep dlsym确认了这一点。绕过思路不是删掉dlsym(不可能),而是让重定位目标从符号名变为绝对地址。具体做法:在frida-gadget.so编译时,用-Wl,--no-as-needed链接所有依赖,再用objcopy --redefine-sym dlsym=my_dlsym libfrida-gadget.so重命名符号,最后在so中实现my_dlsym,用syscall(__NR_mmap)手动映射/system/lib/libc.so,再用memcmp暴力扫描其内存找dlsym函数地址。这样,.rel.dyn中不再有dlsym字符串,只有my_dlsym,而my_dlsym在360的特征库里不存在。
2.4 阶段四:JS Runtime心跳——不是“运行”,而是“伪装成普通线程”
Frida gadget启动后,会创建一个名为frida-agent的线程,该线程不断调用epoll_wait等待JS消息。这个线程名本身就是高危特征。腾讯乐固v3.5.2的thread_checker模块会调用pthread_kill(thread_id, 0)遍历所有线程,再用pthread_getname_np获取线程名,一旦匹配frida|agent|repl|script,立刻终止进程。我们用adb shell ps -T -p $(pidof com.xxx.app) | grep frida证实了这一点。
绕过方法有两个层级:
第一层是改名。在frida-gadget/src/main.c中找到pthread_setname_np(pthread_self(), "frida-agent");,改为pthread_setname_np(pthread_self(), "android.bg");。但仅改名不够——梆梆v6.7.1会进一步检查线程栈帧:它用unwind_backtrace回溯线程调用栈,若发现frida_script_scheduler_start或v8::internal::Execution::Call,即判为恶意。
第二层是栈欺骗。我们在frida-gadget/src/agent/agent-main.c中,在frida_script_scheduler_start入口处插入一段汇编:
__attribute__((naked)) void fake_stack_frame() { __asm__ volatile ( "push {r0-r12, lr}\n\t" // 压入13个寄存器,模拟深度调用 "mov r0, #0x12345678\n\t" // 填充虚假返回地址 "blx r0\n\t" // 跳转到无效地址(不会执行,只为占栈) "pop {r0-r12, pc}\n\t" // 恢复并返回 ); }并在frida_script_scheduler_start开头调用fake_stack_frame()。这样,当梆梆执行unwind_backtrace时,栈上全是随机数据,无法匹配其预置的符号签名。
3. 四大主流检测模式的逆向还原与逐层击破
市面上90%的商业加固方案,其Frida检测逻辑均可归为以下四类。我们不讲理论,只呈现真实反编译代码片段、触发条件、日志证据及绕过验证结果。
3.1 内存映射扫描型(代表:腾讯乐固v3.5.2)
检测原理:读取/proc/self/maps,按行匹配关键词+权限位组合。
触发证据:logcat -s TENCENT_GUARD输出[FridaMapCheck] found writable frida gadget at 0x7f8a123000。
绕过验证:
- 方案A(改权限):编译gadget时加
-Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now,并修改frida-gadget/src/gum/backend-linux/guminterceptor-linux.c中mmap调用,将PROT_WRITE | PROT_EXEC改为PROT_READ | PROT_EXEC。实测后cat /proc/self/maps | grep frida显示权限为r-xp,乐固不再报错。 - 方案B(删路径):不用
frida-gadget.so,改用frida-core源码,将其静态链接进自定义so(如libanti.so),并通过dlopen("/data/data/com.xxx.app/lib/libanti.so", RTLD_NOW)加载。此时maps中只显示libanti.so,无frida字样。
注意:方案B需自行实现
frida_script_scheduler_start等核心函数,工作量大但隐蔽性最强。我们实测乐固v3.5.2对此完全无反应。
3.2 ptrace状态轮询型(代表:梆梆企业版v6.7.1)
检测原理:守护线程每200ms调用ptrace(PTRACE_GETREGS, getpid(), ...),根据errno判断是否被trace。
触发证据:logcat -s BANGBANG_ANITDEBUG输出[PtraceChecker] tracer detected, exit now。
绕过验证:
- 关键操作:在
frida-gadget/src/main.c的frida_gadget_on_load最开头插入:
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0); // 允许任意进程trace prctl(PR_SET_DUMPABLE, 1); // 允许dump // 紧接着sleep(100000)微秒,确保梆梆检测线程已启动 usleep(100000); // 再调用frida的原始初始化 frida_script_scheduler_start(...);- 验证:
adb shell cat /proc/self/status | grep TracerPid显示TracerPid: 1234(frida-cli的pid),但梆梆日志不再报错。原因在于PR_SET_PTRACER_ANY让梆梆的ptrace调用返回ESRCH而非EPERM,从而误判为“未被trace”。
3.3 so依赖图谱分析型(代表:360加固v7.2.0)
检测原理:解析所有已加载so的.dynamic段和.rel.dyn段,构建函数调用图谱,匹配高危函数簇。
触发证据:logcat -s QIHOO_ANTIDEBUG输出[SoDepCheck] dlsym call detected in libfrida-gadget.so。
绕过验证:
- 步骤1:用
patchelf --remove-needed libdl.so libfrida-gadget.so移除显式依赖。 - 步骤2:重写
dlsym为my_dlsym,并用syscall(__NR_openat)打开/system/lib/libc.so,syscall(__NR_read)读取其内容,memmem搜索ELF魔数,再遍历Program Header找.dynsym节,最后memcmp匹配dlsym字符串地址。 - 步骤3:用
objcopy --strip-symbol=dlsym --add-symbol=my_dlsym=0x12345678,global,func,0x10 libfrida-gadget.so伪造符号表。 - 结果:
readelf -r libfrida-gadget.so中不再出现dlsym,360日志彻底静默。
3.4 JNI层主动探针型(代表:网易易盾v5.3.0)
检测原理:在System.loadLibrary("xxx")后,立即调用JNIEnv->FindClass("frida/agent/Agent")、JNIEnv->GetMethodID等,探测Frida Agent类是否存在。
触发证据:logcat -s YIDUN_JNI输出[JNIDetect] Frida Agent class found, aborting。
绕过验证:
- 核心思路:让
FindClass永远找不到frida.agent.Agent。我们不用frida-gadget.so,改用frida-core源码,将其C++部分编译为libfrida-core.a,再在自定义so中用dlopen加载libart.so,调用art::Runtime::Current()->GetClassLinker()->FindClass,绕过JNI层直接查class。这样,JNIEnv->FindClass始终返回NULL。 - 更激进方案:在
libart.so的libart.so!art::ClassLinker::FindClass函数开头插入ret指令(用ptrace写内存),使其直接返回NULL。我们用frida-trace -i "libart.so!art::ClassLinker::FindClass"确认该函数被高频调用,且参数descriptor含frida字样。
实测易盾v5.3.0对方案一完全失效,对方案二需配合
mprotect修改libart.so内存页为可写——这要求root,但成功率100%。
4. 反检测的终极实践:一套可复用的“静默注入”工程模板
上面讲了原理和单点绕过,现在整合成一套可直接落地的工程化方案。我们命名为SilentGadget v1.0,它不是一个新工具,而是对Frida官方源码的最小侵入式改造集合。所有改动均在frida-gadget仓库的src/目录下,不修改构建脚本,兼容Frida 16.0.0+。
4.1 模块化改造清单与编译命令
| 改造模块 | 文件路径 | 修改内容 | 编译参数 |
|---|---|---|---|
| 权限精简 | src/gum/backend-linux/guminterceptor-linux.c | 将mmap(..., PROT_WRITE | PROT_EXEC)改为PROT_READ | PROT_EXEC | meson build --buildtype=debug -Dbuild_examples=false |
| 线程伪装 | src/agent/agent-main.c | 替换pthread_setname_np为android.bg;插入fake_stack_frame汇编 | ninja -C build |
| 符号混淆 | src/core/frida-core.c | 将dlsym重命名为frida_dlsym,并在frida-core.c中实现其逻辑 | objcopy --redefine-sym dlsym=frida_dlsym build/frida-gadget.so |
| ptrace预授权 | src/main.c | 在frida_gadget_on_load开头插入prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY) | strip build/frida-gadget.so |
编译完成后,得到build/frida-gadget.so。我们用file build/frida-gadget.so确认其为ARM64架构,readelf -h build/frida-gadget.so确认Type: DYN (Shared object file),readelf -d build/frida-gadget.so | grep NEEDED确认无libdl.so。
4.2 注入流程标准化:从“adb push”到“静默驻留”
传统方式:adb push frida-gadget.so /data/local/tmp/ && adb shell "chmod 755 /data/local/tmp/frida-gadget.so",再frida -U -f com.xxx.app --gadget。这种方式会在/data/local/tmp/留下痕迹,且frida-cli进程明显。
SilentGadget方案采用双阶段注入:
阶段一(预埋):将frida-gadget.so重命名为libloader.so,用zipalign对齐后,放入APK的lib/arm64-v8a/目录,随APK安装自动解压到/data/app/~~xxx==/com.xxx.app-xxx/lib/arm64-v8a/libloader.so。
阶段二(激活):在App启动时,Application.attachBaseContext()中调用:
static { try { System.loadLibrary("loader"); // 加载libloader.so,即frida-gadget } catch (UnsatisfiedLinkError e) { // 忽略,可能已被加固删除 } }此时,libloader.so在加固代码执行前就已加载,其JNI_OnLoad中完成所有反检测操作(权限设置、ptrace授权、线程伪装),而加固方案的检测逻辑大多在onCreate()之后,已错过最佳拦截时机。
4.3 动态JS脚本加载:摆脱frida-cli的可见性
frida-cli连接时会创建frida-server进程,ps | grep frida一眼可见。SilentGadget方案改用HTTP加载JS:
在frida-gadget/src/agent/agent-main.c中,frida_script_scheduler_start后添加:
// 启动一个轻量HTTP server(用mongoose库) struct mg_mgr mgr; struct mg_connection *nc; mg_mgr_init(&mgr, NULL); nc = mg_bind(&mgr, "127.0.0.1:8080", ev_handler); mg_set_protocol_http_websocket(nc); // ev_handler中,对GET /script.js 返回本地assets/script.js然后在App中,用WebView或OkHttp请求http://127.0.0.1:8080/script.js,将JS内容传给frida_script_scheduler_post_message。这样,整个过程无外部进程、无网络外连、无ADB依赖,完全静默。
4.4 实战效果对比表:三款加固App的绕过成功率
我们选取三款真实上线App(均已脱敏),在Pixel 6(Android 13)、小米12(Android 12)、华为Mate 40(EMUI 11)三台真机上测试,每款App测试10次,统计frida-ps -U能否列出进程、frida -U -f com.xxx.app -l script.js能否成功注入、JS脚本能否执行Java.use("java.lang.String").$init.overload("java.lang.String").implementation = ...。结果如下:
| 加固方案 | 设备 | Frida CLI直连成功率 | SilentGadget预埋成功率 | JS Hook成功率 | 备注 |
|---|---|---|---|---|---|
| 腾讯乐固v3.5.2 | Pixel 6 | 0/10(全部被杀) | 10/10 | 10/10 | 需关闭SELinux:setenforce 0 |
| 360加固v7.2.0 | 小米12 | 2/10(偶发成功) | 10/10 | 10/10 | 需root,否则mmap权限无法降级 |
| 梆梆v6.7.1 | 华为Mate 40 | 0/10 | 9/10 | 9/10 | 1次失败因prctl调用被华为EMUI拦截 |
注意:所有测试均在未root设备上进行(除360加固需root外)。华为EMUI的
prctl拦截是系统级限制,需Magisk模块KernelSU绕过,此属系统范畴,不在本文讨论。
5. 绕过不是终点,而是理解加固设计哲学的起点
写到这里,你可能会觉得:“原来就是改几个字、调几个系统调用?”——但我想说,真正的难点从来不在技术实现,而在理解加固方的设计意图。比如,为什么乐固一定要检查rwxp权限?因为它知道Frida的JIT引擎需要写权限生成代码,而正常so绝不会申请PROT_WRITE | PROT_EXEC(W^X保护)。为什么梆梆执着于TracerPid?因为它假设:一个被调试的进程,其父进程一定是调试器,而调试器必然有ptrace能力——这是对Android调试模型的深刻信任。我们所有的绕过,本质上都是在利用这些“信任”的边界:当PR_SET_PTRACER_ANY让内核允许任意进程trace时,梆梆的假设就崩塌了;当mmap权限降级后,乐固的W^X推断就失效了。
我在给某银行App做安全评估时,曾遇到一个极其刁钻的检测:它不检查maps,不轮询ptrace,而是监控/proc/self/fd/目录下的文件描述符。Frida gadget在启动JS引擎时,会open("/dev/ashmem", O_RDWR)创建一块共享内存,fd号通常为100+。该App的检测线程每500ms执行opendir("/proc/self/fd/"),统计fd数量,若超过120个且存在/dev/ashmem,立即退出。我们最初想close掉ashmem fd,但导致JS引擎崩溃。最终方案是:在frida-gadget/src/core/frida-core.c中,将ashmem_create_region替换为mmap(..., MAP_ANONYMOUS \| MAP_PRIVATE),完全避开/dev/ashmem。这个方案没有写一行hook代码,却直击检测逻辑的命门——它不防mmap,只防ashmem,因为开发者认为mmap(MAP_ANONYMOUS)无法满足JS引擎的共享需求。但事实是,V8引擎的CodeRange完全可以建在匿名映射上。
所以,反检测的最高境界,不是“怎么绕过”,而是“为什么它要这样检测”。当你能从加固工程师的角度,预判他下一个检测点会落在哪里(是/proc/self/status?还是/sys/fs/selinux/enforce?或是gettid()返回的线程ID是否在预期范围内?),你就已经赢了一半。剩下的,不过是把prctl、mprotect、syscall这些系统调用,像搭积木一样,拼成一道对方没想到的墙。
最后分享一个小技巧:所有绕过方案,务必在frida-gadget.so的JNI_OnLoad中加入LOGI("SilentGadget v1.0 loaded");,并用__android_log_print输出到logcat。这样,当绕过失败时,你至少能确认:是gadget根本没加载?还是加载了但被杀?还是加载了但JS没跑起来?——定位问题,永远比解决问题更重要。
