资讯动态

Frida绕过Anti-Frida检测:原理、Hook与实战脚本

发布时间:2026/10/22 6:20:02 来源:尧图企业网站定制
搞安卓逆向和App安全测试的朋友应该都跟Frida打过交道。它是个动态插桩框架能直接在运行中的进程里注入JavaScript脚本实现看函数入参、改返回值、调内部方法、抓协议、过签这些事。正因为太好用越来越多的App尤其是金融类、通信类、有版权保护的开始专门做Anti-Frida检测——只要发现端口27042开着、进程列表里出现frida-server、maps映射里出现frida-agent就直接闪退、弹窗或者拉黑设备。这篇文章想聊的就是这些Anti-Frida检测到底在查什么以及从安全研究和漏洞分析角度我们怎么用Frida自己把这些检测绕过去。文末会给出完整可跑的脚本同时把原理讲明白方便你自己针对不同App做调整。先说明一点下面的内容只建议用在你拥有或获得明确授权的目标上比如自己开发的App、测试环境里的加固验证、CTF和漏洞挖掘练习。把这些检测手法研究透了反过来能帮做防御的一方把壳做得更硬这才是这个方向真正的价值。1. Anti-Frida检测到底在查什么要绕过检测第一步不是写脚本而是搞清楚对方在查什么。Frida在运行期会留下几类非常明显的痕迹绝大多数Anti-Frida方案都是围绕这些痕迹做文章的。1.1 端口、进程和文件系统这“老三样”最基础的检测是从外部环境入手。Frida的服务端frida-server默认监听本机27042端口而且这个端口对外会暴露一个D-Bus协议栈。检测方只要尝试用socket连接一下本机的27042端口能连上就基本可以断定有Frida在运行。有些做得细一点的还会向这个端口发一条D-Bus的AUTH握手消息如果收到REJECTED之类的响应就坐实了。其次是进程和文件。frida-server通常以/data/local/tmp/frida-server这个路径存放App通过Runtime.exec()执行ps、遍历/proc目录或者读/proc/net/tcp很容易发现一个名字里带frida的进程。所以大多数人拿到设备第一件事就是把frida-server改名为mydaemon之类的这能挡住一部分检查但挡不住所有。再就是文件系统层面的扫描。检测方会主动去查找几个常见路径/data/local/tmp/frida-server、/sdcard/frida-server、/data/local/tmp/re.frida.server等。这种检查成本低很多商业加固方案都会带上。1.2 进程内部的痕迹才是大头外部检查稍微改改名就能糊弄过去但进程内部的东西就没那么好藏了。Frida注入进程后会加载一个agent库旧版本叫frida-agent.so新版本叫libgadget.so或者以随机名出现。App只要遍历/proc/self/maps搜一下里面有没有frida、gum-js-loop、gmain、gdbus这些关键词就能发现自己被注入了。这是目前最常见的检测方式也是本文后面要重点处理的。线程名也是重灾区。Frida注入后会在目标进程里创建好几个线程比如gmain、gdbus、gum-js-loop、pool-frida-xxx。检测方遍历/proc/self/task/目录读取每个线程的comm或stat文件匹配这些名字就触发检测。这里注意光改了frida-server的文件名没用线程名是注入后才出现的跟外部二进制无直接关系。还有个容易被忽略的点是/proc/self/status里的TracerPid字段。如果进程被调试器或者这类工具附加过这个字段会显示跟踪进程的PID。很多App会周期性检查这个值一旦发现非零就判定被调试、被注入。检测对象常见检查方式典型特征默认端口socket连接127.0.0.1:27042连接成功即判定D-Bus指纹对27042发送AUTH握手收到REJECTED响应frida-server进程ps / 遍历/proccmdline含fridafrida-agent库读/proc/self/maps映射含frida、gum线程名读/proc/self/task/*/commgmain、gum-js-loop调试状态读/proc/self/statusTracerPid不为0内存特征扫描模块基址附近字节出现inline hook跳转指令1.3 为什么很多检测在Java层就能拦掉一半国内很多App的检测逻辑其实写在Java层写法比较“直给”直接用Runtime.getRuntime().exec(cat /proc/self/maps)去抓内容然后contains(frida)判断或者用new File(/proc/self/maps).readLines()去逐行匹配。这类实现虽然能拦截普通用户但只要我们在Java层把ProcessBuilder和BufferedReader的读取结果替换一下整个检测链就失效了。还有一部分检测下沉到底层native代码绕开Java的File API直接用open()、read()、fopen()、fgets()去读/proc下的文件再用strstr()、memmem()去匹配关键词。这种就需要我们hook libc函数来对付。想清楚一个问题检测方无论用Java还是native最终都要经过libc的文件读写接口。所以我们有一个通用的思路——在native层做统一的“内容过滤器”。这也是下面几套脚本的核心逻辑。2. 绕过的三条路线先定方向再动手别一上来就找脚本。绕过方案大体可以分三条路线适用范围不同复杂度也不同。2.1 路线A让Frida的痕迹从源头消失这条路线的思路是“尽量让检测方看不到Frida”而不是去拦截检测方的检查动作。具体做法包括把frida-server改名为无关的名字放到非标准路径。用-l参数指定非默认端口比如监听127.0.0.1:38460然后用frida -H连接。用adb forward把设备端口转发到电脑本地这样App扫描网络端口时只能看到adb相关端口看不到Frida端口。把libgadget嵌入APK伪装成一个普通的so库随App冷启动加载。这条路线能解决外部检测和一部分简单的maps/端口检测但对线程名检测无能为力因为注入后线程名还是带特征。所以路线A不能单独作为完整方案要结合路线B或者C。2.2 路线B动态Hook检测函数做“结果过滤器”路线B是本文的主角。思路是不藏了就让Frida注入进去但我们把检测方用于读取信息的函数拦住让它们拿到“干净”的结果。具体策略有几种Java层hookProcessBuilder.start、Runtime.exec、BufferedReader.readLine把包含敏感信息的命令替换掉或把读到的问题行替换成无害内容。native层hookfopen/fgets、open/read当检测方读取/proc/self/maps、/proc/self/status、/proc/self/task时过滤掉含frida、gum-js-loop、gmain、gdbus的文本。更聪明的做法hookstrstr、memmem、strcmp这类字符串匹配函数让检测方在内容里搜不到我们想让它们搜到的关键词。这个思路非常实用因为很多native检测都是先读完整文本再调用字符串搜索函数去匹配关键词我们只要在搜索这个环节做文章就不用去改文件内容风险小很多。路线B的优点是通用一个脚本能覆盖大部分App缺点是如果对方在native层自己写了内存扫描器不经过字符串匹配函数而是直接比对字节就拦不住。2.3 路线C抢占注入时机很多App在Application.attachBaseContext或第一个JNI_OnLoad阶段就启动检测线程如果我们在App启动完成后才attach检测早就跑完了还会触发闪退。这时候要用Frida的spawn模式frida -H 127.0.0.1:38460 -f com.example.target -l bypass.js --no-pause-f会让Frida直接拉起App在进程最早期入口函数处挂起等我们的脚本注入完成后再继续运行。这样Java层的hook能在Application的onCreate之前装好native层的hook也能比绝大多数检测逻辑更早生效。实测下来很多“一注入就闪退”的场景换成spawn模式就解决了。三条路线不是互斥的成熟的做法是先用路线A调整外部环境用路线C选择spawn模式保证时机再用路线B做具体检测逻辑的拦截。3. 实战一Java层检测的直接Hook先给一套适合“Java层检测”的脚本。这类App的特征是用Runtime.exec执行shell命令查文件或者用File直接读/proc。3.1 Hook ProcessBuilder与Runtime.exec很多App用的是ProcessBuilder执行cat /proc/self/maps | grep frida之类的命令。我们可以hookProcessBuilder.start在真正启动进程前修改命令列表Java.perform(function () { var ProcessBuilder Java.use(java.lang.ProcessBuilder); ProcessBuilder.start.implementation function () { var cmd this.getCommand().toString(); console.log([bypass] ProcessBuilder.start - cmd); if (cmd.indexOf(frida) ! -1 || cmd.indexOf(maps) ! -1 || cmd.indexOf(status) ! -1) { // 把敏感命令替换成全无害命令 var ArrayList Java.use(java.util.ArrayList); var fakeCmd ArrayList.$new(); fakeCmd.add(echo); fakeCmd.add(clean); this.command(fakeCmd); console.log([bypass] 已替换敏感命令); } return this.start(); }; });这里用到了ProcessBuilder.command(List)方法去重置命令列表。替换成echo clean后检测方拿到的输出里没有任何我们要隐藏的内容而且进程能正常启动不会因为返回null导致检测代码NPE崩掉。如果对方不走ProcessBuilder而是直接Runtime.getRuntime().exec(cat /proc/self/maps)就再hookRuntime.execJava.perform(function () { var Runtime Java.use(java.lang.Runtime); var originalExec Runtime.exec; Runtime.exec.overload([Ljava.lang.String;).implementation function (cmdArr) { var cmd cmdArr.join( ); console.log([bypass] Runtime.exec - cmd); if (cmd.indexOf(frida) ! -1 || cmd.indexOf(maps) ! -1 || cmd.indexOf(status) ! -1) { var fake [echo, clean]; return this.exec(fake); } return this.exec(cmdArr); }; });注意Runtime.exec有不同的重载String和String[]都需要处理。为了严谨建议用Runtime.exec.overloads遍历一遍把所有重载都hook上这里为了篇幅只演示最常见的一种。3.2 在Java层过滤/proc文件的读取还有一类App不走命令行直接用FileInputStream或BufferedReader读/proc/self/maps、/proc/self/status。命令行替换对它们无效我们得过滤读取结果。比较稳的做法是hookBufferedReader.readLineJava.perform(function () { var BufferedReader Java.use(java.io.BufferedReader); var originalReadLine BufferedReader.readLine; BufferedReader.readLine.implementation function () { var line originalReadLine.call(this); if (line ! null) { if (line.indexOf(frida) ! -1 || line.indexOf(gum-js) ! -1 || line.indexOf(gmain) ! -1 || line.indexOf(gdbus) ! -1) { console.log([bypass] 过滤行: line); return \n; // 返回空行保持行数 } // 处理 TracerPid if (line.indexOf(TracerPid) ! -1) { return TracerPid:\t0; } } return line; }; });这里的要点是用\n替换而不是返回null。因为很多检测代码会按行遍历如果提前返回null遍历提前结束可能导致indexOutOfBounds之类的异常反而触发别的崩溃。而返回一个空行只会让contains(frida)的判断为false不会破坏整体结构。Java层Hook的好处是写起来快、调试方便缺点是对native层检测毫无作用。真正难缠的App会把读取和检测都放在nativeJava层的替换根本不会被调用到。4. 实战二Native层字符串级过滤Native层检测是绕不开的战场。这里分享我实际用得最多的一套组合脚本文件内容过滤 搜索函数过滤。4.1 fopen fgets的文件读取过滤很多native检测是用fopen打开/proc/self/maps再循环调fgets按行读取。我们可以在fopen阶段记住当前打开的是哪个敏感文件然后在fgets返回时检查行内容var currentFile null; function isSensitive(path) { if (!path) return false; return path.indexOf(/proc/self/maps) ! -1 || path.indexOf(/proc/self/task) ! -1 || path.indexOf(/proc/self/status) ! -1; } function hasFridaTraces(line) { if (!line) return false; return line.indexOf(frida) ! -1 || line.indexOf(gum-js) ! -1 || line.indexOf(gmain) ! -1 || line.indexOf(gdbus) ! -1; } var fopen Module.findExportByName(libc.so, fopen); if (fopen) { Interceptor.attach(fopen, { onEnter: function (args) { this.path Memory.readUtf8String(args[0]); }, onLeave: function (retval) { if (!retval.isNull() isSensitive(this.path)) { currentFile this.path; console.log([bypass] fopen敏感文件: this.path); } } }); } var fgets Module.findExportByName(libc.so, fgets); if (fgets) { Interceptor.attach(fgets, { onEnter: function (args) { this.buf args[0]; this.size args[1].toInt32(); }, onLeave: function (retval) { if (retval.isNull()) return; if (currentFile isSensitive(currentFile)) { var line Memory.readCString(this.buf); if (hasFridaTraces(line)) { Memory.writeUtf8String(this.buf, \n); console.log([bypass] 已过滤fgets行); } if (currentFile.indexOf(/proc/self/status) ! -1 line.indexOf(TracerPid) ! -1) { Memory.writeUtf8String(this.buf, TracerPid:\t0\n); } } } }); }这里有个非常关键的细节fgets的onLeave里retval如果是NULL说明读到文件尾这时候不要碰buffer只有非NULL才说明这次确实读到一行。另外只替换成单字符\n是刻意的因为Frida在onLeave阶段改写buffer时如果写入超出原内容长度的字符串有概率越过缓冲区造成内存破坏。为什么很多检测读/proc/self/maps还是会被拦因为/proc下的文件是虚拟文件每次read都是动态生成的。我们过滤掉frida相关行之后检测方拿到的maps内容完全干净自然找不到特征。4.2 处理open read这条更隐蔽的路径比fopen/fgets更底层的是openread组合。检测方先open(/proc/self/maps, O_RDONLY)拿到fd再用read(fd, buf, size)整块读进buffer最后在buffer里用memmem或strstr搜索关键词。处理这种场景更推荐在后面说的“搜索函数过滤”方案因为直接过滤read的返回值很危险——read返回的是字节数如果你改了内容但没同步改返回值检测方按原长度去解析buffer一样能看到你改过的内容但可能越界读。所以我个人不太建议在生产脚本里直接改read的buffer除非你很确定目标App的检测逻辑只是简单判断“读出来的内容里有没有frida”。如果一定要在read这层过滤至少要做到两点一是记录当前打开的是哪个fd二是改写buffer后把返回值同步改成新内容长度。大部分/proc文件读取是一次性读整个文件所以可以这样处理读完之后在onLeave里对buffer做一次字节填充把所有包含特征词的子串替换成空格保持长度不变这样就不用改返回值。这个技巧能避开长度不一致的问题。4.3 瞬间清场Hook字符串搜索函数这是性价比最高的一招。检测方在native层读完文件内容后极大概率会调用strstr、memmem、strcasestr这些函数去搜关键词。我们直接在搜索函数上动手让它们永远搜不到[strstr, memmem, strcasestr].forEach(function (fnName) { var addr Module.findExportByName(null, fnName); if (!addr) { console.log(未找到: fnName); return; } Interceptor.attach(addr, { onEnter: function (args) { var needle null; try { if (fnName memmem) { // memmem(haystack, haylen, needle, needlelen) needle Memory.readCString(args[2]); } else { needle Memory.readCString(args[1]); } } catch (e) {} this.needle needle; }, onLeave: function (retval) { if (this.needle) { var n this.needle.toLowerCase(); if (n.indexOf(frida) ! -1 || n.indexOf(gum-js) ! -1 || n.indexOf(gmain) ! -1 || n.indexOf(gdbus) ! -1 || n.indexOf(re.frida) ! -1) { console.log([bypass] 拦截搜索: this.needle); retval.replace(ptr(0)); // 返回NULL } } } }); });这里用Module.findExportByName(null, fnName)而不是指定libc.so是因为Android高版本在libc之外还有ld-android.so和各个namespace直接搜全局更稳。这个方案的巧妙之处在于它不改数据源头只改搜索行为。检测方读到的maps内容是什么就是什么但搜索函数找不到frida等于间接让检测失效。对strcmp也可以用类似思路但strcmp在正常业务代码里用得极多全量hook误伤太大建议只hookstrcmp并判断其中一个参数是否包含敏感词其他情况直接放行。还有一个变体某些检测库不用libc的strstr而是自己写循环字节比对或者用__strstr这种内部符号。遇到这种情况就得上内存扫描层面的对抗了技术难度陡增实际项目中我会先用上面的方案不行再结合“替换agent名称”的思路。5. 实战三从源头隐藏Frida痕迹脚本过滤再完美也架不住对方用更新的检测姿势。所以我们还要从环境侧做些功夫把容易被发现的痕迹提前消掉。5.1 改名换端口还能怎么玩前面提到过把frida-server改名、换socket监听地址。这里补充一个细节端口尽量选高位随机端口且不要用27042、27043这种默认值。启动命令参考adb push mydaemon /data/local/tmp/mydaemon adb shell chmod 755 /data/local/tmp/mydaemon adb shell /data/local/tmp/mydaemon -l 127.0.0.1:38460 adb forward tcp:38460 tcp:38460然后电脑端连接frida -H 127.0.0.1:38460 -f com.example.target -l bypass.js --no-pause用adb forward之后App在设备上扫描端口看到的是adb的转发端口而不是Frida直接监听的端口能躲过一部分只扫局域网的检测。但遇到那种遍历/proc/net/tcp找所有LISTEN端口的App这个方案作用有限因为adb转发端口也是设备上真实存在的监听端口。这种场景就得配合脚本过滤/proc/net/tcp的内容了思路和过滤maps完全一致。5.2 frida-gadget合入App如果目标是“从进程层面彻底隐藏”可以考虑把Frida的gadget模式合入APK让App在启动时自己加载一个伪装成普通so的gadget库而不是外部frida-server注入。这样进程里看不到frida-server也不存在27042端口。用objection可以快速打包objection patchapk -s target.apk -n libshell.so这里的-n是指定注入的so库名。打完包后运行时gadget会随App启动我们再用frida通过gadget连接。由于库名换成了libshell.so很多“检测maps里有没有frida”的逻辑直接失效。gadget默认行为是等待外部连接某些版本还会在日志里打印Frida字样并在/data/local/tmp留下配置文件。所以打包后记得检查一下把gadget配置里的interaction改为listen并指定非默认端口同时关闭不必要的日志输出。这一步实际项目中很容易漏。5.3 进阶自己编译修改过的Frida Server如果目标App检测特别狠把所有已知的Frida特征都列了一遍标准版frida-server怎么改都躲不过那就得走编译路线。Frida是开源的可以修改源码后重新编译比如改掉默认端口和agent库文件名。改掉gmain、gdbus、gum-js-loop这些线程名常量。改掉D-Bus的AUTH/REJECTED响应串。甚至可以把agent库名改成随机字符串每次编译都不一样。这条路线的成本是环境搭建和编译时间但效果最强。遇到高强度的商业保护我个人的经验是编译一个定制版Frida作为最终武器日常先用标准版脚本这样能省下大量排坑时间。6. 常见问题与排坑实录写脚本只是开始实际跑起来一定会遇到各种奇怪问题。这里把最常见的几个坑列出来基本覆盖了我这几年的实操经验。6.1 注入了但脚本没生效先检查两件事一是Frida客户端和服务端版本是否一致版本差距过大会出现注入成功但脚本加载失败的情况二是是否用了spawn模式如果App在Application阶段就检测attach模式注入太晚。还有一种情况是你hook的native函数根本没被调用。需要先确认检测到底走的是Java还是native。可以用一个探针脚本先打印一番在ProcessBuilder.start和strstr的onEnter里分别加日志看看哪个先触发再针对性地写过滤逻辑。6.2 过滤maps后App还是闪退很大概率是read层过滤写坏了buffer。我之前踩过这个坑在read的onLeave里把整段buffer改成空字符串结果返回值还是原来的字节数检测方按原始长度拷贝时读到垃圾数据直接崩溃。对策有两个一是优先用strstr/memmem这类搜索函数过滤不动原始数据二是如果非改不可就用等长替换把敏感词逐字节替换成空格而且不要在read函数里删除字节。6.3 为什么过滤了还提示检测到“模拟器/调试器”很多App的检测是全局的它同时检测root、Xposed、Frida、模拟器、usb调试。即使我们完美隐藏了Frida它还会因为TracerPid非零或者存在adb调试而报错。所以排查时要看日志里到底是哪个分支触发千万别只盯着Frida相关字段。另外检测代码往往会跑在子进程里。Frida的hook是基于内存修改的fork()出来的子进程会继承hook所以一般没问题。但如果子进程里执行了execve()替换了整个进程镜像hook就没了。遇到这种就得上“先hookexecve的族函数把检测子进程的启动参数替换掉”的思路或者干脆提前把execve拦下来。6.4 给出一个可复用的组合脚本框架最后给一个组合脚本的核心框架你可以按需裁剪Java.perform(function () { // 1. Java层拦截进程执行 var ProcessBuilder Java.use(java.lang.ProcessBuilder); ProcessBuilder.start.implementation function () { var cmd this.getCommand().toString(); console.log([bypass] cmd cmd); return this.start(); }; // 2. Java层过滤BufferedReader var BufferedReader Java.use(java.io.BufferedReader); var originalReadLine BufferedReader.readLine; BufferedReader.readLine.implementation function () { var line originalReadLine.call(this); if (line line.indexOf(frida) ! -1) { return \n; } return line; }; }); // 3. native层字符串搜索函数过滤 [strstr, memmem, strcasestr].forEach(function (name) { var addr Module.findExportByName(null, name); if (addr) { Interceptor.attach(addr, { onEnter: function (args) { this.needle null; try { this.needle name memmem ? Memory.readCString(args[2]) : Memory.readCString(args[1]); } catch (e) {} }, onLeave: function (retval) { if (this.needle (this.needle.indexOf(frida) ! -1 || this.needle.indexOf(gum-js) ! -1)) { retval.replace(ptr(0)); } } }); } });这套框架不是万能的但对付大多数常规Anti-Frida方案足够了。真遇到漏检就根据日志一块块补先定位检测分支再针对那个分支写特定过滤。我个人在实际项目中最大的体会是别指望一个脚本通吃所有App。每个App的检测策略、触发时机、运行语言都不一样真正高效的工作方式是用spawn模式加日志脚本先摸清对方的检测链路再精准地打补丁。另外做防御的朋友也建议把端口检查、maps扫描、线程名检查、TracerPid检查这些维度全都用上而且不要把检测结果写在日志里——很多绕过方案之所以能成功就是因为检测代码在一次判断后就把结果通过字符串明文暴露出来了。攻防从来都是互相促进的把Anti-Frida摸透反过来就是在帮自己的App做更结实的防护。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑