360加固这类国产加固方案,在Android安全分析里几乎绕不开。不管你是做恶意样本分析、加固SDK自研,还是做合规检测,只要手里的APK套了一层加固,必然要面对两个硬骨头:一个是DEX怎么从加密状态还原成可分析状态,另一个是ELF里那些被改造过的结构怎么理顺。这篇文章不打算写那种照着点按钮的“一键脱壳”流水账,而是把背后的原理拆开讲清楚——加密包为什么长这样、DEX和ELF的结构在加固里各自扮演什么角色、所谓“修复”到底在修什么。
需要先申明边界:本文只讨论加固方案的技术原理与安全分析方法,用于自研防护评估、恶意代码分析等合法场景。对他人App做逆向,务必先获得授权;任何绕过授权机制的行为都不在讨论范围内。
1. 加固壳的两条主流技术路线:整体DEX加密与抽取式加固
先说结论:现在市面上主流加固方案虽然细节不同,但骨架高度相似。360加固在早期版本和后来的迭代中,核心思路一直围绕两条路线展开——整体加密和抽取式加固。理解这两条路线的差异,是后续所有分析和修复工作的前提。
1.1 整体加密:把DEX锁进资源文件
整体加密的路子比较直白。APK在加固阶段,原始dex文件会被加密,密文被放到assets或者特定资源目录下,同时APK里会替换进一个壳的Application入口。原始Application会被改名、隐藏,真实类名写进壳的配置里。
用户在桌面点击图标启动App时,系统先实例化的是壳的Application。壳的Application在attachBaseContext阶段做两件事:第一件是System.loadLibrary加载对应的so文件,这个so就是解密执行体;第二件是从so里拿到解密的输出,再通过自定义的ClassLoader加载真实DEX。
这两件事的顺序很关键。因为落盘到APK里的原始DEX是密文,Java层到这一步还拿不到可执行的字节码,必须先靠native层完成解密动作,再交给Art虚拟机去解释执行。所以:天然分成两层。
从分析者的视角看,整体加密的突破口在运行时——不管加密算法多复杂,最终在进程内存里一定会出现明文DEX文件镜像。这个镜像可能是不完整的,也可能会被拆分,但只要虚拟机还要执行Java代码,内存里一定存在还原后的DexFile对象。
1.2 抽取加固:把方法体从DEX里“抽走”
整体加密有个副作用:只要拿到运行时的内存dump,整个DEX能一次性还原,防护强度相对有限。所以后来主流的加固方案都在往“抽取”方向走。
抽取式加固的思路是:不再满足于把整个DEX加密存放,而是对每个方法单独处理。在加固阶段,将特定方法的code_item从DEX中摘除或者填充无意义指令。原指令被转移存储到so层或者其他加密数据区。运行时,在方法首次被调用、类被加载等时机,native层钩子会把原始指令回填到内存里的method对应的代码区,再继续执行。
这样做对分析工作影响很大:你即便拿到了DEX完整的内存镜像,也只能看到静态的class信息、字段、方法声明,真正的方法体可能是空的,也可能只有几条恢复用的跳板指令。想看到完整逻辑,必须顺着方法体和native层的对应关系去补齐,这就是“修复DEX”这件事的核心来源。
1.3 壳入口总是藏在so里
不管是整体加密还是抽取加固,都绕不开一个事实:解密逻辑必须放在native层,否则审计者用Java层工具就能直接跟到密钥和算法。这就是ELF在加固体系里拥有极高地位的原因。
壳的so文件通常有一个相对简单的初始化链路:linker加载so之后,先执行.init_array里面的构造函数,再把控制权转移给壳的JNI_OnLoad,或者由Application主动调用某个JNI函数触发后续动作。加固so里往往还集成了反调试、反内存dump、环境检测等逻辑,这些逻辑和真正的解密代码混在一起。
所以分析ELF不是可选动作,而是必须动作。不把so里的入口函数和调用关系理顺,就不知道解密DEX的触发时机,也不知道抽取方法体是从哪块内存拿回来的。
2. DEX和ELF文件结构速记:解密与修复都不能偏离格式约束
很多新手拿到一个加固包,第一反应是找工具,第二反应是找脱壳脚本,唯独忘了先把静态格式吃透。实际上DEX和ELF这两个格式里的关键字段,直接决定了你分析到哪一步、修复时改哪里。
2.1 DEX header是解密后的第一张地图
DEX文件开头是固定的magic,通常为“dex\n035”或“dex\n037”。紧跟在magic后面的整个header区域,记录了文件尺寸、各索引区的偏移和数量。
重点看这几个偏移字段:
- string_ids_off / string_ids_size:字符串索引区,涉及类名、方法名、字段名的字符串都在这。
- type_ids_off / type_ids_size:类型索引,描述类、数组、原始类型的字符串引用。
- method_ids_off / method_ids_size:方法索引,每个method_id包含class_idx、proto_idx、name_idx。
- class_defs_off / class_defs_size:类定义区,指向每个类的详细定义。
- data_off / data_size:数据区,保存code_item、debug信息等实际字节码。
这些字段指向的是文件内偏移。所有官方解析工具和反编译工具都依赖这套表结构。如果加固过程中篡改过索引表结构,或者dump出来的DEX是从内存某个地址直接拷贝的,那么header和真实数据之间可能出现错位,直接丢给jadx或者IDA往往解析失败。
判断一个DEX是否“健康”,第一步不是关心有没有壳,而是先检查header是不是完整、索引区是否和data区对齐。这是修复工作最基础的分诊动作。
2.2 code_item位置和“修复”粒度
每个方法最终的执行指令存放在code_item里。Art虚拟机执行一个方法时,核心就是读取code_item中的insns指令数组。
code_item的结构里有一组字段需要记住:
- registers_size:参数加局部变量的寄存器数量
- ins_size:入参寄存器数量
- outs_size:方法调用时传入的参数寄存器数量
- tries_size:异常try区域的数量
- debug_info_off:调试信息偏移
- insns_size:指令数量
- insns[]:实际的16位指令单元数组
抽取式加固做手脚的地方就是insns[]。加固器可以把整个insns清空、置成nop,或者重定向到一个跳板地址。运行时再由native回填真实指令。
修复时的“方法体修复”,本质就是把被抽走的insns恢复到code_item正确位置,并确保insns_size与真实指令长度一致。如果只恢复了指令内容但长度字段不对,最终执行时会越过边界,轻则解析错乱,重则崩溃。
2.3 ELF头是壳入口的定位器
ELF格式的套路同样固定。文件最开头是e_ident魔数,通常为0x7f 45 4c 46,也就是ELF三个字母。再往后是ELF头里的关键字段:e_type标识可执行文件还是共享对象,e_machine标识ARM、ARM64还是x86架构,e_entry是程序入口点。
但Android平台上的native库更关心的是程序头表和节头表。程序头表里的PT_LOAD段描述运行时要映射到内存的块,linker加载so就是按PT_LOAD来的。节头表则保存符号表、字符串表、重定位表等在静态分析时要用的信息。
加固对ELF的改造手段常见有几种:
- 修改ELF头的某些字段,让静态解析器无法直接load。
- 删除或者混淆节头表,只保留程序头,导致IDA等工具加载后看不到导出符号。
- 把真正的导出函数藏在动态符号表之外,只保留一个入口,其余逻辑运行期展开。
- 对敏感函数做控制流混淆,或者整段代码加密,运行时在内存里解密。
“ELF修复”这个概念之所以存在,就是因为经过这些改造的so文件已经不是一份标准的、可被分析工具直接读懂的ELF了。想看清楚这个so内部做了什么,必须先把它还原成标准结构,或者通过运行时行为去推算它原本的逻辑。
3. 解密对象三连:运行时内存还原、DEX镜像校验与修复粒度
“解密”这个词在不同上下文里意思差别很大。在360加固的方案里,可以拆成三个层次去看:整体DEX密文的还原、抽取方法体的指令回填、以及ELF中相关逻辑的重建。三个层次分别对应不同的分析动作。
3.1 为什么运行时还原后仍然不能直接反编译
整体加密的情况下,只要壳的Application跑起来,内存里就会存在一个解密后的DEX镜像。理论上,把这个镜像从进程内存中提取出来,丢掉原始APK里的密文DEX,这个环节就算完成了。但实践里经常碰到几个坑:
第一,dump时机的问题。壳可能不会在Application启动第一时间解密全部DEX,而是按需解密。如果你dump太早,只能拿到一部分类;dump太晚,可能加载了大量已经回填过指令的DEX,内存布局变得复杂。
第二,多个DEX叠加的问题。一个App可能有classes.dex、classes2.dex、classes3.dex,加固后它们可能被合并成一个整体存储,运行时再拆分还原。dump出来之后需要按DEX header去切割,才能得到多个独立的DEX文件。
第三,内存对齐的问题。DEX在文件系统里有严格的对齐要求,整个文件必须按4字节对齐。内存镜像如果是从堆里某个非对齐地址直接抓的,可能头尾多出或缺少字节,导致解析器报错。
这些都不是算法层面的难题,而是工程层面的琐碎问题。处理它们没有捷径,只能先校验DEX header合法性,再按结构逐个区域核对。
3.2 校验与重建索引的基本思路
拿到一个DEX镜像,我先建议先做几步机械动作:
第一步,检查起始4字节是不是“dex\n”魔数。很多内存dump工具输出的是完整DexFile对象,可能包含前面的Object header,需要跳过。
第二步,读取file_size字段,看和实际文件长度是否一致。如果长度大于file_size,说明后面带了附加数据;如果长度小于file_size,说明dump不完整。
第三步,检查各索引区偏移是否超过file_size。一旦发现有偏移超过文件尾,基本可以判断这个DEX被截断过,或者被加固器改写过头。
第四步,随机抽几个method_id,解析出对应的class_idx、proto_idx、name_idx,再跨索引区解析字符串,看能否拼出一个正常的方法声明。
这一步做完,绝大多数“坏DEX”都能发现具体的异常点。修复工作也不是什么魔法,就是把异常点定位出来,尽量把结构恢复成标准形态。比如class_def里指向class_data的偏移错了,就按真实的内存地址修正;method索引里的name_idx指向了无效字符串,就查字符串表重建字符串。
这整套流程和“DEX解密”其实是一体的:加密过程破坏的是可读性,修复过程恢复的也是可读性。核心价值都在于让静态分析工具能够按照标准格式重新加载这份数据。
3.3 抽取方法体跟踪的换算关系
抽取式加固比整体加密更麻烦。你从内存里抓到的DEX可能已经包含了所有类定义,但大量被抽走的方法体依然是空壳。
要判断一个方法是不是被抽了,有个很直接的办法:看它的insns_size和insns内容。如果insns_size异常大或者异常小,且内容全是nop,或者第一个指令直接是一个跳转到某个固定地址的分支,那八成就是被处理过。
修复抽取方法时,关键是把方法体对应的原始指令找回来。常见的找回路径有几条:
- 壳在运行时会把指令回填到内存,你可以在方法执行后再dump一次,对比执行前后的insns差异。
- 壳的native层可能保存了一个方法地址到指令密文的映射表,分析so后可以从映射表直接找到每个方法的原始指令。
- 某些方案会把指令按方法粒度加密存放,修复时需要在so里找到加解密函数,逆出密钥后再逐个解密。
无论哪种路径,本质上都会落到一件事:把so里存储的二进制数据,按照DEX code_item的格式要求,塞回正确的位置。所以,抽取式的“DEX修复”和ELF分析是深度绑定的。你不可能脱离ELF单独谈抽取修复。
4. ELF修复的具体指向:从不可直接读到与DEX可联动
很多人把ELF修复理解成“把加固so改成能看的so”,这个说法太笼统。真正在分析场景里,ELF修复通常包含三个明确的子任务:恢复ELF基本结构、定位壳初始化入口、解析出加密数据表的生成规则。
4.1 加固so在二进制层面做了什么
360加固的so文件从体积上就很有辨识度,通常体积不小,因为里面塞了大量加解密、反调试、虚拟机检测相关的代码。这类so在静态层面上往往有这些特征:
- 节头表可能缺失,或者节的数目被改成一个极小值。
- 动态符号表中只保留了极少量导出项,JNI_OnLoad都不一定直接暴露。
- 入口函数可能被包在.init_array里的多个构造函数中,调用关系复杂。
- 符号字符串表里全是乱码,或者被加密成非ASCII字符串。
静态分析工具面对这种文件时,最常见的反应是“加载失败”或者“识别为未知CPU架构”。这些只是表象,本质是工具依赖的节头表、符号表信息被破坏了,导致解析器无法按常规路径定位代码和数据。
修复ELF的第一步,通常是绕过或者重建这些被破坏的信息。绕过是比较务实的手段——既然linker加载so实际上只看程序头表,那么加载后程序头表在内存里的形态就是可用的。在内存态下借助调试器或者运行时工具去转储,能拿到一份已经映射好的、方便IDA分析的镜像。
4.2 需要梳理清楚的三类ELF入口
我分析加固so时,习惯先把入口梳理成三条线:
第一条是静态入口线。从ELF头的e_entry出发,结合.init_array里的构造函数指针,找到最初执行的那些函数。加固的初始化逻辑大概率藏在这里。
第二条是JNI入口线。壳在Java层一定会调用System.loadLibrary,随后虚拟机会找JNI_OnLoad。所以JNI_OnLoad往往是一个必经点,从这里跟进能摸到壳暴露给Java层的API面。
第三条是动态注册函数线。很多加固so在JNI_OnLoad里会调用RegisterNatives做动态注册,把Java层native方法和so里的函数绑定起来。分析动态注册的参数,能直接看出哪些native函数负责解密、哪些负责指令回填。
这三条线不是孤立存在的,它们在运行时会汇合。最典型的场景是:某个Java native方法从Java层传入一个DEX文件路径或者byte数组,native函数内部完成解密之后,再通过Art API注册进虚拟机。这一整套调用关系,离开了ELF层面的分析根本理不清。
4.3 DEX和ELF联动修复的边界
实际项目里,DEX修复和ELF修复经常是交替进行的,不存在先修完DEX再修ELF这种线性顺序。
举个常见例子:抽取式加固的so里保存着一个“方法索引表”,表的每一项记录某个方法在DEX中的method_idx、类的class_def偏移,以及加密指令的存储位置。分析so时,你会拿到这张表;修复DEX时,你要靠这张表去回填每个方法体。没有ELF分析,表就找不到;没有DEX结构知识,表里的method_idx也解释不了。
反过来也一样:如果你先把DEX修复好了,再回头看ELF,很多so里的加密字符串、函数命名规律突然就能对上了。因为DEX里Java层的调用关系,正好可以印证so里哪些函数是解密DEX用的、哪些是反调试用的。
边界在哪里?我的经验是:DEX修复强调结构对齐,ELF修复强调行为关联。DEX修复的“对错”是解析器能不能正常加载,ELF修复的“对错”是你能不能把so里的逻辑映射到具体行为上。先修DEX,修到能过解析器,再回去分析ELF,效率最高。
5. 合法分析场景下的工具链与避坑经验
工具这块我不想重复网上一抓一大把的列表。只说我实际项目里用下来比较顺手的组合,以及这几个工具在“DEX解密与ELF修复”任务里的合理分工。
5.1 工具分工参考
| 工具 | 主要用途 | 在加固分析里的角色 |
|---|---|---|
| jadx | Java层反编译 | 还原DEX后的类结构、调用关系 |
| IDA Pro | ELF/ARM指令分析 | 梳理so入口、解密函数的调用链 |
| 010 Editor | 二进制模板解析 | 手工检查DEX header、ELF header、code_item |
| unidbg | JNI模拟执行 | 在无设备环境下调用so里的解密函数 |
| 动态调试器 | 运行时内存与寄存器观察 | 捕捉内存中的明文DEX、跟踪指令回填时机 |
这几类工具之间是互补关系。纯静态分析卡住时,我会靠010 Editor手工过一遍结构字段,判断到底是文件被截断还是索引被篡改。要看so里的解密逻辑时,IDA是主力。要验证一个猜测的时候,unidbg这类模拟执行工具比真机调试更快,省去了适配、反调试对抗的麻烦。
5.2 容易被忽略的坑
第一个坑是版本差异。360加固不同版本的实现差异非常大,老版本可能只是整体加密,新版本可能是抽取加VMP的混合方案。网上很多脱壳脚本只适配特定版本,拿新版样本直接跑,结果必然是不完整或者直接崩。分析前建议先看so文件里的版本特征,把样本归类,再决定用什么思路。
第二个坑是反调试对抗。加固so里普遍存在ptrace反调试、时间差检测、文件完整性校验。你以为自己在正常分析,实际上so早就检测到调试环境,故意返回假数据,或者故意触发崩溃。处理办法没有统一公式,只能逐项识别并绕过,这也是为什么模拟执行环境越来越受欢迎——它相对可控,不用跟真实设备上的反调试缠斗。
第三个坑是方法体修复后的验证。指令回填以后,光看jadx能出结构还不够。虚拟机执行时还会做校验,比如CRC32校验、方法长度检查。修复完的DEX如果只过了静态解析器,但运行时校验不过,App一样会崩。我习惯修复后做两轮验证,第一轮用反编译工具确认结构,第二轮在可控环境里跑一次目标流程,确认执行逻辑真实可用。
5.3 个人实践心得
做了几年加固对抗方向的工作,最大的体会是:这类任务拼的不是某个奇技淫巧,而是对两种格式的熟练度和对加固实现路径的预判能力。一旦你能在脑海里把DEX的code_item和ELF的so函数地址关联起来,大部分加固方案在你面前就是透明的。
我也越来越重视自动化,但这里的自动化不是指无脑跑脚本,而是把自己分析过程中的判断逻辑沉淀成可复用的校验工具。比如写一个脚本自动检查DEX header合法性,自动识别被抽取的method列表,自动对比两次dump的insns差异。这些工具能极大节省重复劳动,让你把精力集中在真正需要人脑判断的逆向环节。
最后想说的是,研究加固技术,最忌讳的是“为了破解而去破解”。对加固方案做技术分析,正确的出发点应该是理解和评估安全防护本身——保护自己开发的App、分析恶意软件、审计第三方SDK的安全性。无论是掌握DEX格式还是理解ELF修复,本质上都是二进制分析能力的一部分。这套能力用在正道上,才是它真正的价值所在。