在 ART 下抽取壳的实现
什么是抽取壳
在 Android 安全领域,抽取壳(Extraction Packer) 是一种常见且经典的加固方案。它的核心思想非常直观——把 DEX 文件中每个方法的字节码(机器指令)“抽走”,只留一个空壳。当应用运行时,壳程序再将这些字节码动态地填回原位,使程序能够正常执行。
具体来说,抽取壳会:
- 解析 classes.dex,找到每个方法对应的
code_item结构体 - 提取
code_item中的字节码数据(即 Dalvik 字节码指令序列) - 清零原方法在 DEX 中的
code_off字段,使其指向 0(无效地址) - 将提取的字节码加密存储到 assets 目录或 SO 库中
- 运行时由壳 Application 负责解密并恢复
经过抽取壳处理后的 APK,用 jadx 等反编译工具打开,你会发现所有方法体都是空的——这正是抽取壳最显著的特征。
DEX 文件格式基础
要理解抽取壳的原理,首先需要了解 DEX 文件中与代码存储相关的几个关键数据结构。
method_id_item
method_id_item 是 DEX 文件中的方法索引表,记录了每个方法的声明信息:
method_id_item {
ushort class_idx; // 所属类的类型索引
ushort proto_idx; // 方法原型索引
uint name_idx; // 方法名索引
}
它本身不包含方法体,只是方法的"身份证"。
code_item
code_item 才是真正存放方法字节码的结构体,它被 DEX 文件中的 data 区域引用:
code_item {
ushort registers_size; // 使用的寄存器数量
ushort ins_size; // 方法参数占用的寄存器数
ushort outs_size; // 调用其他方法时需要的参数寄存器数
ushort tries_size; // try-catch 块的数量
uint debug_info_off; // 调试信息偏移
uint insns_size; // 指令条数(以 16-bit code unit 为单位)
ushort insns[insns_size]; // 实际的字节码指令数组
}
其中 insns 字段就是抽取壳的目标——Dalvik 字节码指令序列。
关联关系
在 class_def_item 的 class_data_item 中,每个 encoded_method 都包含一个 code_off 字段,指向该方法的 code_item 在 DEX 文件中的偏移地址:
encoded_method {
uint method_idx_diff; // 方法索引(差值编码)
uint access_flags; // 访问标志(public、static 等)
uint code_off; // 指向 code_item 的偏移量
}
抽取壳的核心操作就是:把 code_off 设为 0,让虚拟机找不到方法体。
抽取壳的实现流程
下面按照 APK 打包阶段和运行时阶段,完整梳理抽取壳的实现流程。
打包阶段:抽取字节码
┌─────────────────────────────────────┐
│ 原始 classes.dex │
│ ┌─────────┐ ┌─────────┐ │
│ │method_A │ │method_B │ ... │
│ │code_item│ │code_item│ │
│ │(完整) │ │(完整) │ │
│ └─────────┘ └─────────┘ │
└──────────────┬──────────────────────┘
│
▼ 加固工具处理
┌─────────────────────────────────────┐
│ 处理后的 classes.dex │
│ ┌─────────┐ ┌─────────┐ │
│ │method_A │ │method_B │ ... │
│ │code_off=0│ │code_off=0│ │
│ │(空壳) │ │(空壳) │ │
│ └─────────┘ └─────────┘ │
└─────────────────────────────────────┘
+
┌─────────────────────────────────────┐
│ 加密的字节码数据 (assets/bin.dat) │
│ [method_A_code] [method_B_code]... │
└─────────────────────────────────────┘
具体步骤如下:
第一步:解析 DEX 文件
读取 classes.dex,解析 DEX 头部获取 string_ids、type_ids、method_ids、class_defs 等各段偏移。
第二步:遍历所有方法的 code_item
通过 class_defs → class_data_item → encoded_method → code_off,定位到每个方法的 code_item。
第三步:提取并存储字节码
将每个 code_item 的完整数据(包括寄存器数量、指令数组等)提取出来,经过加密后存放到 assets 目录或嵌入 SO 文件中。为了运行时能对应恢复,需要记录每个方法与其字节码的映射关系(通常用方法的 dex_method_index 作为 key)。
第四步:清零 code_off
将原 DEX 中每个 encoded_method 的 code_off 字段置 0,这样虚拟机在加载 DEX 时就找不到方法体了。同时,原始 code_item 占用的空间可以被清零或移除以减小体积。
第五步:生成壳 Application
创建一个自定义 Application 类替换原始的 Application,在 attachBaseContext() 中执行运行时修复逻辑。
运行时阶段:动态填充
壳 Application 在 attachBaseContext() 中的工作流程:
public class ShellApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// 1. 读取加密的字节码数据
byte[] encryptedData = readEncryptedCodeFromAssets("bin.dat");
// 2. 解密得到原始字节码映射
Map<Integer, byte[]> methodCodeMap = decryptCodeData(encryptedData);
// 3. 遍历已加载的类,找到被抽取的方法
// 4. 将字节码写回内存中的 code_item,或修改 ArtMethod 的入口
fixMethods(methodCodeMap);
}
}
在 ART 虚拟机下,修复方法有两种主要途径:
方式一:内存修改 code_item
直接在内存中定位被清零的 code_item 区域,将原始字节码写回去。这种方式要求精确计算内存偏移:
// 伪代码:在 native 层修改内存中的 code_item
void fixCodeItem(ArtMethod* method, byte* originalCode, size_t codeSize) {
if (method->GetCodeItem() == nullptr) {
// code_off 为 0,code_item 不存在,需要手动分配
CodeItem* newCodeItem = allocCodeItem(codeSize);
memcpy(newCodeItem->insns_, originalCode, codeSize);
method->SetCodeItem(newCodeItem);
} else {
// code_item 存在但被清零,直接覆盖
memcpy(method->GetCodeItem()->insns_, originalCode, codeSize);
}
}
方式二:修改 ArtMethod 的 entry_point_
ART 虚拟机中,每个 ArtMethod 都有一个 entry_point_from_quick_compiled_code_ 字段,指向方法的机器码入口。壳可以直接修改这个指针,使其指向解密后的代码:
// 伪代码:修改 ArtMethod 的入口点
void hookMethod(ArtMethod* method, void* newEntryPoint) {
// 将 entry_point 指向我们自己解密出来的代码
method->entry_point_from_quick_compiled_code_ =
reinterpret_cast<void*>(newEntryPoint);
}
这种方式更直接,但需要处理好执行模式的切换(解释执行 vs 编译执行)。
Python 示例:读取 DEX 并提取 code_item
下面用一个 Python 脚本演示如何解析 DEX 文件并提取方法的 code_item 数据:
import struct
import os
class DexParser:
def __init__(self, dex_path):
with open(dex_path, 'rb') as f:
self.data = f.read()
self.parse_header()
def parse_header(self):
"""解析 DEX 文件头"""
magic = self.data[0:8]
assert magic.startswith(b'dex\n'), "不是有效的 DEX 文件"
# 使用小端序读取字段
self.string_ids_size = struct.unpack_from('<I', self.data, 0x38)[0]
self.string_ids_off = struct.unpack_from('<I', self.data, 0x3C)[0]
self.method_ids_size = struct.unpack_from('<I', self.data, 0x60)[0]
self.method_ids_off = struct.unpack_from('<I', self.data, 0x64)[0]
self.class_defs_size = struct.unpack_from('<I', self.data, 0x60)[0]
self.class_defs_off = struct.unpack_from('<I', self.data, 0x64)[0]
def read_string(self, string_idx):
"""根据索引读取字符串"""
str_off = struct.unpack_from('<I', self.data,
self.string_ids_off + string_idx * 4)[0]
# ULEB128 编码的长度 + MUTF-8 字符串
size = self.data[str_off]
start = str_off + 1
end = start + size
return self.data[start:end].decode('utf-8', errors='replace')
def extract_code_items(self):
"""提取所有方法的 code_item"""
results = []
class_defs_off = 0x60
class_defs_size = struct.unpack_from('<I', self.data, class_defs_off)[0]
class_defs_off = struct.unpack_from('<I', self.data, class_defs_off + 4)[0]
for i in range(class_defs_size):
offset = class_defs_off + i * 32
class_data_off = struct.unpack_from('<I', self.data, offset + 24)[0]
if class_data_off == 0:
continue
# 解析 class_data_item(简化处理)
pos = class_data_off
static_fields_size = self.read_uleb128(pos); pos = self._next_pos(pos)
instance_fields_size = self.read_uleb128(pos); pos = self._next_pos(pos)
direct_methods_size = self.read_uleb128(pos); pos = self._next_pos(pos)
virtual_methods_size = self.read_uleb128(pos); pos = self._next_pos(pos)
# 跳过字段,处理方法
for _ in range(static_fields_size + instance_fields_size):
pos = self._skip_field(pos)
# 提取 direct methods 的 code_item
for j in range(direct_methods_size):
method_idx_diff, access_flags, code_off, pos = \
self.read_encoded_method(pos)
if code_off != 0:
code_item = self.parse_code_item(code_off)
results.append({
'method_idx': method_idx_diff,
'code_off': code_off,
'insns_size': code_item['insns_size'],
'code_bytes': code_item['insns']
})
# 提取 virtual methods 的 code_item(同上)
for j in range(virtual_methods_size):
method_idx_diff, access_flags, code_off, pos = \
self.read_encoded_method(pos)
if code_off != 0:
code_item = self.parse_code_item(code_off)
results.append({
'method_idx': method_idx_diff,
'code_off': code_off,
'insns_size': code_item['insns_size'],
'code_bytes': code_item['insns']
})
return results
def parse_code_item(self, offset):
"""解析单个 code_item"""
registers_size = struct.unpack_from('<H', self.data, offset)[0]
ins_size = struct.unpack_from('<H', self.data, offset + 2)[0]
outs_size = struct.unpack_from('<H', self.data, offset + 4)[0]
tries_size = struct.unpack_from('<H', self.data, offset + 6)[0]
debug_info_off = struct.unpack_from('<I', self.data, offset + 8)[0]
insns_size = struct.unpack_from('<I', self.data, offset + 12)[0]
insns_start = offset + 16
insns = self.data[insns_start : insns_start + insns_size * 2]
return {
'registers_size': registers_size,
'ins_size': ins_size,
'outs_size': outs_size,
'tries_size': tries_size,
'insns_size': insns_size,
'insns': insns
}
def read_uleb128(self, pos):
result = 0
shift = 0
while True:
byte = self.data[pos]
result |= (byte & 0x7F) << shift
pos += 1
if (byte & 0x80) == 0:
break
shift += 7
return result
def _next_pos(self, pos):
while True:
if (self.data[pos] & 0x80) == 0:
return pos + 1
pos += 1
def _skip_field(self, pos):
pos = self._next_pos(pos) # field_idx_diff
pos = self._next_pos(pos) # access_flags
return pos
def read_encoded_method(self, pos):
method_idx_diff = self.read_uleb128(pos); pos = self._next_pos(pos)
access_flags = self.read_uleb128(pos); pos = self._next_pos(pos)
code_off = self.read_uleb128(pos); pos = self._next_pos(pos)
return method_idx_diff, access_flags, code_off, pos
# 使用示例
if __name__ == '__main__':
parser = DexParser('classes.dex')
items = parser.extract_code_items()
print(f"共提取 {len(items)} 个方法的 code_item")
for item in items[:5]: # 打印前 5 个方法的信息
print(f" method_idx={item['method_idx']}, "
f"code_off=0x{item['code_off']:X}, "
f"insns_size={item['insns_size']}, "
f"code_len={len(item['code_bytes'])} bytes")
这个脚本展示了 DEX 解析的核心流程。在实际的抽取壳工具中,会在此基础上将提取出的 code_item 数据加密存储,并在运行时解密恢复。
如何识别抽取壳
识别一个 APP 是否使用了抽取壳,最简单的方法是使用反编译工具查看:
使用 jadx 检测
jadx -d output/ target.apk
打开反编译后的代码,如果发现大部分方法体为空,如下所示:
public class MainActivity extends AppCompatActivity {
protected void onCreate(Bundle bundle) {
// 方法体为空——被抽取了
}
private void initViews() {
// 方法体为空
}
}
这几乎可以确定使用了抽取壳。
使用 dex2jar + JD-GUI 检测
d2j-dex2jar.sh classes.dex
jd-gui classes-dex2jar.jar
如果反编译结果中方法体只有 return 或完全为空,同样是抽取壳的标志。
其他特征
- DEX 文件异常小:原始 DEX 在去掉所有
code_item后体积会显著减小 - assets 目录有可疑的加密文件:提取的字节码通常以加密形式存放在 assets 中
- 自定义 Application 类:壳需要入口来执行运行时修复
抽取壳的局限性
尽管抽取壳实现简单且一度广泛使用,但它存在明显的局限性:
1. 不能抽取 native 方法
Native 方法(native 关键字修饰)的实现在 SO 库中,不在 DEX 的 code_item 里,因此无法被抽取。
2. 构造函数处理困难
<init> 和 <clinit>(实例初始化和类初始化)方法在类加载阶段就会被调用。如果这些方法也被抽取,壳需要在非常早期的时间点完成修复,否则类加载就会失败。很多抽取壳会跳过这些方法,留下未加固的突破口。
3. 反射调用和动态代理
通过反射可以获取 ArtMethod 对象,壳需要确保在修复完成之前,这些方法不会被反射调用,否则会触发 AbstractMethodError。
4. 脱壳相对容易
相比其他加固方案,抽取壳的脱壳难度较低。常用的脱壳方法包括:
- Frida Hook:Hook
ArtMethod::Invoke,在方法第一次被调用时从解释器缓冲区中 dump 字节码 - 内存 Dump:在壳修复完成后,直接从内存中 dump 完整的 DEX
- dex2oat 分析:如果应用进行了 AOT 编译,可以从 OAT 文件中提取编译后的代码
5. ART 虚拟机的兼容性
不同 Android 版本的 ART 虚拟机在内存布局上有所差异。ArtMethod 的字段偏移、code_item 的内存管理方式都可能变化,壳开发者需要针对不同版本做适配。
总结
抽取壳是 Android 加固技术中最基础的方案之一,其原理并不复杂——将 DEX 中方法的字节码抽离,运行时再动态恢复。理解抽取壳对于逆向工程师来说是基本功,因为:
- 它帮助我们深入理解 DEX 文件格式和 ART 虚拟机的内部机制
- 掌握抽取壳的分析方法后,可以快速识别和绕过这类保护
- 抽取壳的原理是学习更复杂壳技术(如 VMP、Dex 加密壳)的基础
在实际的安全分析工作中,遇到抽取壳不用畏惧。使用 Frida 等工具,配合对 ART 虚拟机的理解,通常可以在较短时间内完成脱壳并获取原始代码。