多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Frida反调试绕过实战:商业加固检测组合拳的破解思路

Frida反调试绕过实战:商业加固检测组合拳的破解思路 最近接了一款App的安全测试刚把frida-server拉起来gadget一attach进程就直接被干掉了日志窗口连错误信息都没来得及吐只有一行冷冰冰的“Process terminated”。我翻了下加固信息对方上的是某商业加固方案的企业版这套东西的检测模块写得特别激进为了不点名我在这篇文章里统一叫它“伪爱加密企业”。如果你也是个经常跟Android逆向、应用安全测试打交道的人大概率遇到过同款场景不管是hook Java层还是Native层只要Frida一出现App要么秒退要么声东击西让你完全操作不下去。这篇文章我想把和“伪爱加密企业”这套反调试较量的完整过程写下来从原理、现象、定位到绕过再到绕过之后藏着的那些坑尽量都讲清楚。内容只面向合法的授权安全测试和学习研究别拿去搞不该搞的东西。1. 为什么要先搞懂“伪爱加密企业”里的反调试逻辑商业加固的初衷是保护APP的代码资源防Dex篡改、防二次打包、防内存dump这些大家都熟。但最近几年企业版加固里越来越爱塞“反调试”模块尤其是专门针对Frida的。原因不复杂Frida这套动态插桩工具太强了它可以非常方便地在App运行期修改Java层逻辑、hook Native层函数、dump解密后的内存、甚至直接修改函数返回值。如果你的App里有敏感算法、登录协议或者支付逻辑一旦别人用Frida挂上去加固壳再硬也等于没穿衣服。“伪爱加密企业”这类方案的反调试本质上不是单一检测而是“多层检测组合拳”。我把它拆成几层第一层静态特征扫描查找进程里有没有加载frida-agent.so、gadget库以及frida-server的进程名和端口。第二层动态行为检测比如通过ptrace附加自己、监控/proc/self/status里的TracerPid看有没有调试器在跟踪。第三层协议特征识别Frida默认的D-Bus协议在/data/local/tmp端口上有固定握手包检测到就直接自杀。第四层完整性校验启动后定时比对关键函数指针、扫描内存里有没有可疑的注入线程。这四层往往不是串行跑一次的而是分散在多个线程里并行跑、循环跑。所以很多人在实战中会感觉到“明明绕过了一个检测过几秒又崩了”就是因为它的检测是间隔性的你只处理了单个点没有处理调度机制。提示面对企业级反调试第一件事不是急着写Frida脚本而是先搞清楚这套方案到底有几层检测、每层在什么时机跑。否则就会陷入“按下葫芦浮起瓢”的死循环。2. 被反调试粘住时我见到的几种典型“现场”碰到“伪爱加密企业”之后我前前后后测试了几台不同Android版本的设备总结下来反调试发作的方式有下面几类。你自己碰到时可以对照一下能少走很多弯路。2.1 现象一frida-server一跑整个App直接闪退这种最明显。你先启动frida-server然后正常打开App还没等你来得及attachApp就在启动阶段崩了。背后原因是启动期内嵌在壳里的检测线程已经扫描到了frida-server这个进程或者扫描到了27042端口的监听状态。我一开始用的就是Frida默认配置frida-server跑在27042端口进程名也暴露在进程列表里。对“伪爱”这种商业方案来说这种特征简直是明牌。它只需要遍历一下/proc下的进程名再尝试connect一下27042就能轻松发现。遇到这种情况你需要先放弃默认启动方式。第一方案是改成非标准端口第二方案是直接用frida-gadget注入到App内部。2.2 现象二附加成功了但一执行任何hook定点秒崩这种最让人头疼因为你能附加进去说明端口和进程扫描没拦住你。但只要你一Java.perform或者一挂Native hookApp立刻退出。这通常不是被“检测线程”发现而是Frida注入的agent在attach时的行为触发了“伪爱加固”的异常监控。它的实现逻辑大概是壳在Native层维护了一套关键函数的指针表比如art::Thread、art::JNI、linker相关函数一旦发现这些函数头几条指令被修改也就是被内联hook了就调用pthread_kill或者kill(getpid(), SIGKILL)自杀。这就是为什么你明明只是hook了一个普通函数也会导致整个进程被杀。2.3 现象三一切看起来正常但Hook就是没有生效这种更隐蔽——App不崩、不闪退Frida也正常连接但你的ret_val.replace(0x...)就是不起作用。仔细排查才发现App的逻辑根本不在你hook的函数里执行它已经走了另一条分支。原因通常是反调试模块检测到了调试状态后通过setjmp/longjmp跳到了一个“安全模式”的逻辑分支所有敏感操作全部降级或者走了混淆路径让你以为没Hook上。2.4 定位检测点用Frida自己当探针这种时候我会先写一个“纯观察型”的Frida脚本不hook任何业务逻辑只hook底层文件访问和系统调用去反向定位它到底读了哪些文件、调用了哪些函数。比如hook掉open、read、dlsym、pthread_create把调用栈打出来。这能帮你把所有检测线程的入口函数和文件路径抓出来。实测中最有价值的路径是/proc/self/status的读取记录。只要检测TracerPid就一定会有App打开这个文件的记录。你用hook把open该文件的堆栈打出来就能直接看到是哪一个so库的哪个函数在干这个事。然后用IDA或者Ghidra打开对应so顺着交叉引用找基本能把整个检测逻辑扒个七七八八。3. 磕掉硬骨头绕过“伪爱”Frida反调试的完整链路当你从“症状”反推出了“检测点”之后就可以动手做“绕过”了。我这里说的绕过是在合法授权的前提下为了完成App安全测试而做的技术对抗。下面按我实际操作时的顺序来说。3.1 第一阶段先让Frida进得去要绕反调试第一步往往不是“绕”而是“藏”。先让Frida不被发现地进到进程里后面才有得玩。比较实用的方案是Frida的gadget模式。把libgadget.so塞进App的lib目录然后在libgadget.config.so里配置监听端口和交互方式。配置文件大概长这样{ interaction: { type: listen, address: 127.0.0.1, port: 8099, on_load: resume } }注意端口改成8099别再用27042。on_load设为resume等Frida agent注入后再恢复主线程。这样既不启动frida-server也不暴露默认进程名从静态特征上先躲过第一波扫描。第二步是重打包App并重新签名。这会涉及绕过签名校验但“伪爱”方案的签名校验其实不怎么强因为它是企业版加固很多功能跑在反射和动态加载上签名校验比较粗糙。如果签名校验过不去你需要再走hookPackageManager的路线但那属于另一个话题了。如果App本身加固壳就已经校验包完整性用gadget模式就比较麻烦。这时候可以改用frida-server的非标准端口启动比如./frida-server -l 127.0.0.1:8899同时把server二进制文件重命名成不显眼的名字。这种方法入口快但遇到进程内存扫描还是会暴露所以只适合前期探路。3.2 第二阶段绕过线程与ptrace检测把Frida成功带进去之后紧接着要面对的是ptrace自附加检测。常见检测逻辑是启动一个子进程ptrace(PTRACE_TRACEME)附加到自己然后父进程等待如果此时Frida也尝试用ptrace去附加就会因为“多调试器互斥”而失败。基于这个特性很多加固方案会周期性检查当前进程能不能被ptrace如果能就说明没有调试器如果不能就说明已经被Frida附加过了。绕过思路有两个方向方向一让壳自己的ptrace失败而不是让Frida的ptrace失败。方向二伪造/proc/self/status里的TracerPid为0。我比较推荐方向一因为它不会破坏Frida的调试通道。在Frida脚本里hookptrace函数当判断到调用方来自某个被加固的so库时直接返回0Interceptor.attach(Module.findExportByName(null, ptrace), { onEnter: function(args) { this.caller args[0].toInt32(); if (Process.getModuleByAddress(this.returnAddress)?.name ! undefined) { this.shouldSkip true; } }, onLeave: function(retval) { if (this.shouldSkip) { retval.replace(0); } } });但这个hook是有风险的因为有些系统的libc是经由syscall调用ptrace只hook导出函数不一定能拦到。更底层的做法是hooksyscall函数根据__NR_ptrace的值进行处理。不过那样性能影响比较大建议只对检测线程所在的so做范围限定。同时还要hookgetppid因为有些检测会对比父进程是不是init如果不是就可能被判定为frida-server的父进程链。3.3 第三阶段干掉D-Bus和端口特征Frida的agent在连接时走的是D-Bus协议端口和握手请求都有固定token。如果“伪爱”方案做到了协议识别层就算你换端口它也能通过扫描本地socket主动connect然后发一段D-Bus认证数据如果对方给的是Frida的回复特征就直接判定“已被Frida注入”。对付这一层最干净的办法是修改Frida的默认握手特征。但这需要自己重新编译frida-server或者frida-gadget源码不是改个配置那么简单。如果不想编译还有个歪招在App进程里hook所有socket和bind相关函数把检测线程尝试连接的端口请求全部拦截下来。不过说实话对于“伪爱”这种企业方案它很少用主动连接检测更多是被动检查“有没有非App创建的监听端口”。这个更好绕你只要确保Frida的agent是注入到进程内的gadget模式它就不监听端口而是通过和主进程共享内存通信这样D-Bus端口特征自然就没了。3.4 第四阶段处理detect循环与自我恢复机制绕过几个点之后你会发现App还是会隔一段时间崩溃一次。这是它在反调试里加了“看门狗”机制检测不是一次性的而是一个独立线程每几百毫秒循环检测。你用Frida钩住检测线程创建函数pthread_create然后看新线程的入口函数是不是之前定位到的检测入口。如果是就把这个线程的入口函数体直接改掉让它什么都不干。更粗暴一点可以定期调用Thread.sleep不要打掉检测线程的调度。比较直接的方式是枚举所有线程找到名称里带guard、checker、anti、debug的线程然后把它挂起Process.enumerateThreads().forEach(function(t) { if (t.name.toLowerCase().indexOf(guard) ! -1 || t.name.toLowerCase().indexOf(anti) ! -1) { Process.findThreadById(t.id).suspend(); console.log(suspended: t.name); } });但线程名经常被混淆更可靠的是前面用探针定位到检测线程的入口地址然后在pthread_create里做过滤如果入口函数地址落在某个so的检测函数区域就把传入的attr改成detached同时把入口函数替换成一个空函数指针。这里我想特别说一句不要一上来就疯狂挂线程那样很容易把业务线程也误伤直接导致App卡死。4. 绕过去之后藏在底下的“二段击”才最磨人很多人以为绕过反调试就万事大吉但“伪爱加密企业”这套方案的恶心之处在于反调试只是它最外层的东西。你躲过第一层检测之后下面还有几个坑。4.1 检测点轮询与自我恢复刚才说了它有一个看门狗线程会定期重新拉起被挂起的检测线程。你以为自己已经suspend了检测线程过一会儿它可能通过一个父线程重新创建了一个新线程检测逻辑又恢复了。这是我实测遇到最多的“死灰复燃”情况。所以处理看门狗线程时不能只挂线程还要找到创建这个线程的“源头”把父线程一起处理掉。这个源头通常是在JNI_OnLoad里初始化的你可以hookdlopen和dlsym看看是哪个模块调用了pthread_create顺着它的调用链往上摸。理论上只要守住最上层调度者下面再多的检测线程都是无源之水。4.2 基于系统调用层面的反Hook绕过到第三层之后加固方案通常会启用“syscall直连”。也就是检测逻辑不使用libc导出函数而是通过内联汇编直接执行svc指令发起系统调用。比如检测ptrace、读取/proc/self/status时它不走openread而是直接用原始syscall。这样你hookopen、read、ptrace这些导出函数就全部失效了。对这种情况只有两条路路一把trace级别提升到syscall层用Frida的Process.getSyscallApi或者inline hook来做拦截但这非常容易把系统搞崩。路二不去管它怎么读取而是直接改它最终拿到的结果。如果它把结果mmap到内存里再做判断你可以扫描它的判断分支修改判断逻辑。实测中我更常用路二也就是在“判断”上下手而不是在“读取”上下手。用Ghidra打开对应so的检测函数找到条件跳转指令在Frida中对这个地址做Interceptor.replace直接强制跳转。这样无论它怎么读取系统信息最终都不会走“检测到调试器就自杀”的分支。4.3 内存完整性与环境指纹最后一个坑是环境指纹检测。App会检查运行环境的包管理器里有没有frida相关的包名、有没有USB调试设备列表异常甚至检查一些常用的Xposed模块包名。同时还会计算自身so文件的hash如果发现被篡改就退出。对于这种指纹检测我的做法是在Frida里hookPackageManager.getInstalledPackages和getInstalledApplications过滤掉包含frida、xposed等包名的返回值。同时hookSystem.loadLibrary等so加载完成后用Frida的内存API去重新计算关键区段hash确保与原始so一致——这在动态分析场景下是个比较重的做法一般建议只在必要的时候开。注意不要在你的Hook脚本里引入体积太大的工具代码。Performing full-memory scanning can easily trigger behavior-based detection, since the embedded agent modifies memory layout. Keep your script small and focused. (中文不要为了检查完整性而在运行时对全进程内存做大范围扫描那本身就会暴露Frida agent的痕迹。)5. 一份可以直接改用的Frida反反调试脚手架说了这么多下面我给出一套我在“伪爱加密企业”测试中用下来还比较稳的脚本结构。它不算完美但可以作为你的起点。使用时一定记得根据你定位到的具体检测点做裁剪。5.1 注入前置gadget配置假设你决定用libgadget.so注入配置文件libgadget.config.so放在同名路径下{ interaction: { type: listen, address: 127.0.0.1, port: 8899, on_load: resume } }用frida -H 127.0.0.1:8899 -n com.example.target连接即可。如果App有反Frida字符串扫描你可以把libgadget.so重新改名为libnative-lib.so只要so头里的soname不对即可因为Android加载so时优先使用原始路径名不强制要求内部soname一致。5.2 核心hook脚本function hookNative() { // 1. 绕过ptrace检测 [ptrace, ptrace64].forEach(function(sym) { let mod Process.findModuleByName(libc.so); if (!mod) return; let addr mod.findExportByName(sym); if (addr) { Interceptor.attach(addr, { onEnter: function(args) { // 只拦截非frida发起的调用 }, onLeave: function(retval) { // 模拟成功 retval.replace(0); } }); } }); // 2. 伪造TracerPid let openPtr Module.findExportByName(null, open); Interceptor.attach(openPtr, { onEnter: function(args) { this.path args[0].readCString(); this.fd -1; if (this.path this.path.includes(/proc/self/status)) { this.fd this.fd; // 标记 this.shouldRedirect true; } }, onLeave: function(retval) { if (this.shouldRedirect) { // 可以在解读出字符串时替换内容更稳妥的做法是hook read/readlink } } }); // 3. 干掉检测线程的pthread_create let pthread_create Module.findExportByName(null, pthread_create); Interceptor.attach(pthread_create, { onEnter: function(args) { // args[0]是线程id指针, args[2]是入口函数 let entry args[2]; let module Process.getModuleByAddress(entry); if (module module.name.includes(libsecneo.so)) { // 举例加固so名 args[2] Module.findExportByName(null, sleep); // 换成无害入口 } } }); } function main() { if (Java.available) { Java.perform(function() { // hook PackageManager过滤frida包名 let PM Java.use(android.app.ApplicationPackageManager); // ... 按需补充 }); } hookNative(); } setTimeout(main, 0);这里只是骨架关键是教你思路凡是检测行为相关的调用链都要找到入口和调度者然后从源头化解。只处理下游的API一定会遇到“死灰复燃”。5.3 避开的误伤Frida脚本写得太猛会把自己也绕进去。我有几个实际踩过的坑不要全局hookread、open这些常用函数只hook特定路径。不然App性能断崖式下降直接卡到触发看门狗。不要把所有线程都suspend。App的业务线程很多挂错了直接死锁。不要重打包后用“debug签名”很多加固方案会检测签名中的debug标志。不要忘了绕过反反调试之后还要恢复Hook目标。你可以先跑通整个Hook再决定要不要对某个检测点做“永久”修改。最后的一点心得体会跟“伪爱加密企业”这种商业级反调试打交道技术上其实没有太多“一招毙命”的秘籍关键还是耐心拆层。我个人的体会是真正让你事半功倍的不是你手上有多少现成脚本而是你肯不肯花时间先把它的检测链完全摸清。每次检测崩溃都把调用栈拉出来把so拖进IDA把函数交叉引用捋一遍你会发现它的套路其实就那么几板斧只是用多个线程反复执行而已。分享一个我自己一直在用的小技巧在定位阶段我会把Frida的日志输出重定向到本地文件同时开着Android的logcat二者对照着看。很多时候App自杀前会在logcat里留下一条“警告级别”的日志虽然普通用户看不到但对安全测试来说就是一条价值极高的线索能省下大半天的逆向时间。如果你也在测类似的反调试壳希望这篇文章能给你一个比较清晰的作战地图。还是那句话务必在合法授权范围内做这些事工具没有善恶用的人才需要守住边界。
返回列表