先声明一下,这篇文章只聊技术原理和防御思路,所有内容仅供移动安全研究、恶意样本分析、漏洞挖掘等合法合规场景使用。我默认能看到这里的读者,都是在做安全研究或者学习逆向分析的同学,请勿把相关技术用于破解商业应用、绕过授权校验等非法用途。
360加固算是国内Android应用里出现频率很高的商业加固方案之一。早期版本就是简单地把DEX整体加密,运行时在Native层解密后加载进内存;后来为了对抗内存Dump,又加入了VMP、指令抽取、反调试、反HOOK等一系列手段。做样本分析的时候,遇到360加固的APK,基本绕不开两个事儿:一是把DEX从内存里捞出来,二是把安全加固过的SO文件修复到能静态分析的状态。这两个点,就是这篇文章要展开的内容。
我最早接触360加固是在分析一批恶意样本的时候,那时候工具链还不像现在这么完善,网上能找到的脱壳机也大多失效,只能自己一点点Hook、Dump、修ELF。几年下来,踩过的坑确实不少。这篇文章我就把自己在实际操作中验证过、在样本分析中真正派上用场的思路和步骤整理出来,从加固原理讲到DEX解密,再讲到ELF修复,最后附上常见的坑和排查办法,希望给正在做相关方向的同学省点时间。
1. 360加固的技术栈拆解:先搞懂对手在保护什么
1.1 加固后的DEX去了哪里
我们先从APK的打包结构说起。一个没有加固的APK,里面的classes.dex就是Dalvik/ART虚拟机直接执行的字节码文件。而360加固之后,APK里那个classes.dex其实是“壳”,真正的业务DEX会被加密成二进制数据,藏到assets目录、lib目录下的so文件资源段里,或者直接拼到so文件末尾。
我用一个比较直观的比喻:原本的DEX是一本书,360加固相当于把书的内容加密后锁进了一个保险柜(so文件),然后放了一本目录(壳DEX)在外面。应用启动时,libjiagu.so会先加载,在JNI_OnLoad里执行解密流程,把保险柜里的书取出来,再通过自定义ClassLoader加载到ART虚拟机里。这样做的目的很直接:如果只盯着classes.dex去静态分析,你看到的只有一个加载壳的入口,真正的业务逻辑根本不在里面。
这个阶段有一个非常关键的点:解密后的DEX一定会在某个时刻完整地出现在内存中,因为ART虚拟机要执行它,就必须要解析完整的DEX文件结构。这就是“脱壳”方案的本质思路——不管壳怎么加密、怎么混淆,最终的DEX字节码总要在运行时还原到内存里,我们只需要在正确的时机把它Dump出来。
1.2 为什么Native层是加固的核心战场
360加固把绝大部分保护逻辑都放到了Native层。具体来说,APK的lib目录下会有libjiagu.so、libjiagu_x86.so等多个不同ABI的SO文件,这些SO本身也不是“干净”的ELF文件,而是做过手脚的。
加固厂商对ELF的常见处理方式有这么几类:
- 清除或混淆ELF的section header,让readelf、IDA等工具无法正常列出节区;
- 把函数的关键指令抽取出来,运行时再动态解密回填;
- 修改Dynamic段,让动态符号表和重定位表解析异常;
- 在.init_array或JNI_OnLoad里加入反调试、反HOOK校验,检测到Frida、Xposed等环境就直接退出。
为什么Non-Native不可行?因为纯Java层的加固太容易被Hook了。ART虚拟机的加载流程相对固定,只要Hook住DexFile相关函数,就能在内存中拿到完整的DEX。而Native层的保护可以做到非常底层,它和系统加载器直接交互,要绕过需要在更底层做对抗。所以360加固在Native层投入了大量精力,这也是我们在分析时不得不处理ELF修复问题的根本原因。
理解了这两点,后面的DEX解密和ELF修复就有了明确的目标:DEX解密解决的是“业务逻辑在哪里”的问题,ELF修复解决的是“加固SO怎么入手”的问题。
2. DEX解密实战:把内存中的真实字节码捞出来
2.1 环境搭建与脱壳工具选型
做DEX脱壳之前,先把环境准备好。我的常用组合如下,给大家做个参考:
| 环节 | 推荐方案 | 说明 |
|---|---|---|
| 运行环境 | Android 7~10的模拟器或真机,已Root | 老版本ART的DEX结构相对直观,新版本需要适配 |
| 注入框架 | Frida 14~16 | Hook能力强,支持JS/Python脚本,日常分析首选 |
| 辅助工具 | BlackDex、Youpk、FDex2、Frida-DexDump | 先用成熟工具快速验证,再手动Hook深入分析 |
| 静态分析 | 010 Editor、jadx、GDA | 用来检查Dump结果的完整性和结构 |
| 抓包/动态调试 | objection、IDA Pro | 处理SO层逻辑时配合使用 |
BlackDex这种工具比较适合用来做快速验证,它不依赖Frida,直接在运行时通过内存搜索DEX魔数来实现整体Dump,对于早期的360加固版本效果很好。但如果遇到了指令抽取、VMP等更高强度的保护,BlackDex就只能Dump出空壳了,这时候就需要手写Frida脚本配合动态调试来还原。
2.2 基于Frida的内存Dump完整流程
我只写一套自己实测稳定的思路,核心就是“找准时机、多路Hook、校验结果”。
第一步,先确定加固版本。安装APK后,在Frida里搜模块,看有没有libjiagu.so。有的话,再确认它的导出函数特征。一般可以通过JNI_OnLoad入口确认这是360加固。
第二步,Hook关键函数。DEX解密后,文件内容会被读取到内存,然后被ART虚拟机解析。所以我们可以Hook以下两类函数:
- 文件读取链路:open、read、lseek、mmap
- DEX解析链路:DexFile::DexFile、dexFileParse(老版本)、art::DexFileLoader
我用得最多的是DexFile::DexFile和dexFileParse的hook点,因为这两个函数在加载时会拿到完整的DEX内存地址和大小。下面是一个Frida脚本示例,思路是Hook libart.so里的DexFile相关函数,在内存中搜索DEX魔数并保存:
// frida -U -f com.example.target -l dump_dex.js --no-pause function dump_dex(addr, size) { if (size <= 0 || size > 0x8000000) return; // 过滤异常大小 var dex_magic = Memory.readByteArray(addr, 4); var magic_bytes = new Uint8Array(dex_magic); if (magic_bytes[0] !== 0x64 || magic_bytes[1] !== 0x65 || magic_bytes[2] !== 0x78 || magic_bytes[3] !== 0x0a) { return; // 不是 dex\n 开头 } var file = new File("/data/local/tmp/dex_" + Date.now() + "_" + ptr(addr) + ".dex", "wb"); file.write(Memory.readByteArray(addr, size)); file.close(); console.log("[*] dumped dex at " + addr + ", size: " + size); } Interceptor.attach(Module.findExportByName("libart.so", "_ZN3art7DexFileC2EPKjRKNSt3__110unique_ptrINS_16DexFileContainerENS3_14default_deleteIS5_EEEE"), { onEnter: function(args) { var begin = args[0]; var container_ptr = args[1]; // container + 0x10 处可能是 size var size = Memory.readPointer(container_ptr.add(0x10)).toInt32(); dump_dex(ptr(begin), size); } });这是老版本ART的hook方式,函数符号在不同Android版本里会变化。新版本建议直接Hookart::DexFileLoader::OpenCommon,或者用现成的Frida-DexDump工具扫描整个内存的DEX魔数。我在实战中比较喜欢“内存扫描”这种方式:打开一个线程,反复扫描运行中的进程内存,找到所有以dex\n 035或dex\n 037开头的内存段,然后Dump下来。
为什么反复扫描很重要?因为加固后的DEX不是一次全部加载进来的,有些类可能是在运行时动态解密、动态加载的。只Dump一次的话,很可能只拿到一部分类,后面的方法调用就会报ClassNotFoundException。我遇到过一个样本,光是在冷启动阶段就解密了三份DEX,而且有两份是同一个DEX的不同方法块,必须在不同的时间点各Dump一次才能拼完整。
2.3 Dump结果的校验与DEX结构修复
Dump下来的文件不能直接丢给jadx,需要先做结构校验。DEX文件头有固定的字段要求:
- 魔数:
64 65 78 0a 30 33 35 00,对应"dex\n035\0" - checksum:文件第8~11字节,是对除magic和checksum外所有内容的Adler32校验值
- signature:文件第12~31字节,是SHA-1哈希值,一般也需要修正
如果直接用010 Editor打开Dump出来的DEX,很可能会看到一片乱码,或者在jadx里打不开,提示“Invalid DEX file version”之类。这时候就需要自己写脚本修复头部校验字段。下面这个Python脚本是我日常一直在用的:
import hashlib import zlib import sys def fix_dex(path): with open(path, "rb") as f: data = bytearray(f.read()) if len(data) < 0x20: print("file too small") return # 0x08 ~ 0x0B 是 checksum,0x0C ~ 0x1F 是 signature # 先填 0,再计算 checksum 和 signature data[8:12] = b"\x00\x00\x00\x00" data[12:32] = b"\x00" * 20 # SHA-1 signature 是除 magic 和 signature 外所有内容的哈希 sig = hashlib.sha1(data[32:]).digest() data[12:32] = sig # Adler32 checksum 是除 magic 和 checksum 外所有内容的哈希 csum = zlib.adler32(data[12:]) data[8:12] = csum.to_bytes(4, "little") out = path.replace(".dex", "_fixed.dex") with open(out, "wb") as f: f.write(data) print("fixed ->", out) if __name__ == "__main__": fix_dex(sys.argv[1])这个脚本的原理并不复杂。DEX的头部校验字段有自己的计算范围:Checksum是文件第12字节到末尾的Adler32值,Signature是文件第32字节到末尾的SHA-1值。计算时要先把对应位置清零,避免把上一轮的错误校验值一起算进去。修复之后再用jadx打开,一般就能正常反编译了。
不过要注意,这只能修复DEX文件头的一致性。如果加固采用了指令抽取,DEX里函数对应的insns区域可能是一堆0或无效指令,运行时才被填回真正的字节码,那么静态反编译出来的代码依然是不完整的。这种情况属于VMP或抽取加固的范畴,需要配合Unicorn模拟执行、指令回填等更底层的手段来处理,本文后面会稍微聊到。
3. ELF修复技术:Native层的硬骨头怎么啃
3.1 加固SO对ELF文件结构的破坏方式
DEX捞完之后,我们的目标就转到SO文件上。360加固的SO和普通的NDK编译产物很不一样,直接用IDA打开,通常是一堆无意义的section名称,甚至section header都是空的,符号表也不完整。
我总结过几种典型的ELF破坏方式,遇到问题可以先对号入座:
第一种是清除section header table。ELF文件分为ELF header、program header table、section header table三大部分。section header table对运行不是必须的,但静态分析工具非常依赖它。加固SO会把e_shoff、e_shnum、e_shstrndx等字段清零或者改成无效值,导致readelf -S、IDA无法列出节区,只留下非常有限的segment信息。
第二种是混淆符号与重定位表。Dynamic段里记录的DT_SYMTAB、DT_STRTAB被替换成无效地址,或者把符号表里的函数名全部改成无意义字符串,让逆向分析者无法直接看出so导出了哪些函数。
第三种是抽取函数指令。把关键函数的指令片段从.text段中抽走,运行时在.init_array或特定函数里动态解密并写回。静态分析时这些函数的机器码是空的或是一堆占位数据,看反汇编代码完全无法理解其逻辑。
第四种是整体加密 + 自解密。整个.so文件重要段都被加密存放,运行时在内存中解密成完整ELF再执行。这时候直接从APK里拿到的.so文件是加密壳,真正可以分析的ELF只存在于进程内存中。
针对前两类破坏方式,静态修复还能搞定;针对后两类,就得在动态执行环境中让so自己解密自己,再在内存中做Dump和重建。
3.2 静态修复ELF的标准操作流程
先说静态修复的思路。一个ELF文件能正常加载运行,主要依赖的是ELF header和program header table,而不是section header table。运行时loader只需要把各个segment映射到内存、处理重定位即可。所以当遇到section header table被破坏的情况,我们可以靠着program header table里的动态段信息,重建出可分析的ELF。
我的标准操作流程是这样的,供大家参考:
第一步,用readelf看基本结构。先跑一下readelf -h看ELF header里哪些字段异常,再跑readelf -l看program header,重点确认PT_LOAD段、PT_DYNAMIC段是否存在。如果PT_DYNAMIC还能正常解析,说明动态链接所需的基本信息还在。
readelf -h libjiagu.so readelf -l libjiagu.so readelf -d libjiagu.so第二步,检查Dynamic段。重点关注DT_SYMTAB、DT_STRTAB、DT_JMPREL、DT_RELA这几个标签。如果这些值都正常,说明符号表和重定位表是可用的,只是section header被清了。这时候修复的思路就不应该是凭空重建section,而是先恢复一个可运行的“最小ELF镜像”。
第三步,用已有信息重建ELFile。一种比较推荐的做法是直接用010 Editor的ELF模板。010 Editor内置了ELFSectionHeaderTable、ELFProgramHeaderTable等模板,打开文件后能自动解析ELF结构,还能手动修改字段。如果不知道模板怎么用,网上搜“010 Editor ELF模板解析”相关的资料,其实核心就是打开Template Editor后选中ELF.bt模板文件,然后系统会帮你把各个字段的偏移都算好,非常适合在修复时定位e_shoff、e_shnum这些关键字段。
我实际用法是这样的:先拿一个正常的、未加固的同架构SO文件做参照,在010 Editor里把两者的ELF header并排对比,重点关注e_shoff(节区表偏移)、e_shnum(节区数量)、e_shstrndx(节区字符串表索引)这几个字段的差异,然后把加固SO里被清零的字段按正常SO的结构补回去。
需要注意,直接照抄正常SO的节区表是不可行的,因为两个SO的布局和符号表大小不同。但我们的目标往往是先让IDA能够解析函数边界,所以在恢复_e_shoff_之后,还需要根据PT_LOAD段的偏移构建出可信的节区表。这个过程比较繁琐,需要多次验证。
3.3 动态Dump与内存重建:让so自己“交出”ELF
静态修复搞不定指令抽取和整体加密的情况,那就得上动态方案。动态方案的核心思想是:不管加固so怎么保护,它最终都要在内存中展开成完整的、可执行的ELF镜像,我们只要让它在内存中跑起来,再把内存镜像Dump出来,就能拿到真正可分析的ELF。
这里我把Unicorn模拟执行单独拎出来说,因为它是我处理指令抽取加固时最常用的工具。Unicorn是一个CPU模拟器,它可以在宿主机器上模拟ARM/ARM64指令的执行,不需要真实设备。流程一般是这样的:
- 解析加固so的program header,把PT_LOAD段逐段映射到Unicorn的虚拟内存中;
- 设置好SP(栈指针)、PC(程序计数器)和参数寄存器,直接调用需要解密的函数入口;
- 让函数在模拟器里执行完,等解密逻辑把指令写回.text段;
- 从虚拟内存中把.text段重新读出来,和原文件对比,找出被回填的指令区域;
- 用ida脚本或010 Editor把这些指令patch回原so文件。
这套方案的好处是不用关心设备上的反调试和反HOOK,因为执行环境完全由我们控制。坏处是环境搭建成本高,遇到大量系统调用或JNI交互时,需要我们自己模拟实现相应函数,否则程序会跑飞。
还有一种更简单粗暴的动态方案——直接在Android设备上运行APK,然后从内存中Dump so。用Frida附加到目标进程,搜索模块基址和大小,读取整个内存镜像,然后保存下来。这个方案的问题在于,内存镜像里的so是已经映射过的,它的段地址已经按页对齐,直接保存后并不能直接作为文件用,需要对ELF头部和段的偏移做合理的重建。我自己习惯用Frida配合一个so_dump脚本,把内存中映射段导出来,然后手动修复header中的偏移字段。这类脚本GitHub上有很多,搜“Android so dump frida”就能找到。
这里应该强调一点:动态方案虽然见效快,但逻辑理解要跟上,否则很容易Dump出来一堆看似完整但实际无法分析的垃圾数据。我见过有人从内存里Dump出一个“ELF”文件,readelf能识别header,但IDA打开全是乱码,原因是内存镜像中的p_vaddr和文件偏移已经完全不是对应关系了,没有做正确的转换。
3.4 静态方案与动态方案的选型对比
在实际样本分析中,怎么选静态和动态方案?我个人的经验是用一个简单的判断框架:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| section header被清除,其他段完整 | 静态修复 | 只影响工具解析,不影响运行,重构成本低 |
| 动态符号表被破坏 | 静态修复 + 根据导入导出表推导 | 可以结合Dump时的内存符号信息补充 |
| 指令抽取 | 动态执行后回填 | 抽取的指令必须让程序跑起来才能还原 |
| 整体加密 | 动态内存Dump | 静态拿不到明文,必须先运行 |
实际分析中一个样本可能是多种破坏方式叠加的,需要静态和动态一起上。先跑readelf看结构,能修就修;修不了就上Frida或Unicorn动态捞内存;捞完再做二次细修。整个过程是一个不断反馈、不断修正的过程。
4. 常见问题与排查技巧实录
4.1 Dump下来的DEX全是空壳或只有少量类
这个问题绝大多数情况下是“时机不对”。DEX是在类被加载时才真正解析的,如果加固方案是按需解密,那么只Dump一次肯定拿不全类。解决办法是改进Dump策略:
- 使用Frida反复扫描内存,每隔几秒自动扫描一次;
- 在关键业务页面启动后,再触发扫描,让所有类都有机会被加载;
- Hook DefineClass或ClassLoader.loadClass,在类加载的hook点做Dump。
再补充一个经验:360加固在部分高版本系统上,解密后的DEX不会长期驻留内存。它可能在某个类加载完成后,就把对应内存区域释放了或者覆盖了。遇到这种情况,光靠被动扫描是拿不到的,需要主动去触发业务逻辑,或者Hook DEX释放相关的函数,在释放前把内容保存下来。
4.2 so修复后一加载就崩溃,报relocations错误
“relocations in generic elf”这串关键字,我在排查so加载崩溃时见过很多次。它通常意味着链接器在处理重定位时,ELF文件中的重定位类型或符号索引有问题。对于脱壳后的so来说,这个错误一般有两个原因:
第一,内存Dump时只保存了.text和.data段,没有完整保存Dynamic段和重定位表。运行时加载器需要依赖这些表来解析函数地址,缺失了自然就崩了。
第二,dump时地址没有按文件偏移对齐。内存镜像的段地址是按页对齐的,但ELF文件要求各段的p_offset也按一定规则对齐。如果直接保存,就会导致section映射关系错乱,重定位时找不到对应的目标地址。
遇到这种情况,建议回到ELF header逐字段排查。拿到dump文件后,先跑一遍readelf -l,确认每个PT_LOAD段的p_vaddr和p_offset差值一致(对齐范围内允许有差异),再用010 Editor检查重定位表相关的Dynamic条目是否指向有效内存。多数情况下,修正段偏移就能解决问题。
另外还有一个容易被忽视的点:ELF interpreter。在Linux环境里,如果用户态程序报了“bad ELF interpreter”之类的问题,通常是指定了解释器路径但找不到。Android下的so没有显式interpreter,但NDK开发时如果选择错误ABI,也会在运行时报类似的“cannot locate symbol”或“wrong ELF class”错误。排查时可以先readelf -h确认架构(ARM还是ARM64、x86还是x64),确保和运行设备的ABI一致。我遇到过有人在自己测试机上装的是arm64位so,却在32位进程里尝试加载,结果怎么修也修不出一个能跑的so来。
4.3 010 Editor辅助ELF修复的几个实用技巧
这里再展开说下010 Editor在ELF修复中的应用。很多人觉得010 Editor只是一个十六进制查看器,其实它的模板解析功能在逆向里特别有用。
先设置一下模板。010 Editor的模板是基于脚本的,打开一个ELF文件后,如果之前没有安装ELF的模板,需要去官网的Template Repository下载或手动导入。导入后在“Templates”菜单里选择“ELFSectionHeaderTable”模板,编辑器会自动解析出文件头、program header table、section header table的字段,并以树形结构展示。这比自己手动去十六进制里翻要靠谱多了。
我常用的几个操作:
- 按字段定位:比如想看e_shoff被改成多少,直接在模板树里点e_shoff,010 Editor会同步高亮文件中的对应字节位置。
- 二进制对比:打开两个文件(一个损坏so、一个正常so),用“Tools -> Compare Files”或者自定义脚本比较相同字段的偏移和值,能快速发现哪些字段被篡改。我经常拿正常编译的so去对比加固so的header结构,定位被动的“手脚”。
- 地址换算:ELF文件里很多字段是十六进制表示的,在010 Editor的十六进制窗口和十进制窗口之间切换,能让偏移计算方便很多。观察阶段的数据、修复后的数据都可以用hexdump导出,对比起来非常直观。
4.4 加固对抗实战中的几个高级坑
普通脱壳工具处理360加固老版本时很好用,但版本一升级,之前能跑的脚本就失效了。常见的高阶保护手段有几种:
- 字符串加密:所有敏感字符串在静态文件中都是密文,运行时才解密。就算你拿到了完整的DEX,反编译出来看到的还是一堆乱码字符串。需要配合Hook解密函数,在运行时做字符串还原。
- VMP:函数被转成解释器模式执行,DEX从内存中捞出来,函数体也是一段段被VMP处理的字节码,静态看不到真实逻辑。这种只能靠动态调试和协议分析来绕过。
- 反调试与反Frida:360加固的so会检测Frida的端口、扫描maps中的可疑文件、校验so的hash。遇到这种情况,我的经验是先换掉Frida默认的端口,再用Gadget模式,或者在Frida的底层hook处加反检测修改。
处理这类对抗,我的总体经验是:不要一上来就硬刚最新加固,先踩稳整体流程,把DEX和ELF的通用修复搞清楚,再针对加密的样本逐步升级方案。基础不牢的话,连报错都分辨不出是加固导致还是自己修复错了。
5. 脱壳与修复后的一些实操心得
最后再分享几点我自己实践后的体会,这些在书本和工具文档里往往不会写到。
第一,工具始终是辅助,理解ELF和DEX的底层格式才是根本。我遇到过很多人遇到问题就换工具,BlackDex不行换Frida-DexDump,Frida-DexDump不行又换Youpk,换来换去还是解不了,原因就是不理解DEX的header校验、不理解ELF的program header怎么映射。当你搞清楚了格式本身,很多问题完全可以自己写个小脚本解决,根本不需要到处找工具。
第二,拿到样本先不要急着跑,先做静态观察。用unzip -l看看APK结构,用readelf看看so文件情况,用jadx打开壳DEX感受一下加固逻辑。这些静态观察能帮你快速判断这是哪个版本、用了哪些保护手段,从而决定后续方案。我见过有人拿到样本就开始Frida Hook,Hook了半天发现根本没定位到关键函数,就是因为跳过了这一步。
第三,操作过程中养成随时备份的习惯。我修复ELF时经常改着改着就把文件改坏了,如果没有原始备份,只能重新Dump一遍,时间成本非常高。建议每做一步关键修改,就另存一个带注释的文件名,比如libjiagu.so.bak0_original、libjiagu.so.bak1_section_fixed,这样回溯问题时非常方便。
第四,遇到复杂样本不要单打独斗,把工具链组合起来用。Frida负责动态Hook和Dump,Unicorn负责在可控环境中模拟执行,010 Editor负责静态比对和修复,IDA负责最后的逻辑分析。这一整套组合拳打下来,大部分360加固的样本都能拆到可分析的程度。
后续如果想继续深入,可以往VMP指令还原、反调试对抗、ART运行时Hook等方向扩展。不过这些都是后话了,先把DEX解密和ELF修复的底子打牢,后面的路自然会顺很多。