ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

360企业加固APK逆向:脱壳、Native还原与生命周期重建实战

360企业加固APK逆向:脱壳、Native还原与生命周期重建实战 拿到一个 360 企业加固 APK 的第一感觉通常是两个字干净。jadx 打开后只剩几个 stub 类assets 里躺着加密数据lib 目录下摆着 libjiagu.so真实的业务 dex 和 so 都藏得严严实实。但只要你真的 trace 一遍启动流程就会发现所有业务代码其实都活着只是被壳藏进了运行时才出现的内存里。这篇文章把我的完整处理思路写下来从识别加固特征、脱壳到 Native 抽取还原再到 Activity 生命周期重建外加一路上踩过的坑。如果你是已经能熟练跑 frida 基础脚本、想再往深处走一步的 Android 逆向爱好者这篇应该能帮你把“查壳到还原”这条线彻底打通。我的基本主张是对抗企业加固不能只把它当成“脱壳”这一个动作而是一个“找回执行现场”的过程。壳替你把代码藏起来你就把壳的入口翻个底朝天壳把函数抽走你就等着它运行时写回再趁热打铁壳把组件调度打乱你就手动把生命周期重新接上。这三步是环环相扣的少一步都会在后续分析中卡壳。1. 360 企业加固到底改了什么1.1 加固特征怎么一眼认出来要对抗一个东西先得知道敌人长什么样。360 企业加固的 APK 解包后有几个非常明显的特征基本可以当指纹用lib 目录下存在 libjiagu.so、libjiagu_a64.so、libjiagu_art.so、libjiagu_x86.so 之类以 jiagu 命名的动态库。注意不同版本、不同 ABI 下的命名可能略有差异比如有些版本还会有 libjiagu_mfb.so。AndroidManifest.xml 中 application 节点指定的类名是 com.stub.StubApp或者 com.stub.StubApplication 这种带有 stub 字样的类。assets 目录存在加密产物常见名类似 assets/jiagu/ 开头的目录或者一些没有扩展名、直接是一坨随机字节的加密文件。classes.dex 往往体积异常小或者内容里面找不见任何业务包名反编译后全是壳入口相关代码。我一般在确认目标时不会只看一个特征。比如有些免费版的加固libjiagu.so 会存在但是包体积可能比较小企业版经常在 assets 里放大量加密资源还会把 dex 拆成多份。判断清楚版本和特征后会直接影响后面 dump 的时机选择——这个后面细说。1.2 加固后的启动链路和普通 App 有什么不同正常情况下APK 安装后系统解析 AndroidManifest.xml创建 Application 对象然后按生命周期创建 Activity。加了加固壳后这个过程被硬生生插进去了一层代理。系统启动时看到的 Application 是 StubApp而这个 StubApp 的 attachBaseContext 方法会完成大量脏活累活。整体流程大致是系统创建 StubApp 对象调用它的 attachBaseContext。StubApp 内置的 native 逻辑也就是 libjiagu 系列 so开始跑解密 assets 里的业务 dex用自定义的 ClassLoader 把它加载进来。壳会在合适时机把真实的 Application 类名取出来用反射或者内部调用把它实例化替掉“代理应用”的身份。随后系统再发起 Activity 创建时会走壳的代理逻辑由壳去加载真正业务 dex 里的 Activity 类。从这里能看出一个关键问题壳类的执行优先级高于任何业务代码。你静态打开 classes.dex 看到的是壳业务代码只在运行时存在于内存里。这也是为什么后面所有还原工作的起点都在“等它把代码放出来”。1.3 为什么说“对抗”不只是在脱壳早期网上能找到的很多教程就是一句“ dump 内存里的 dex 就完事了”。但到了 360 企业加固这个级别单纯 dump dex 往往是不够的。第一业务 dex 里可能还叠加了抽取加固。所谓抽取就是 dex 中某些方法的指令被“挖空”了真正的字节码在运行时才由 native 层解密并回填到内存中。你 dump 出来的 dex 即便能打开也会发现部分方法体内只有空壳或占位指令。第二业务 so 也被动了手脚。Native 层的关键函数可能被整体抽取静态分析翻开 so 文件时根本看不到真正的指令。第三Activity 生命周期被壳拦截后就算你成功脱壳并重新打包直接替换回原始 Application 也未必能正常跑起来。因为系统侧的组件调度还停留在壳代理那一套上下文里。所以我说这是一套组合拳脱壳解决“代码在哪”的问题Native 还原解决“函数内容是什么”的问题生命周期重建解决“脱壳后能不能跑起来、能不能调试”的问题。三步缺一步分析过程都会特别别扭。2. 动手前的准备环境、工具与特征确认2.1 环境准备真机优先别指望模拟器一条龙做加固对抗这类工作我强烈建议优先准备一台能 root 的真机。原因很简单一些加固在对模拟器、frida 特征、xposed 框架做检测时非常敏感模拟器环境很容易全程碰壁。我在硬往上顶的过程中踩过太多模拟器启动即崩的坑后来干脆固定一台 Pixel 作为调试机整个分析体验立刻稳定下来。系统版本不用追新Android 9 到 Android 11 这个区间比较舒服尤其是配合 frida 动态 hook 时。太老的系统反而容易被壳用旧版本特征识别出来太新的系统又可能遇到 frida 版本兼容问题。环境清单大致如下一台已 root 的 Pixel 或其他 AOSP 系设备系统版本 Android 9-11。adb 环境建议把 adb 和 fastboot 都加入系统 PATH。frida 客户端 服务端版本务必匹配。检查版本用frida --version和adb shell /data/local/tmp/frida-server -version。jadx-gui 用于反编译 Java 层查看壳逻辑。GDA 或 Android Studio 作为备选GDA 对混淆和加固后的 dex 打开成功率不错。IDA Pro 或 Ghidra 用于 so 层分析Ghidra 免费且还原能力已经够用。frida-dexdump 用于快速内存 dump dex。unidbg 作为可选工具在 so 层算法逆向时能省不少事。另外不要忘记把本机常用工具的检查项放在一起比如关掉系统自动更新避免调试到一半设备自己重启备份 frida-server 到多个位置防止被目标 app 反调试机制干掉后还要重新推。2.2 判断目标是不是 360 企业加固以及大概版本一个比较稳妥的流程是先解包后静态探查。用 apktool 或者直接 zip 解包都行重点看三个地方lib 目录下 so 是否存在 jiagu 系列以及包含哪些 ABI 版本。AndroidManifest.xml 里 application 的 name。assets 目录下是否存在加密文件以及它们的命名规律。如果看到的是 libjiagu_a64.so 加 com.stub.StubApp基本可以断定是 360 企业加固的大方向。但它具体是企业版还是免费版有时要运行起来才知道。企业版往往隐藏了更多 java 层调用表现在静态层面就是 StubApp 里可读逻辑很少native 层有完整的初始化链。也有少数样本会做“重打包”或二次加工把 jiagu 相关文件名改掉、把 StubApp 类名混淆掉但壳特征还是会在运行时加载的 so 列表里出现。所以如果静态看不出明显特征我会提前用 frida 在进程起来后打印 loaded modules过滤关键词来确认。2.3 给新手的几个必要提醒在开始之前把合规问题说清楚你处理的样本应该来源于授权测试、CTF 题目、漏洞分析任务或自己开发的应用。用这个技术去对没有授权的线上商业应用“练手”边界和版权问题自己心里要有数。安全研究的关键是理解通用原理而不是把所有目标都当成练习场。另外别在刚开始就想着写全自动脱壳机。手动流程走通一遍后你会更清楚哪些环节可以用脚本固化哪些环节必须人工判断。这篇走的是手动半自动结合的路子。3. 入口分析与脱壳实操3.1 从 AndroidManifest 顺藤摸瓜到 StubApp静态分析的第一步用 apktool 解包后打开 AndroidManifest.xml 看 application 节点。如果是加固状态会看到类似application android:namecom.stub.StubApp ... 注意这里可能有多个 activity但它们的名字通常也不是真实业务类而是壳的代理类。接下来用 jadx 打开 classes.dex定位 StubApp 的 attachBaseContext / onCreate一般能看到它调用了 native 方法比如nativeInit之类的。这一步的核心不是读懂每一行代码而是要找到壳初始化发生在哪个生命周期节点、使用的是什么 native 函数。常见套路是 StubApp 在 attachBaseContext 里调用System.loadLibrary加载 libjiagu然后调用某个 native 方法来启动解密流程。找到这个入口后后面用 frida 跟踪内存解密就有方向了。3.2 用 frida-dexdump 把业务 dex 从内存里抠出来脱壳的常用方案是等业务 dex 被解密并加载进内存后把内存中的 dex 文件 dump 出来。frida-dexdump 是我的首选它基于 frida 在进程内存中扫描 dex 魔数找到后直接把 dex 读出来保存。实操命令很直接frida-dexdump -U -f com.example.target -o ./dump这里-U表示连接 USB 设备-f表示以 spawn 方式启动目标进程。用 spawn 模式的好处是能在 app 早期逻辑执行时就开始注入不容易漏掉 dex 解密过程。dump 完成后重点检查两个指标目录下生成的 dex 文件数量和每个 dex 是否能被 jadx 正常解析。如果能正常打开并看到业务类说明脱壳成功如果打开后只有壳类说明 dump 的时机早了或者业务 dex 还没被完整解密。此时换成先启动 app、再 attach、再 dump 的方式frida-dexdump -U -n com.example.target -o ./dump如果这个操作在某个样本上仍然 dump 不到业务 dex我会手写一个扫描脚本用Memory.scan搜索 dex 魔数64 65 78 0a 03 35对应dex\n035再把匹配到的区域按 dex header 的 file_size 字段读出来。这种方法能覆盖大部分 frida-dexdump 失效的情况。3.3 脱壳成功不代表能跑类加载与入口被劫持的问题一个很常见的误区是dumped 出来 dex 了就很高兴结果把 dex 塞回 APK、重新打包后一运行就崩。原因不是“脱壳失败了”而是壳的代理入口没处理。原来的 classes.dex 里 Application 还是 com.stub.StubApp业务 dex 虽然被 dump 出来但 StubApp 少了 native 解密能力后根本没法把业务类加载起来。系统试图创建 Application 时走的路径还是壳的路径壳又崩了自然整个 app 就起不来。所以如果你想重新打包运行就必须在脱壳之后进一步“修复组件入口”这就和后面要讲的 Activity 生命周期重建直接相关。就算你只是为了静态看代码不追求能重新安装运行也建议了解这一层因为在动态调试场景下壳的入口劫持会直接影响你 hook 的时机和位置。4. Native 抽取还原找回被抽走的真实函数4.1 静态分析时看到的是壳 so 还是业务 so怎么区分360 企业加固的 Native 抽取还原第一步是分清“壳 so”和“业务 so”。壳 so 就是 libjiagu 系列它们的任务是解密、反调试、动态恢复业务 so 才是目标应用自己的 native 逻辑。如果目标 app 引用了某个第三方加密库或自研 so业务 so 可能被压缩、加密存储也可能被抽取函数段。用 IDA 或 Ghidra 打开业务 so 时比较典型的抽取迹象是某个关键导出函数或者 JNI_OnLoad 里只有极短的跳板指令比如mov w0, wzr; ret而真实功能却依然正常。这说明真实指令没有放在文件里而是在运行时由壳写回内存后再执行。静态分析时我会重点看三处.init_array 里的构造函数列表壳往往会在这里挂解密逻辑。导出函数表确认 JNI 函数或自研导出函数的入口地址。字符串交叉引用如果能找到解密相关字符串顺藤摸瓜能定位抽取逻辑。但这些静态线索通常只能确定“它被抽了”真正要看懂真实指令还是得动态下手。4.2 动态跟进等待壳把代码“写回去”壳在运行时恢复 native 代码一般会经历一个“先解密、再改内存页属性、再写回”的过程。这一过程有两个天然的观察点第一JNI_OnLoad 是很多加固一定会执行的地方。它本身可能也是被抽取的目标。可以在业务 so 加载后、JNI_OnLoad 执行前后用 frida hook 起来然后读取该 so 的 .text 段内容对比 hook 前后是否有变化。第二很多抽取逻辑会调用 mprotect 把代码段从只读改成可读可写写完后再改回去。用 frida hook mprotect 就能把壳“什么时候、在哪一段地址上、做了什么”看得明明白白。var mprotect Module.getExportByName(null, mprotect); Interceptor.attach(mprotect, { onEnter: function (args) { var addr args[0]; var len args[1].toInt32(); var prot args[2].toInt32(); console.log(mprotect addr addr len len prot prot); } });记下地址后读内存、dump、对比静态 so差异部分往往就是被抽取并写回的代码。如果目标函数非常明确也可以直接在函数地址上做 Interceptorvar targetAddr Module.getExportByName(libtarget.so, Java_com_example_nativeCheck); Interceptor.attach(targetAddr, { onEnter: function (args) { this.addr targetAddr; console.log(enter nativeCheck, sleeping to let hooks settle); }, onLeave: function (retval) { var base Module.getBaseAddress(libtarget.so); var off this.addr.sub(base); console.log(target offset: off); var bytes Memory.readByteArray(this.addr, 0x200); var file new File(/data/local/tmp/dump_native.bin, wb); try { file.write(bytes); } finally { file.close(); } } });这样在目标函数执行完的瞬间把该函数起始地址的内存读出来得到的就是还原后的真实指令。注意抽取恢复的粒度不一定是整个函数也可能是某个基本块dump 长度要根据实际情况调整太短可能漏代码太长则引入无关数据。4.3 从内存地址换算回文件偏移并 patch so内存中 dump 出来的代码最终要落到 so 文件的正确位置才能用 IDA/Ghidra 静态分析。这里的关键是 ELF 虚拟地址vaddr和文件偏移file offset的换算。ELF 加载时默认按页对齐。对一个 segmentp_vaddr % 页大小和p_offset % 页大小是相等的。所以如果你 dump 的内存地址对应到 base vaddr可以先算出 vaddr再根据 ELF program headers 定位它属于哪个 segment然后用offset vaddr - p_vaddr p_offset换算到文件偏移。给一个简单的 Python 思路from elftools.elf.elffile import ELFFile def vaddr_to_offset(elf_path, vaddr): with open(elf_path, rb) as f: elf ELFFile(f) for seg in elf.iter_segments(): if seg[p_type] PT_LOAD: start seg[p_vaddr] end start seg[p_filesz] if start vaddr end: return vaddr - start seg[p_offset] return None换算完成拿到文件偏移后用十六进制编辑器把 dump 的字节覆盖到原始 so 的对应位置保存新 so再丢进 Ghidra 重新分析。整个过程要记录 dump 前和 patch 后的差异继续用 BinDiff 类工具做对比能立刻看出哪些函数被真正还原了。4.4 还原后的代码还是没法直接看怎么办有些样本在 native 层还叠加了 OLLVM 混淆、字符串加密、控制流平坦化。这时候即使指令回填成功了反编译出来的伪代码依然很难读。我的做法是分两步补环境一是用 unidbg 模拟执行目标函数。unidbg 能把 so 在 PC 上跑起来通过 hook 系统调用和 enc 相关函数去观察函数输入输出很多静态看不出来的算法流程在模拟执行中会清晰很多。尤其是那些只在特定参数组合下才会走到的分支模拟执行可以一条条 trace 记录下来。二是动态抓字符串。有些抽取还原后的函数里面字符串是运行时逐字节拼出来的。直接用 frida hook 常见字符串构造函数比如String相关的 native API再看内存中解密后的字符串往往就是加密算法、协议字段、URL 之类的关键线索。到这一步Native 抽取还原的目标基本达成至少你能看到一个完整符号、完整逻辑的 so 文件而不是一坨“空壳 ret”。5. Activity 生命周期重建把断掉的应用上下文接回去5.1 为什么脱壳后 Activity 生命周期是乱的理解这一点需要先回到壳的代理机制上。系统安装时读到的 AndroidManifest.xml里面的 Application 是 StubAppActivity 也很大程度上被壳的代理 Activity 接管。壳在运行时通过反射或动态代理把真实 Activity 类加载进来再让它看起来像是被系统正常调度。当你脱壳、修补 dex、甚至重新打包后问题就来了系统侧还是认为 Application 是 StubApp、Activity 是壳的代理。一旦壳的 native 解密能力失效或不存在了StubApp 就无法正确创建真实 Application那么 Activity 的 onCreate、onResume 也就无人去调度。更典型的情况是业务 Activity 根本没有在 Manifest 里注册系统发起的 Intent 无法找到对应组件直接抛 ActivityNotFoundException。所以在“脱壳后继续分析”的场景里Activity 生命周期重建不是可选项而是必经之路。5.2 方案一静态修复修改 Manifest 让系统认账静态修复的思路很直接既然壳的代理类不干活那就把 Manifest 里的 Application 和 Activity 全部改成真实业务类。首先从 dump 出来的 dex 里拿到原始 Application 类名、Activity 类名列表这一步可以用 jadx 直接浏览也可以写脚本解析 dex 的 class_defs 再按注解过滤。然后打开 apktool 解包目录下的 AndroidManifest.xml把 application 的 name 替换成真实 Application把壳的代理 Activity 条目清理掉再补上真实的 activity 注册包括可能被隐藏的 service、receiver。静态修复的优点是持久缺点是需要处理签名、资源、加固校验。对于纯分析用途你甚至不一定要让它安装成功重点是把类加载逻辑梳理清楚。但如果确实要跑起来做动态调试签名校验是个大坎后面我会单独说。5.3 方案二动态重建用 Frida 在内存里接管组件调度当不想重打包、不想对抗签名时动态重建是更快的方案。思路是绕过系统对 Manifest 的解析限制直接干预 ActivityThread 的调度逻辑。ActivityThread 是 Android 中管理 Activity 生命周期的主线程类。我们可以在 frida 里 hook ActivityThread 的消息处理比如 hookActivityThread.H类的handleMessage拦截LAUNCH_ACTIVITY消息拿到消息里的 Intent 后把 component 替换成真实 Activity 的类名。这样即使 Manifest 里没注册我们也能在 intent 唤起时把它导向正确的目标类。简单思路如下var ActivityThread Java.use(android.app.ActivityThread); var H Java.use(android.app.ActivityThread$H); H.handleMessage.overload(android.os.Message).implementation function (msg) { if (msg.what 0x64) { // LAUNCH_ACTIVITY var intent msg.obj.intent; var target Java.use(android.content.Intent).$new(); target.setClassName(com.example.real, com.example.real.MainActivity); msg.obj.intent target; } return this.handleMessage(msg); };注意消息常量在不同 Android 版本中可能有变化真实项目中不能直接写死 0x64要从源码或反射字段里读取。另一个更直接的思路是调用ActivityThread.performLaunchActivity手动构造参数并传给系统。这个方法在公开源码里可以查参数比较多不同版本也不一样适合对源码熟悉的同学去操作。动态重建的核心价值在于它不需要你修改原 APK目标进程启动后快速把真实 Activity 挂回去后续所有 frida 插桩和调试都基于真实组件的生命周期来开展。缺点是每次重启进程都要重新注入而且壳的反调试可能会干扰。5.4 重建之后仍然崩溃的排查方向生命周期重建不是魔法我实践中最常见的崩溃点有这几类Application 没有正确初始化。真实 Application 的 onCreate 里可能初始化了很多全局单例、数据库、网络库动态重建时如果没让真实 Application 先跑一遍Activity 一上来就访问空引用必崩。主题资源未加载。壳把资源文件也动了导致 Activity 创建时找不到主题。启动模式与 Task 栈不匹配。某些 Activity 声明了特殊的 launchMode但动态重建时没有原样保留 intent flags导致重入、重复创建。ProGuard 或混淆导致的类名不一致。这种一般在静态修复时最容易踩到从 dex 里读到的类名必须和打包前的原始类名完全一致任何一层重命名都会让组件找不到。所以我建议动态重建和静态分析交替进行先用静态修复把类结构摸清楚再结合动态重建快速验证关键路径。6. 常见问题快查与避坑实录6.1 frida 附加不上或者注入后进程直接退出这是对抗加固时最高频的问题。壳可能存在反调试和反注入逻辑常见检测点包括检测进程名、检测 /proc/self/maps 是否包含 frida 相关字符串、检测端口 27042、检测内存特征等。我的处理思路是分层排查先看日志有没有可疑的 java 层输出。再用 frida 的-fspawn 模式附加如果 spawn 模式失败尝试 attach 模式。如果进程启动即退出使用 stub 方式注入或者用 Magisk 模块隐藏 root 特征。临时关闭 SELinux 再试有时候只是权限问题。动态 native 层的反调试检测通常集中在 ptrace、android_log_print 调用上可以通过 hook ptrace 和 readlink 来屏蔽检测。这个方向能展开很多但实操中多半需要针对每个样本的具体特征去定制没有一劳永逸的脚本。6.2 dump 出来的 dex 无法解析或者运行报错如果拖出来的 dex 能被 jadx 打开但代码断断续续大概率是 dump 时机偏移了。业务 dex 可能分多次被解密加载第一次 dump 到的只是部分 class。这种情况多 dump 几次结合 dex 的 class_defs 校验结果挑一个最完整的。另一种情况是 dex 头被改过。有些壳会把 dex 头的 magic 或 file_size 篡改运行时才恢复dump 时扫描到的是未完全修复的版本。解决办法是手动修复 dex header或者用 dex-fix 类工具自动修复。6.3 Native so 还原后发现功能还是无法 hook有时静态指令已经“像模像样”了但函数内部真的执行路径还是被 OLLVM 压平了。这时 hook 目标不应该是整个函数而是先把函数跑起来接着用 Stalker 做指令级 trace看看真正执行到了哪些地址。Stalker 在固化代码分析中的应用很多但开销较大建议在关键函数范围内开启。6.4 速查表现象可能原因处理建议frida 注入即退出反调试/反注入检测隐藏 frida 环境hook ptrace调整注入模式脱壳后 dex 打不开dex 头损坏或 dump 不完整修复 dex header重新 dump 校验静态看到函数的 retnative 函数被抽取等运行时写回后 dump 内存再换算偏移 patch 回 somanifest 里没有真实 Activity壳代理了全部组件从脱壳 dex 中解析静态修复 manifest动态重建 Activity 后崩溃Application 未初始化或资源缺失先初始化真实 Application再启动 Activity还原后 so 反编译仍混乱OLLVM/控制流平坦化unidbg 模拟执行 Stalker trace 结合实际参数分析6.5 关于反调试与对抗的边界写这一节的时候我必须多说一句反调试对抗越深入越容易滑向“针对某个线上产品做破解”的领域。我的态度是对企业加固的原理学习到“能完成自建样本的还原、能看懂他人样本的技术报告”这个程度就够了。真正做产品安全测试时一定要在授权范围内而且通常安全测试讲究的是“发现风险”而不是“绕过所有检测”两者的技术导向完全不同。我个人在实际操作中的体会是逆向一款 360 企业加固样本最耗时的往往不是某个单一环节而是三个环节之间的衔接。每次你以为“dump 完就完了”结果 native 层给你一记闷棍你以为“还原 so 就结束了”结果 Activity 调度又把你拉回起点。后来我把流程固化下来先静态梳理入口和壳的初始化链再动态 dump dex 并立即校验之后专门花时间做 native 样本对比最后才考虑如何把组件生命周期接回来。这个顺序适合绝大多数加固样本也可以直接挪用到其他商业壳的对抗分析中。最后再分享一个小技巧操作过程中随时记录“谁改了哪片内存”会非常有帮助。用 frida 的 Memory.patchCode 或 mmap/mprotect 的记录脚本把壳每次写回的真实指令都保存成 bin 文件配合版本管理记录下来后面调整还原策略时能省很多返工时间。另外不管多急都不要在没保存原始样本的情况下直接改 so 或者 dex备份好原包再做任何 patch这是最基本的职业素养。
返回列表