尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

企业级加固App反调试绕过:Frida实战拆解与对抗思路

企业级加固App反调试绕过:Frida实战拆解与对抗思路 1. 企业加固与反调试的“生态位”1.1 伪爱加密企业是个什么来头“伪爱加密企业”不是哪一家真实公司的名字是我给这次分析对象起的代号。这个标题里最关键的两个词一个是企业级加密一个是 Frida 反调试。简单说就是一个 App 套了类似商业加固方案的壳我在分析它时和它的反调试模块正面硬刚了一整天。表面上看它像极了市面上那种“爱加密企业版”的加固效果但实际实现里杂糅了很多自有逻辑不能完全对号入座所以我干脆叫它“伪爱加密企业”。企业级加固方案到底在解决什么问题往浅了说是防止 App 被二次打包、防止核心代码被 Jadx 直接读出来、防止关键算法被复制到别的项目里。往深了说是让安全分析者每一步都走得很痛苦。加固厂商通常会把 DEX 文件整体加密把核心逻辑下沉到 Native 层把 Java 层抽成空壳再配合大量完整性校验。这一整套组件摆出来之后反调试就是防火墙最外层的那道门。对做移动端安全测试的人而言这道门绕不过去后面的分析根本走不动。反调试不是单个函数而是一套机制它检测调试器是否存在、进程是否被附加、系统里有没有 Frida 痕迹、运行环境是不是模拟器、当前进程的 TracerPid 是否为 0。每一个点单独拿出来都不难绕难的是它们在加固壳里被拆成多个模块互相配合甚至互相守护。我这次在“伪爱加密企业”上感触最深的一点是它把反调试做到了“服务化”。不是启动时查一下就走而是起了一个后台逻辑常驻隔几秒轮询一次。这就意味着你就算在第一秒绕过了后面某个时间点只要漏了一个检测整个进程照样崩给你看。1.2 Frida 为什么被重点针对Frida 是一个动态插桩框架核心分为两部分一部分跑在 PC 端提供命令行工具和 Python/JS 接口另一部分跑在目标设备上的 frida-server负责把运行 JS 脚本的 agent 注入到目标进程里。注入成功后它可以在运行时 hook 目标函数的入口和出口修改参数、篡改返回值甚至直接调用目标进程内部函数。传统调试器要断点、单步、附加进程而 Frida 最大的优势是不需要重启目标进程也不需要依赖 ptrace 做 attach。它这一套注入机制对安全分析来说非常高效但站在加固方的角度看Frida 就是头号威胁。因为几乎所有动态分析都能靠它完成还不容易被常规的 ptrace 反调试拦住。所以“伪爱加密企业”这类加固方案毫不犹豫地把 Frida 检测加进了 native 层。最典型的是扫描/proc/self/maps里有没有 frida-agent 的内存映射检查/proc/self/task/*/comm里有没有 gum-js-loop、gmain 这类线程名连默认监听端口 27042 也要试一遍。更狠一点的会直接尝试和 27042 端口完成一次 D-Bus 协议握手用返回值判断对面是不是 frida-server。我一开始对“伪爱加密企业”使用默认 frida-serverApp 三秒内直接退出。第二次改了服务名App 没有秒退但等我在 Java 层枚举类到一半时进程开始疯狂崩溃。从这些表现能明显感觉到它不止一种检测手段而且不是简单地在启动时检查一次。这种组合拳式的反调试设计正是企业级加固和普通反调试脚本之间最本质的区别。2. 环境准备frida 下载、安装与联调2.1 frida 下载与版本配对先把环境跑通后面才有得聊。很多人第一步就栽在版本不匹配上。PC 端安装很简单pip install frida-tools装完之后敲一下版本号frida --version这里显示的版本是 Frida 核心库的版本frida-tools 只是外围包装。设备端需要单独下载 frida-server下载时务必保证 frida-server 的版本和 PC 端frida --version看到的完全一致。我见过太多次unable to connect to remote frida-server的报错最后排查半天问题就是两端大版本差了一位。frida-server 下载时还必须选对架构现在主流真机基本都是 arm64老设备是 armx86 模拟器要用 x86 或 x86_64。下载完成后把文件推到设备并提权adb push frida-server /data/local/tmp/fs adb shell su chmod 755 /data/local/tmp/fs /data/local/tmp/fs -l 127.0.0.1:12345 注意上面我故意把服务名改成fs端口也换成了 12345。默认的frida-server文件名和默认端口 27042 实在太显眼几乎任何带点智商的反调试模块都会先检查这两个特征。这个习惯在分析企业加固 App 时非常重要。当然只靠改名和换端口肯定骗不过“伪爱加密企业”这种方案但至少能把最基础、最廉价的检测过滤掉。设备端需要 root 权限模拟器也必须选能开启 root 的版本。如果你用的是 Android 10 以上系统/proc/self/mem访问限制会更严格这通常不会影响 frida-server 启动但会影响后面某些 hook 技巧的稳定性。遇到奇怪问题时可以先换个系统版本再试。2.2 跑通第一个 Frida 脚本连上设备之后先验证基础功能。在 PC 端执行frida-ps -U能看到进程列表就说明 frida-server 与 PC 端的连接正常。接着看目标 App 是否在列表里frida-ps -U | grep com.example.demo也可以用 frida-trace 观察目标 App 启动过程中调用了哪些可疑函数frida-trace -U -f com.example.demo -i open -i strstr这条命令的意思是使用 spawn 方式启动 App并跟踪open、strstr两个函数。对企业加固 App 来说跑到这一步大概率就会触发反调试App 刚启动就崩掉。不过没关系这一轮 trace 没被杀掉就算好消息。真正的战斗在后面的反调试定位和欺骗上。如果frida-ps -U都报错优先检查三件事frida-server 是不是以 root 身份运行的、设备上有没有用adb shell ps -A | grep fs确认进程存活、PC 端版本和设备端版本是否匹配。这些是基础中的基础但身边人踩过最多的坑反而就是这几步。3. 反调试手段拆解这次到底踩到哪些雷3.1 Java 层的小把戏先看 Java 层。虽然企业加固 App 的 Java 代码已经被抽空但反调试模块会把一部分检测放在 Java 层作为第一道不痛不痒的防线。最常见的就是Debug.isDebuggerConnected()判断当前是否连接了调试器以及检测android:debuggable标记。Java 层也会检查Build.FINGERPRINT、Build.MODEL字段判断当前设备是不是模拟器如果是就直接拒绝运行。这些检测本身不算复杂但对普通用户来说已经足够正常用户根本不会触发会触发的人大概率是逆向工程师直接拉黑最省事。绕过 Java 层检测的思路很简单用 Frida 在 Java 层直接 hook 这些判断方法把返回值改掉。比如Java.perform(function() { const Debug Java.use(android.os.Debug); Debug.isDebuggerConnected.implementation function() { return false; }; });问题在于“伪爱加密企业”这类方案早就把这些判断下沉了。真正的检测逻辑在 native 层Java 层只是它对外展示的一个幌子。你如果只 hook Java 方法很可能会出现“明明 hook 了却不起作用”的情况因为检测代码根本不走 Java 的Debug类而是自己解析/proc/self/status或者通过 JNI 调回到 native 函数。所以想绕干净必须顺着代码进到 native 层的主战场。3.2 Native 层的重火力企业级加固的重火力都集中在 Native 层。“伪爱加密企业”也一样第一个典型的动作就是抢注 ptrace。ptrace 是 Linux 系统里的调试系统调用而一个进程同时只能被一个跟踪者 attach。反调试模块会在进程启动早期主动调用ptrace(PTRACE_TRACEME, 0, 0, 0)把自己变成“已经被父进程跟踪”的状态。这样后续真正的调试器再用 ptrace attach 时就会因为EPERM失败。相当于先把座位占了别人只能站着。另一种变体是双进程互相 ptrace。主进程和一个守护进程互相跟踪调试器如果只 attach 主进程守护进程很快就能发现主进程的状态异常然后主动杀掉主进程。这套方案比单纯自占 ptrace 难缠多了因为你不能只在一个进程里动手脚得同时照顾两个进程。Native 层另一个普及率极高的检测是读取/proc/self/status里的TracerPid字段。正常情况下它是 0一旦有调试器通过 ptrace 附加成功这个值就会变成调试器的 PID。反调试代码会隔一小段时间打开这个文件看一眼发现TracerPid不是 0立刻自杀或者触发保护逻辑。很多加固方案把这个简单字段玩出了花配合轮询和延迟检测让分析者防不胜防。再就是对 Frida 特征的定向扫描典型的有下面几类检查/data/local/tmp下有没有 frida-server 相关文件扫描/proc/self/maps看进程内存映射里有没有 frida-agent.so、gum-js-loop 等字样遍历/proc/self/task下的线程名查找gum-js-loop、gmain、gdbus主动连接本地 27042 端口发送 D-Bus 协议的 AUTH 消息根据返回判断是否存在 Frida 服务检查特定的 system property、特定进程名、特定 socket 文件。这些检测大多是用 C/C 写的循环逻辑轮询时间不固定延迟随机不会让我们用简单的时间点绕过。我第一次分析时就是低估了这套轮询逻辑以为绕过启动时那一下就行了结果进入主界面后又崩排查半天才发现它每隔一段不确定的时间就要重新查一遍。3.3 隐藏陷阱延迟检测与多进程守护“伪爱加密企业”最让我头疼的还不是上述任何单一手段而是组合拳。它把检测分散在多个时机App 启动后 3 秒查一遍 TracerPid进入主界面后再查一次点击某个核心功能按钮时再对比一次签名或时间。你如果在启动阶段做了一次绕过后面任何一个节点漏了都会前功尽弃。延迟检测更隐蔽。常见实现是记录一个时间戳用当前时间反复对比判断执行路径是不是因为调试单步而被拖慢。如果某段代码执行时间异常长就直接认定有调试器介入。这种手段不查 TracerPid、不查 Frida 痕迹单纯通过时间差做判断。用 Frida hookclock_gettime、gettimeofday时还得小心不能一刀切把所有时间调用都改成同一个值否则业务逻辑自己先崩了。多进程守护是另一个大坑。“伪爱加密企业”里出现过的情况是主进程被 ptrace 保护同时还有一个独立守护进程在循环读/proc/主进程PID/status。只要守护进程发现主进程的 TracerPid 不为 0就直接 kill 掉主进程。这种情况下正确的思路是先注入守护进程把它对主进程的监控逻辑 hook 掉再回头处理主进程自身的检测。如果你不管守护进程只对主进程做绕过它照样能发现异常然后把你辛苦建立的分析环境一波带走。4. 实操用 Frida 一步步拆掉反调试4.1 先侦察再动手绕反调试之前必须先知道目标在查什么。我习惯把反调试代码先当成黑盒用 Frida 把所有可疑的系统调用全部记录下来看看它到底走了哪条路径。一个比较实用的侦察脚本是 hook 文件访问和字符串比较Interceptor.attach(Module.findExportByName(libc.so, strstr), { onEnter(args) { const haystack args[0].readCString(); const needle args[1].readCString(); if (needle haystack (needle.indexOf(TracerPid) ! -1 || needle.indexOf(frida) ! -1 || haystack.indexOf(frida) ! -1)) { console.log([strstr] needle - haystack); } } });跑起来之后观察日志里反复出现的关键字基本就能判断检测模块的偏好。如果日志里反复出现/proc/self/status那它就是走 TracerPid 路线如果出现gum-js-loop、27042这类的字样那它就是走 Frida 特征路线。侦察脚本跑完就定策略不要东一榔头西一棒槌。我一般按优先级排序先解决 ptrace 占用再处理 TracerPid最后清理 Frida 痕迹。这个顺序不能乱因为 TracerPid 检测本身依赖 ptrace 的状态ptrace 没处理干净后面改再多都是白搭。4.2 绕过 ptrace 与 TracerPid 的核心脚本对付抢注 ptrace思路是让 ptrace 调用本身“看起来成功实际什么都不做”。最直接的办法是把 ptrace 的返回值强制改成 0。检测方以为已经成功占位因此不会触发保护逻辑但实际上 ptrace 没有真正占用。const ptrace Module.findExportByName(null, ptrace); if (ptrace) { Interceptor.replace(ptrace, new NativeCallback(function(request, pid, addr, data) { return 0; }, long, [int, int, pointer, pointer])); }这里有个细节ptrace 在 Android 上通常以变参形式暴露直接Interceptor.replace偶尔会翻车。如果发现 App 在替换后莫名崩溃就改成用Interceptor.attach拦截 ptrace 的入口在onLeave里把返回值强改为 0或者在onEnter里直接跳过原调用。具体选哪种试了才知道不同架构、不同厂商加固之间的行为差异挺大。TracerPid 检测的绕过要讲究一些。先搞清楚检测代码是怎么读文件的它可能用open加read也可能用fopen加fgets。最稳的做法是把open和read一起 hook找到被打开的/proc/self/status文件描述符然后在read返回时把缓冲区里的TracerPid: 12345替换成TracerPid: 0。let statusFd -1; Interceptor.attach(Module.findExportByName(libc.so, open), { onEnter(args) { const p args[0].readCString(); if (p p.indexOf(/proc/self/status) ! -1) { this.suspect true; } }, onLeave(retval) { if (this.suspect) { statusFd retval.toInt32(); } } });这段代码只是记录文件描述符真正的替换要在read的onLeave里做把读出来的字符串内容改掉。实际项目中我更喜欢先把检测代码定位到具体函数然后只 patch 那一小段逻辑。全局 hookread会带来很大的性能开销还会干扰业务代码自己的文件读取。用 Frida 做“精准外科手术”永远好过全面覆盖。4.3 处理 Frida 痕迹和线程扫描TracerPid 绕过去之后剩下的大 Boss 是 Frida 特征检测。就算改了 frida-server 名字、换了端口内存映射、线程名、D-Bus 指纹这些痕迹依然会暴露。先说最基础的隐藏措施把 frida-server 改名放到/data/local/tmp/fs用-l 127.0.0.1:12345指定非默认端口有条件的话配合adb reverse tcp:12345 tcp:12345让 frida-server 只监听在 localhost。但在“伪爱加密企业”面前这只能骗过最粗的检查。它可能去遍历/proc/self/maps找里面的frida-agent或者gum-js字样也可能遍历线程名寻找gum-js-loop。对线程名的处理可以通过 hookpthread_create来掩盖但工程量大还要处理不同 Android 版本的差异。更省力的方案是用社区里专门为对抗检测编译过的 Frida 分支版本。这类版本通过重新编译源码把默认的frida-agent字符串、线程名、端口特征全部改掉使用时基本能绕过普通字符串扫描。不过要记得这类方案只是把特征改成另一种样子并不能做到真正无痕。遇到针对性更强的检测还是得靠自己分析。对于端口扫描可以在启动 frida-server 时使用-l 127.0.0.1:0让它随机监听一个端口。这样默认端口 27042 不再可扫但连接时需要通过 adb reverse 或者特殊方式获取实际端口操作上会麻烦一些。我在实际对抗中会优先保证稳定性毕竟频繁往返于命令行和终端之间很容易把思路打断。4.4 对抗多进程守护处理多进程守护的最终思路是先把守护进程的监控能力废掉再对主进程做常规绕过。在 Frida 里可以对两个进程分别注入脚本。先 attach 守护进程找到它用来检测主进程状态的函数比如读取/proc/主进程PID/status的函数或者用它杀掉主进程的kill调用然后把致命路径直接 hook 掉。Interceptor.attach(Module.findExportByName(libc.so, kill), { onEnter(args) { const target args[0].toInt32(); const sig args[1].toInt32(); const self Process.id; if (target self) { console.log([*] block kill self sig); args[0] ptr(0); args[1] ptr(0); } } });这只是其中一种思路把kill的信号值改成 0这样调用仍然返回成功但不会真正发送信号。更优雅的方式是找到守护进程内部判断“主进程是否被调试”的布尔逻辑直接把判断结果改成“未被调试”。前提是你能把守护进程分析清楚。很多守护进程本身也做了加固和反调试需要不少时间去处理。实际项目里多进程守护往往和签名校验联动。即使反调试绕过了后续还会遇到加固壳对 dex 完整性、so 完整性、包名的校验。所以跑完整流程时耐心和日志管理特别重要。我习惯每完成一个阶段的绕过就保存一次 Frida 脚本并记录当时的日志变化方便随时回到上一个正常状态。5. 常见问题与排查技巧实录5.1 frida-server 连不上、版本不匹配遇到unable to connect to remote frida-server先别急着怀疑设备按顺序查frida-server 进程是否存活adb shell ps -A | grep fs是否用 root 身份启动adb shell su -c /data/local/tmp/fsPC 端 frida 版本与设备端 frida-server 版本是否一致adb devices是否正常识别设备。版本不匹配最隐蔽Frida 15 的 PC 端客户端连 frida-server 14经常会握手失败或者连接成功后很快就断开。把两端版本对齐到完全一致问题基本消失。不要用“差不多”的心态去试很多玄学问题最后都能在版本号上找到答案。5.2 一 attach 就被杀确认 frida-server 正常运行但目标 App 一 attach 就退出大概率是反调试模块发现了 Frida 特征。盲目换隐藏版 Frida 之前先判断它到底扫到了什么。可以用一个最小脚本跑起来观察日志里的可疑字符串frida-trace -U -f com.example.demo如果 frida-trace 能正常执行说明反调试还没有对 Frida 做“宁可错杀”式的粗暴检测。如果连 frida-trace 都起不来就要先把端口默认特征、线程名、maps 映射全部处理一遍。对付这种场景我建议先把隐藏措施全做完再回来确认是哪个检测点没处理到位。一次只改一个变量保持其他条件不变能帮你快速定位问题。5.3 hook 到 Java 方法却毫无反应企业加固 App 的类和方法是动态解密出来的普通方式的Java.perform如果执行过早就找不到目标类。解决办法是 hookClassLoader.loadClass或者等 App 跑几秒再 attach让类先完成解密加载。如果方法名在任何枚举列表里都找不到说明这个方法已经被抽到了 native 层Java 层只剩一个空壳。这种情况只能去 native 层分析或者先脱壳修复 dex 再看。反调试绕得再好如果连目标方法都没法定位后面的业务逻辑分析依然是空谈。做一张常见问题速查表方便大家对照排查现象可能原因处理建议frida-ps -U 连不上frida-server 没起来或版本不一致确认版本、root、进程存活attach 后秒退反调试发现 Frida 特征改名、换端口、隐藏线程名Java hook 无效果加固动态解密类时机不对适当延迟再 attach或 hook 类加载TracerPid 一直非0ptrace 占用未解决替换 ptrace 返回值为0日志中出现“kill self”多进程守护在动作先注入守护进程block kill6. 最后说点踩坑心得我在这类企业级加固 App 上摸索了很长时间最大的体会是绕反调试的每一步都离不开日志。刚开始我没太在意凭感觉 hook 了一堆函数结果 App 疯狂崩溃根本不知道是哪个 hook 闯的祸。后来学乖了每一轮 Frida 脚本都会输出函数名、参数变化和返回值排查效率直接翻倍。另一个经验是保留“侦察版”和“绕过版”两套脚本。侦察版只负责记录字符串、文件路径、递归调用不做任何替换绕过版才真正修改逻辑。这样能快速对比哪一轮修改生效了哪一轮修改把 App 搞崩了。别嫌麻烦在企业级加固面前这两套脚本能帮你节省大量时间。还有一个容易被忽略的小技巧当一段逻辑怎么都绕不过去时可以试着换一个 attach 的时机。有时候用 spawn 模式启动太早加固壳还没加载完反调试模块已经抢先运行有时候用 attach 模式又太晚壳早就把检测结果缓存好了。我经常组合使用-f和--no-pause或者配合 sleep 几秒再加载脚本很多时候问题就这么解决了。最后想强调一点Frida 反调试对抗是一个普遍的安全测试技能但一定要在授权范围内使用。拿自己的 App、在本地测试环境搭建样本练手都没有问题未经授权去逆向别人的商业产品不仅违约还容易惹上不必要的麻烦。技术本身是中立的怎么用完全取决于使用者自己。
返回列表