17、一代壳脱壳实战:整体加固原理、内存dump DEX、修复DEX文件头与结构
各位同学,今天我们来聊点硬核的。一代壳,也就是整体加固,是Android加固领域最基础的形态。虽然现在很多App都上了VMP、Dex2C这些高级玩法,但一代壳依然是入门脱壳的必修课。我个人习惯把一代壳比作「给DEX文件穿了一件外套」——你看到的APK里,真正的DEX被加密或隐藏了,运行时才在内存中还原。
说白了,整体加固的核心就三步:加密原始DEX → 替换为壳DEX → 运行时解密并加载。我们今天要做的,就是抓住它解密后的那个瞬间,把DEX从内存里「捞」出来,再修复好让它能正常分析。
整体加固原理:壳是怎么「骗」过系统的?
先看一张图,理解一下整体加固的完整流程:
流程其实不复杂。加固厂商把原始DEX文件加密,然后塞进一个「壳DEX」里。这个壳DEX本身是一个合法的、可以正常加载的DEX文件,里面包含了解密逻辑。当App启动时,系统先加载壳DEX,壳代码拿到控制权后,在内存中解密原始DEX,再通过DexClassLoader或直接调用openDexFile等底层API来加载真正的业务代码。
关键点:原始DEX一定会在内存中以明文形式存在。这是整体加固的「死穴」——你加密得再好,运行时总得解密。我们要做的,就是在这个时间窗口内把数据捞出来。
内存dump DEX:抓住那个瞬间
怎么捞?最直接的方法就是内存dump。我早期做脱壳时,用过好几种方案,这里给大家梳理一下:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| ddms/hprof dump | 通过DDMS或adb导出heap dump | 操作简单,无需root | 数据量大,DEX可能不完整 |
| frida脚本dump | Hook关键函数,读取内存 | 精准定位,可自动化 | 需要frida环境,部分壳有反frida |
| Xposed模块dump | Hook ClassLoader加载流程 | 稳定可靠 | 需要Xposed框架,兼容性一般 |
| 自定义Loader | 修改系统源码,编译自定义ROM | 最底层,几乎无法检测 | 门槛高,需要刷机 |
我个人最常用的是frida脚本dump。为什么?因为它灵活,而且可以针对不同加固厂商做定制。你想想看,每个壳的解密时机、解密方式都不一样,frida可以让我们动态调整Hook点。
这里分享一个我常用的frida dump脚本核心逻辑:
// frida dump DEX 核心代码片段
function dumpDex() {
// 枚举所有加载的DEX
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
var baseDexClassLoader = Java.use('dalvik.system.BaseDexClassLoader');
var pathListField = baseDexClassLoader.getDeclaredField('pathList');
pathListField.setAccessible(true);
var pathList = pathListField.get(loader);
var dexElementsField = pathList.getClass().getDeclaredField('dexElements');
dexElementsField.setAccessible(true);
var dexElements = dexElementsField.get(pathList);
for (var i = 0; i < dexElements.length; i++) {
var dexElement = dexElements[i];
var dexFileField = dexElement.getClass().getDeclaredField('dexFile');
dexFileField.setAccessible(true);
var dexFile = dexFileField.get(dexElement);
if (dexFile != null) {
var mCookieField = dexFile.getClass().getDeclaredField('mCookie');
mCookieField.setAccessible(true);
var mCookie = mCookieField.get(dexFile);
// 通过cookie读取DEX数据
var dexData = readDexFromCookie(mCookie);
if (dexData != null && dexData.length > 0) {
// 保存到文件
saveToFile('/data/local/tmp/dex_' + i + '.dex', dexData);
}
}
}
} catch(e) {
console.log('Error: ' + e);
}
},
onComplete: function() {}
});
}
小技巧:很多壳会在解密后立即释放或清零内存。我建议在壳的Application.attachBaseContext()或Application.onCreate()之后延迟几百毫秒再dump,成功率会高很多。
修复DEX文件头与结构:让Jadx能读懂
好,DEX dump出来了。但直接扔进Jadx或GDA,大概率报错。为什么?因为内存中的DEX数据可能不完整,或者文件头被篡改了。我曾经遇到过一个壳,它把DEX的checksum和signature字段清零了,导致所有反编译工具都拒绝解析。
修复DEX文件头,说白了就是让DEX文件恢复成一个「合法」的状态。DEX文件头结构如下:
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0x00 | 8 bytes | magic | 固定为"dex\n035\0" |
| 0x08 | 4 bytes | checksum | adler32校验和 |
| 0x0C | 20 bytes | signature | SHA-1签名 |
| 0x20 | 4 bytes | file_size | 文件总大小 |
| 0x24 | 4 bytes | header_size | 固定为0x70 |
| 0x28 | 4 bytes | endian_tag | 0x12345678 |
| 0x2C | 4 bytes | link_size | 链接段大小 |
| 0x30 | 4 bytes | link_off | 链接段偏移 |
| 0x34 | 4 bytes | map_off | map段偏移 |
| 0x38 | 4 bytes | string_ids_size | 字符串ID数量 |
| 0x3C | 4 bytes | string_ids_off | 字符串ID偏移 |
| 0x40 | 4 bytes | type_ids_size | 类型ID数量 |
| 0x44 | 4 bytes | type_ids_off | 类型ID偏移 |
| 0x48 | 4 bytes | proto_ids_size | 原型ID数量 |
| 0x4C | 4 bytes | proto_ids_off | 原型ID偏移 |
| 0x50 | 4 bytes | field_ids_size | 字段ID数量 |
| 0x54 | 4 bytes | field_ids_off | 字段ID偏移 |
| 0x58 | 4 bytes | method_ids_size | 方法ID数量 |
| 0x5C | 4 bytes | method_ids_off | 方法ID偏移 |
| 0x60 | 4 bytes | class_defs_size | 类定义数量 |
| 0x64 | 4 bytes | class_defs_off | 类定义偏移 |
| 0x68 | 4 bytes | data_size | 数据段大小 |
| 0x6C | 4 bytes | data_off | 数据段偏移 |
修复时,我们主要关注几个字段:
- magic:必须是"dex\n035\0",如果不是,直接改回去。
- checksum:用adler32算法重新计算整个文件(从0x0C开始到文件末尾)的校验和。
- signature:用SHA-1重新计算(从0x20开始到文件末尾)。
- file_size:如果dump出来的数据长度不对,需要修正。
- header_size:固定0x70,如果被改了,改回来。
- endian_tag:固定0x12345678。
这里给一个Python修复脚本的核心逻辑:
import hashlib
import zlib
import struct
def fix_dex_header(dex_data):
"""修复DEX文件头"""
# 1. 修复magic
if dex_data[0:4] != b'dex\n':
dex_data = b'dex\n035\0' + dex_data[8:]
# 2. 修复endian_tag
struct.pack_into('<I', dex_data, 0x28, 0x12345678)
# 3. 修复header_size
struct.pack_into('<I', dex_data, 0x24, 0x70)
# 4. 修复file_size(如果已知实际大小)
actual_size = len(dex_data)
struct.pack_into('<I', dex_data, 0x20, actual_size)
# 5. 修复signature (SHA-1)
sha1 = hashlib.sha1(dex_data[0x20:]).digest()
dex_data = dex_data[:0x0C] + sha1 + dex_data[0x20:]
# 6. 修复checksum (adler32)
checksum = zlib.adler32(dex_data[0x0C:]) & 0xFFFFFFFF
struct.pack_into('<I', dex_data, 0x08, checksum)
return dex_data
# 使用示例
with open('dump.dex', 'rb') as f:
data = bytearray(f.read())
fixed_data = fix_dex_header(data)
with open('fixed.dex', 'wb') as f:
f.write(fixed_data)
注意:有些壳会修改DEX的内部数据结构,比如篡改string_ids或class_defs的偏移。这种情况下,光修文件头是不够的,还需要根据DEX的map_off指向的map段来重建索引表。嗯,这个属于进阶内容,我们后面章节会详细讲。
实战中的坑与经验
做一代壳脱壳,有几个坑我踩过,跟大家分享一下:
- dump时机不对:太早,DEX还没解密;太晚,内存已经被释放。我建议用frida的
Stalker或Interceptor来监控DexFile::open或ArtMethod::Invoke等关键函数,精准定位解密点。 - DEX被分片存储:有些壳会把DEX分成多个块,分别加密。dump出来的可能只是其中一块。这时候需要分析壳的加载逻辑,找到所有分片再合并。
- 文件头被深度篡改:比如把
magic改成其他值,或者把header_size改大。修复时不能只改固定偏移,要结合DEX文件格式规范来逐字段校验。 - 反调试/反Hook:很多壳会检测frida、Xposed等工具。我一般会用frida的
--no-pause参数,或者配合frida-gadget注入来绕过检测。
我的建议:刚开始练手时,找一些老版本的加固App(比如360加固3.x、腾讯加固1.x),这些壳的加密逻辑相对简单,适合入门。等熟练了再挑战新版壳。
好了,一代壳脱壳的核心思路就这些。说白了就是:找到解密点 → dump内存 → 修复文件头。每一步都有很多细节,但框架就是这个框架。你动手试几个App,很快就能找到感觉。
最后提醒一句:脱壳技术请用于合法用途,比如分析自己的App、做安全研究。未经授权脱别人的壳,可能会惹上法律麻烦。嗯,这个大家心里有数就好。
公众号:蓝海资料掘金营,微信deep3321