目录
  1. 1. 一、加固壳的原理回顾
    1. 1.1. 一.1 壳的分类体系
    2. 1.2. 一.2 典型壳 Application 代码
    3. 1.3. 一.3 DEX 在 ART 中的加载路径
  2. 2. 二、ZjDroid 脱壳原理
    1. 2.1. 二.1 ZjDroid 架构
    2. 2.2. 二.2 ZjDroid 的关键 Hook 点
    3. 2.3. 二.3 ZjDroid 脱壳工作流
    4. 2.4. 二.4 ZjDroid 核心 Hook 代码逻辑(简化版)
  3. 3. 三、Frida 内存 Dump 技术(现代替代方案)
    1. 3.1. 三.1 方法一:Hook DexFile 构造函数
    2. 3.2. 三.2 方法二:枚举已加载的 ClassLoader 获取 DEX
    3. 3.3. 三.3 方法三:通过 Memory.scan 搜索 DEX 魔数
    4. 3.4. 三.4 方法四:Hook ART 内部函数(更底层)
    5. 3.5. 三.5 Frida 配合 Python 实现自动化脱壳
  4. 4. 四、手动脱壳完整流程
  5. 5. 五、对抗抽取型加固
    1. 5.1. 五.1 抽取型加固的工作原理
    2. 5.2. 五.2 对抗抽取型加固的 Frida 脚本
    3. 5.3. 五.3 Hook ART 的 ClassLinker::DefineClass
  6. 6. 六、对抗 VMP 加固
    1. 6.1. 六.1 VMP 加固原理深入
    2. 6.2. 六.2 VMP 脱壳的可行思路
  7. 7. 七、脱壳技术对抗演进总结
  8. 8. 八、AOSP 相关源码导读
    1. 8.1. 关键源码片段:DexFile::OpenCommon
  9. 9. 面试常考问题
【逆向安全技术-工具篇】脱壳神器ZjDroid

一、加固壳的原理回顾

在深入脱壳之前,先理解壳是怎么保护 DEX 的。

一.1 壳的分类体系

Android 加固技术经历了多代演进,每一代都提升了脱壳的难度:

第一代:整体型加固(DEX 整体加密)
├── 代表:梆梆加固、早期爱加密、早期360加固
├── 原理:整个 DEX 加密存放在 assets/ 或 lib/ 中
│ 壳 Application 在运行时一次性解密全部 DEX
│ 通过 DexClassLoader 或 InMemoryDexClassLoader 加载
├── 脱壳难度:★☆☆☆☆ (一次性 dump 即可)
└── 代表 so:libSecShell.so, libjiagu.so

第二代:抽取型加固(方法指令抽取)
├── 代表:360加固(VMP Pro)、百度加固
├── 原理:DEX 文件结构大部分完整,但每个方法的 code_item
│ 中的指令被抽走(替换为 nop 或跳转到壳的代码)
│ 方法第一次执行时,壳从加密数据中解密出真实指令并回填
├── 脱壳难度:★★★☆☆ (需要主动调用所有方法或 Hook 方法执行)
└── 代表 so:libjiagu.so (VMP), libbaiduprotect.so

第三代:VMP/Dex-to-C 加固(虚拟机保护)
├── 代表:360 VMP、梆梆 VMP、顶象加固
├── 原理:将原始 DEX 的方法体转换为自定义虚拟机的 opcode
│ 运行时由壳自带的 VM 解释器执行
│ 原始 Dalvik 字节码根本不存在于 final APK 中
├── 脱壳难度:★★★★★ (需要逆向分析壳的 VM 解释器)
└── 代表 so:libjiagu_vm.so, libSecShell_vm.so

一.2 典型壳 Application 代码

加固的本质是 DEX 加密存放 + 运行时解密加载。因此脱壳的关键是:在 DEX 解密后、加载前,将其从内存中 dump 出来

// 典型的壳 Application(整体型加固)
public class ShellApplication extends Application {
static {
System.loadLibrary("shell"); // 加载壳的原生库
}

@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);

// 1. 从 assets 或 lib 中读取加密的 DEX 数据
byte[] encryptedDex = readEncryptedDexFromAssets("classes.dat");

// 2. 在 Native 层解密 DEX
byte[] realDex = nativeDecrypt(encryptedDex, encryptedDex.length);

// 3. 解密完成后,可能通过多种方式加载:
// 方式 A:写入私有目录然后通过 DexClassLoader 加载
File dexFile = new File(getDir("dyn_dex", 0), "real.dex");
FileOutputStream fos = new FileOutputStream(dexFile);
fos.write(realDex);
fos.close();

DexClassLoader dcl = new DexClassLoader(
dexFile.getAbsolutePath(),
getDir("opt_dex", 0).getAbsolutePath(),
getApplicationInfo().nativeLibraryDir,
getClassLoader()
);

// 方式 B:通过 InMemoryDexClassLoader (Android 8.0+)
// 直接在内存中加载,不落盘

// 4. 反射替换 LoadedApk 中的 mClassLoader
replaceClassLoader(dcl);

// 5. 调用原始 Application 的 attachBaseContext
invokeOriginalApplication();
}
}

一.3 DEX 在 ART 中的加载路径

理解 DEX 的加载过程是理解脱壳原理的前提:

ART Runtime 中的 DEX 加载路径:

1. DexClassLoader 构造函数
└→ BaseDexClassLoader.<init>(dexPath, ...)
└→ DexPathList.<init>(dexPath, ...)
└→ DexPathList.makeDexElements(...)
└→ DexFile.loadDex(dexPath, ...) ← 入口 1
└→ DexFile.openDexFile(dexPath, ...) ← 入口 2
└→ DexFile::OpenCommon(file, ...) ← 入口 3 (Native)
└→ DexFile::OpenFile(...)
└→ DexFile::DexFile(byte*, size) ← 解析 DEX header
└→ ClassLinker::RegisterDexFile(...)
└→ 类方法注册

2. InMemoryDexClassLoader (Android 8.0+)
└→ DexFile.<init>(ByteBuffer) ← 入口 4
└→ DexFile::OpenMemory(byte*, size) ← 入口 5 (Native)

3. defineClass / loadClass
└→ ClassLinker::DefineClass(...) ← 入口 6
└→ 解析 class_def → 注册到 ClassTable
└→ LoadMethod(...)

脱壳的关键 Hook 点正是这些入口函数。

二、ZjDroid 脱壳原理

ZjDroid(https://github.com/halfkiss/ZjDroid)是早期著名的 Xposed 脱壳模块,核心思路是 Hook ClassLoader 和 DexFile 相关方法

二.1 ZjDroid 架构

ZjDroid 模块结构:
├── AndroidManifest.xml ← 声明为 Xposed 模块
├── assets/xposed_init ← 入口类
├── com/
│ └── zjdroid/
│ ├── MainHook.java ← 主 Hook 入口
│ ├── DexDumper.java ← DEX dump 逻辑
│ ├── PacketHandler.java ← 广播接收处理
│ └── Utils.java ← 辅助工具函数
└── lib/
└── armeabi-v7a/
└── libdump.so ← Native dump 辅助

二.2 ZjDroid 的关键 Hook 点

Hook 点 1: dalvik.system.DexFile.<init>(String)
目的:捕获通过文件路径创建的 DEX 对象
时机:DexClassLoader 构造时

Hook 点 2: dalvik.system.DexFile.<init>(ByteBuffer)
目的:捕获通过内存加载的 DEX 对象(InMemoryDexClassLoader)
时机:Android 8.0+ 的内存 DEX 加载

Hook 点 3: dalvik.system.DexFile.loadClass(String, ClassLoader)
目的:监控类的加载过程
时机:每个类第一次被加载时

Hook 点 4: dalvik.system.BaseDexClassLoader.findClass()
目的:捕获类查找过程
时机:ClassLoader 解析类名时

Hook 点 5: java.lang.Runtime.exec()
目的:监控命令执行(检测反调试行为)
时机:应用执行 shell 命令时

二.3 ZjDroid 脱壳工作流

# 1. 安装 ZjDroid 模块并在 Xposed 中启用
# 2. 重启设备使 Xposed 生效
# 3. 启动目标应用,ZjDroid 自动开始工作
# 4. 通过广播获取 dump 信息
adb shell am broadcast -a com.zjdroid.invoke --ei target pid

# 5. 发送具体操作指令
# 查看已加载的 DEX 信息
adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_dexinfo"}'

# dump 指定 DEX
adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_dex","dexpath":"/data/app/.../base.apk"}'

# dump 所有内存中的 DEX
adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_all"}'

# 收集所有已加载的类列表
adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_class"}'

# 6. 从设备拉取 dump 出的 DEX
adb pull /data/data/com.target.app/files/zjdroid/ ./

# 7. 验证 dump 结果
jadx-gui dumped_dex/classes*.dex

二.4 ZjDroid 核心 Hook 代码逻辑(简化版)

// ZjDroid 的 DexFile Hook 核心逻辑
public class MainHook implements IXposedHookLoadPackage {

@Override
public void handleLoadPackage(LoadPackageParam lpparam) {
// Hook DexFile 构造函数
XposedHelpers.findAndHookConstructor(
"dalvik.system.DexFile",
lpparam.classLoader,
String.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String dexPath = (String) param.args[0];

// 尝试读取并 dump 该 DEX 文件
try {
File dexFile = new File(dexPath);
if (dexFile.exists()) {
byte[] dexData = readFile(dexFile);
saveDexToFile(dexData, "dump_" + dexFile.getName());
XposedBridge.log("[ZjDroid] Dumped: " + dexPath
+ " (" + dexData.length + " bytes)");
}
} catch (Exception e) {
XposedBridge.log("[ZjDroid] Dump failed: " + e.getMessage());
}
}
}
);

// Hook loadClass 监控所有类加载
XposedHelpers.findAndHookMethod(
"dalvik.system.DexFile",
lpparam.classLoader,
"loadClass",
String.class,
ClassLoader.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String className = (String) param.args[0];
// 记录所有被加载的类名(可以后续用于主动调用)
loadedClasses.add(className);
}
}
);
}
}

三、Frida 内存 Dump 技术(现代替代方案)

由于 ZjDroid 已多年未更新(最后支持 Android 4.x-6.x),现代脱壳几乎都使用 Frida。

三.1 方法一:Hook DexFile 构造函数

// frida_dex_dump.js - Hook DexFile init
Java.perform(function() {
var DexFile = Java.use("dalvik.system.DexFile");

// Hook DexFile.<init>(String)
DexFile.$init.overload('java.lang.String').implementation = function(path) {
console.log("[*] DexFile init(String): " + path);

// 尝试从文件路径 dump
try {
var FileInputStream = Java.use("java.io.FileInputStream");
var fis = FileInputStream.$new(path);
var Channel = Java.use("java.nio.channels.FileChannel");
var channel = fis.getChannel();
var ByteBuffer = Java.use("java.nio.ByteBuffer");
var size = channel.size();
var buf = ByteBuffer.allocate(parseInt(size));
channel.read(buf);
channel.close();
fis.close();

var bytes = buf.array();
// 发送到 PC
send({type: "dex", path: path, size: size}, bytes);
} catch(e) {
console.log("[-] Failed to read " + path + ": " + e);
}

return this.$init(path);
};

// Hook DexFile.<init>(ByteBuffer) for InMemoryDexClassLoader
try {
DexFile.$init.overload('java.nio.ByteBuffer').implementation = function(buf) {
console.log("[*] DexFile init(ByteBuffer) - size: " + buf.capacity());
// 从 ByteBuffer 中提取数据
var position = buf.position();
buf.position(0);
var bytes = Java.array('byte', new Array(buf.capacity()));
buf.get(bytes, 0, buf.capacity());
buf.position(position);

send({type: "dex", path: "memory_dex", size: buf.capacity()}, bytes);

return this.$init(buf);
};
} catch(e) {
console.log("[-] ByteBuffer overload not available: " + e);
}
});

三.2 方法二:枚举已加载的 ClassLoader 获取 DEX

// 通过 Java.enumerateLoadedClasses + ClassLoader 遍历
Java.perform(function() {
var PathClassLoader = Java.use("dalvik.system.PathClassLoader");
var DexFile = Java.use("dalvik.system.DexFile");

Java.enumerateLoadedClasses({
onMatch: function(className) {
// 收集所有类名
classList.push(className);
},
onComplete: function() {
console.log("[*] Total loaded classes: " + classList.length);
// 继续处理...
}
});
});

三.3 方法三:通过 Memory.scan 搜索 DEX 魔数

内存扫描是最底层的 dump 方法,不依赖任何 Java 层 API,因此最难被壳检测和对抗:

// 通过搜索 DEX 魔术数 "dex\n035" 来定位内存中的 DEX
var DEX_MAGIC = "64 65 78 0a 30 33 35"; // "dex\n035" 的 hex

function scanAndDumpDex() {
var found = 0;

Process.enumerateRanges('r--').forEach(function(range) {
// 跳过太小的范围
if (range.size < 0x70) return; // DEX header 最小约 0x70 字节

try {
var results = Memory.scanSync(range.base, range.size, DEX_MAGIC);
results.forEach(function(match) {
console.log("[*] DEX magic found at: " + match.address);

// DEX header 结构:
// offset 0x00: magic (8 bytes)
// offset 0x08: checksum (4 bytes)
// offset 0x0C: SHA-1 signature (20 bytes)
// offset 0x20: file_size (4 bytes, 小端)

var fileSize = match.address.add(0x20).readU32();
console.log("[*] DEX size: " + fileSize + " bytes");

// 校验合法性(简单检查:size 不能太大或太小)
if (fileSize > 0x70 && fileSize < 100 * 1024 * 1024) {
// 读取完整 DEX 数据
var dexData = match.address.readByteArray(fileSize);

// 发送到 PC 保存
send({
type: "dex_memory",
address: match.address.toString(),
size: fileSize
}, dexData);

found++;
}
});
} catch(e) {
// 某些内存区域可能无法读取,跳过
}
});

console.log("[*] Total DEX found in memory: " + found);
}

// 注意:某些壳在 dump 后会立即清零已解密的 DEX 内存
// 因此需要在解密完成的瞬间就执行 dump

三.4 方法四:Hook ART 内部函数(更底层)

// Hook ART 内部的 OpenMemory / OpenCommon 函数
// 这些是 C++ 函数,需要 Native Hook(Frida Interceptor)

var openMemoryAddr = Module.findExportByName(
"libart.so", "_ZN3art7DexFile10OpenMemoryEPKhm");

if (openMemoryAddr) {
Interceptor.attach(openMemoryAddr, {
onEnter: function(args) {
this.dexData = args[0]; // 指向 DEX 数据的指针
this.dexSize = args[1].toInt32(); // DEX 大小
console.log("[OpenMemory] DEX data at: " + this.dexData
+ ", size: " + this.dexSize);
},
onLeave: function(retval) {
if (this.dexSize > 0 && this.dexSize < 100 * 1024 * 1024) {
// dump 解密后的 DEX
var data = this.dexData.readByteArray(this.dexSize);
send({type: "dex_art", size: this.dexSize}, data);
}
}
});
}

// OpenCommon 是更通用的入口
var openCommonAddr = Module.findExportByName(
"libart.so", "_ZN3art7DexFile10OpenCommonEPKhmRKNSt3__..."

// 也可以使用符号模糊搜索
var symbols = Module.enumerateSymbolsSync("libart.so");
symbols.forEach(function(sym) {
if (sym.name.indexOf("DexFile") >= 0
&& sym.name.indexOf("Open") >= 0) {
console.log("[*] Found ART symbol: " + sym.name
+ " at " + sym.address);
}
});

三.5 Frida 配合 Python 实现自动化脱壳

#!/usr/bin/env python3
"""
auto_unpacker.py - 自动化 Frida 脱壳脚本
"""
import frida
import sys
import os
import time

PACKAGE_NAME = "com.target.app"
OUTPUT_DIR = "./dumped_dex"

def on_message(message, data):
if message['type'] == 'send':
payload = message['payload']
if payload['type'] == 'dex_memory':
filepath = os.path.join(OUTPUT_DIR,
f"dex_{payload['address']}_{payload['size']}.dex")
with open(filepath, 'wb') as f:
f.write(data)
print(f"[+] Saved: {filepath} ({payload['size']} bytes)")

def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)

# 读取 Frida 脚本
with open('frida_dex_dump.js', 'r') as f:
script_code = f.read()

# Attach 到目标进程
device = frida.get_usb_device()
pid = device.spawn([PACKAGE_NAME])
session = device.attach(pid)

script = session.create_script(script_code)
script.on('message', on_message)
script.load()

# 恢复应用执行
device.resume(pid)

print(f"[*] Attached to {PACKAGE_NAME} (PID: {pid})")
print("[*] Waiting for DEX dumps... (Press Ctrl+C to stop)")

try:
sys.stdin.read()
except KeyboardInterrupt:
print("\n[*] Detaching...")
session.detach()

if __name__ == '__main__':
main()

四、手动脱壳完整流程

针对一个未知壳的通用脱壳流程:

# ===== Step 1:识别加固厂商 =====
# 方法 A:使用 APKiD
git clone https://github.com/rednaga/APKiD
cd APKiD
python apkid.py target.apk

# 输出示例:
# [!] 360加固 → libjiagu.so (libjiagu_vm.so detected → VMP)

# 方法 B:apktool 解包后检查 so 文件
apktool d target.apk -o unpacked
ls unpacked/lib/armeabi-v7a/

# 特征 so 对照表:
# libtup.so → 腾讯乐固 (T-WP)
# libjiagu.so → 360 加固
# libjiagu_vm.so → 360 VMP 加固
# libSecShell.so → 梆梆加固
# libDexHelper.so → 梆梆加固 (辅助)
# libnqshield.so → 网易易盾
# libnaga.so → 娜迦加固
# libegis.so → 通付盾
# libbaiduprotect.so → 百度加固
# libAPKProtect.so → APKProtect
# libprotectClass.so → 几维加固
# libtosprotection.so → 腾讯御安全

# 方法 C:检查壳的 StubApplication
grep -A5 "application" unpacked/AndroidManifest.xml | grep "android:name"
# com.qihoo.util.StubApp3580 → 360 加固
# com.secneo.apkwrapper.AW → 梆梆加固

# ===== Step 2:安装并运行应用 =====
adb install target.apk
adb shell am start -n com.target.app/.MainActivity

# ===== Step 3:Frida attach =====
frida -U com.target.app

# ===== Step 4:在 Frida 控制台中执行 dump 脚本 =====
%load frida_dex_dump.js

# 或者通过命令行直接附加并加载脚本
frida -U -l frida_dex_dump.js com.target.app

# 对于壳有反调试的情况,使用 spawn 模式
frida -U -f com.target.app -l frida_dex_dump.js --no-pause

# ===== Step 5:手动触发所有功能以收集更多 DEX 代码 =====
# 在应用中尽可能多地操作:
# - 登录、浏览所有页面
# - 触发所有按钮
# - 切换所有 Tab
# 目的:让更多的加密方法被解密执行

# ===== Step 6:拉取 dump 的文件 =====
adb pull /sdcard/dumped_dex/ ./

# ===== Step 7:用 JADX 验证 dump 出的 DEX =====
jadx-gui dumped_dex/*.dex

# 判断脱壳质量:
# 若反编译出完整业务逻辑 → 整体型加固 → 脱壳成功
# 若方法体为空或为 nop → 抽取型加固 → 需要额外处理
# 若方法体为奇怪的字节码 → VMP 加固 → 需要逆向 VM 解释器

五、对抗抽取型加固

抽取型加固(如 360 的 VMP)不一次性解密整个 DEX,而是按需解密每个方法的字节码。

五.1 抽取型加固的工作原理

抽取前的 DEX:
class Foo {
void method1() {
0x0000: const/4 v0, 0x1 ← 正常指令
0x0002: invoke-static {v0}, ...
0x0006: return-void
}
void method2() {
0x0000: const-string v0, "bar" ← 正常指令
0x0004: return-object v0
}
}

抽取后的 DEX(打包在 APK 中):
class Foo {
void method1() {
0x0000: invoke-static {}, LStubShell;->decryptMethod(I) ← 跳转到壳
0x0006: return-void
}
void method2() {
0x0000: invoke-static {}, LStubShell;->decryptMethod(I) ← 跳转到壳
0x0004: return-object v0
}
}

运行时 method1() 第一次被调用:
1. decryptMethod(method_id) 从加密数据中取出真实指令
2. 将真实指令回写到 method1() 的 code_item 中
3. 跳转到 method1() 的开头重新执行(此时指令已恢复)

五.2 对抗抽取型加固的 Frida 脚本

// 主动调用所有方法来触发指令恢复
Java.perform(function() {
var classList = [];
var targetClasses = [];

// Step 1: 枚举所有已加载的类
Java.enumerateLoadedClasses({
onMatch: function(className) {
// 过滤系统类
if (!className.startsWith("android.")
&& !className.startsWith("java.")
&& !className.startsWith("javax.")
&& !className.startsWith("dalvik.")
&& !className.startsWith("com.qihoo")
&& !className.startsWith("com.secneo")) {
classList.push(className);
}
},
onComplete: function() {
console.log("[*] Application classes: " + classList.length);

// Step 2: 对每个 class,尝试调用所有方法
classList.forEach(function(className) {
try {
var clazz = Java.use(className);
var methods = clazz.class.getDeclaredMethods();

methods.forEach(function(method) {
try {
method.setAccessible(true);

// 尝试使用默认参数调用
var paramTypes = method.getParameterTypes();
var args = [];
for (var i = 0; i < paramTypes.length; i++) {
args.push(getDefaultValue(paramTypes[i]));
}

// 若是 static 方法直接调用,否则需要实例
if (java.lang.reflect.Modifier.isStatic(
method.getModifiers())) {
method.invoke(null, args);
} else {
// 跳过需要实例的方法(无法自动创建)
}
} catch(e) {
// 忽略调用异常
}
});
} catch(e) {
// 忽略类加载异常
}
});

console.log("[*] Method invocation complete");
console.log("[*] Now run Memory.scan to dump recovered DEX code");
}
});

function getDefaultValue(type) {
if (type.isPrimitive()) {
if (type.getName() == "boolean") return Java.use("java.lang.Boolean").FALSE.value;
if (type.getName() == "int") return 0;
if (type.getName() == "long") return Java.use("java.lang.Long").valueOf(0);
if (type.getName() == "float") return 0.0;
if (type.getName() == "double") return 0.0;
if (type.getName() == "short") return 0;
if (type.getName() == "byte") return 0;
if (type.getName() == "char") return '';
}
return null;
}
});

五.3 Hook ART 的 ClassLinker::DefineClass

对于抽取型加固,Hook ART 内部的类定义函数可以在类加载的第一时间获取信息:

// Hook libart.so 中的 ClassLinker::DefineClass
// 这可以捕获每一个被真正加载的类的详细信息

var defineClassAddr = null;
// 搜索符号(可以使用部分匹配缩小范围)
Module.enumerateSymbolsSync("libart.so").forEach(function(sym) {
if (sym.name.indexOf("DefineClass") >= 0
&& sym.name.indexOf("ClassLinker") >= 0) {
console.log("[*] DefineClass: " + sym.name + " at " + sym.address);
defineClassAddr = sym.address;
}
});

六、对抗 VMP 加固

六.1 VMP 加固原理深入

VMP(Virtual Machine Protection)将所有方法体转化为自定义虚拟机的指令,完全抛弃了 Dalvik 字节码:

原始 Java 代码:
int add(int a, int b) {
return a + b;
}

编译为 Dalvik 字节码:
add-int v0, p1, p2
return v0

VMP 加固后(存储在 DEX 中的 code_item):
[VMP 自定义指令序列]
0x01 0x02 0x03 0x10
0xFF 0x00 0x00 0x00
...

运行时执行流程:
VMP 解释器 (libjiagu_vm.so)
→ 读取 VMP 指令
→ Dispatch (switch-case 分发)
→ 模拟执行
→ 调用真实的 ART API 执行副作用(如 field access)

六.2 VMP 脱壳的可行思路

// 思路 1:Hook VMP 解释器的 Dispatch 函数
// 通过 trace 解释器的执行流,建立 VMP opcode → Dalvik 指令的映射表

// 思路 2:Hook ART 的内部 API 调用
// VMP 最终仍需调用 ART 的 API 来执行 field access、method invoke 等
// Hook 这些 API 可以间接获取程序的行为逻辑

// 示例:Hook SetField / GetField
var setFieldAddr = Module.findExportByName(
"libart.so", "_ZN3art9ArtMethod8SetFieldEjPNS_6ThreadEPNS_9ShadowFrameE");

// 思路 3:通过 trace 恢复算法逻辑
// 使用 Frida Stalker 对 VMP 解释器执行代码覆盖率追踪
// 结合输入/输出对比,推断黑盒算法
Stalker.follow(threadId, {
transform: function(iterator) {
// 记录每条执行的指令
var instruction = iterator.next();
do {
console.log(instruction.address + ": " + instruction.mnemonic);
iterator.keep();
} while ((instruction = iterator.next()) !== null);
}
});

七、脱壳技术对抗演进总结

时间线         壳技术                   脱壳技术
─────────────────────────────────────────────────────────
2012-2014 整体 DEX 加密 代码级 Hook DexFile 构造
简单内存 dump 即可
─────────────────────────────────────────────────────────
2014-2016 分段解密 + 及时清空内存 时序竞态 dump
(DEX解密后立即加载再清空) 通过 Hook ART 底层函数
在解密完成瞬间 dump
─────────────────────────────────────────────────────────
2016-2018 抽取型加固 主动调用触发指令恢复
方法指令按需解密 然后完整 dump
Hook DefineClass 逐方法收集
─────────────────────────────────────────────────────────
2018-2020 VMP 加固 逆向 VM 解释器
完全替换为自定义 opcode 建立 opcode 映射表
Trace + 符号执行
─────────────────────────────────────────────────────────
2020-现在 混合型加固 多技术组合:
整体 + 抽取 + VMP - Frida + Xposed 组合
分段 + 反调试 + 反 Hook - 定制 Kernel 模块
多层嵌套壳 - 硬件辅助 (JTAG/SWD)
- Emulator + Taint Tracking
─────────────────────────────────────────────────────────

八、AOSP 相关源码导读

理解 DEX 加载和类加载的 AOSP 源码是脱壳的基础:

模块 源码路径 关键内容
DexFile.java /libcore/dalvik/src/main/java/dalvik/system/DexFile.java Java 层 DEX 操作入口
DexClassLoader /libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java DEX 类加载器
PathClassLoader /libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java APK 路径类加载器
InMemoryDexClassLoader /libcore/dalvik/src/main/java/dalvik/system/InMemoryDexClassLoader.java 内存 DEX 加载器
BaseDexClassLoader /libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java 类加载器基类
DexFile (Native) /art/libdexfile/dex/dex_file.cc DEX 文件解析 Native 层
dex_file.h /art/libdexfile/dex/dex_file.h DexFile C++ 头文件
ClassLinker /art/runtime/class_linker.cc 类链接器和加载
class_linker.h /art/runtime/class_linker.h DefineClass, LoadMethod

关键源码片段:DexFile::OpenCommon

// art/libdexfile/dex/dex_file.cc (AOSP,简化)
std::unique_ptr<const DexFile> DexFile::OpenCommon(
const uint8_t* base, size_t size,
const std::string& location,
uint32_t location_checksum,
const OatDexFile* oat_dex_file,
bool verify,
bool verify_checksum,
std::string* error_msg) {

// 验证 magic number
if (size < sizeof(DexFile::Header)) {
*error_msg = "DexFile: too small";
return nullptr;
}
const Header* header = reinterpret_cast<const Header*>(base);

// 验证 magic: "dex\n035\0" 或 "dex\n037\0" 等
if (!IsMagicValid(header->magic)) {
*error_msg = "DexFile: invalid magic";
return nullptr;
}

// 这是脱壳的关键点:参数 base 指向的是解密后的 DEX 数据
// 如果在此时 dump base 指向的数据,就能获取解密后的完整 DEX
// 壳通过在 OpenCommon 返回后立即清空 base 指向的内存来对抗 dump
// 因此脱壳必须在 OpenCommon 执行中(通过 inline hook)完成 dump

// ... 后续 DEX 结构解析 ...
}

面试常考问题

Q1:为什么不能直接从 APK 中提取 DEX,必须要从内存 dump?

A:因为加固后的 APK 中,classes.dex 文件是加密的壳代码,真正的业务 DEX 被加密后以各种形式隐藏:(1)嵌入在 assets/ 目录下(如 assets/ijmData.dat);(2)隐藏在 lib/*.so.rodata 段或自定义 section 中;(3)附加在 APK ZIP 文件的尾部(利用 ZIP 格式不校验尾部数据的特性);(4)加密后分段分布在多个文件中。

运行时,壳的 Native 库在内存中解密 DEX,并通过 DexFile 或 InMemoryDexClassLoader 加载到 ART 运行时中。只有在这个”解密完成、尚未加载”的时间窗口内,内存中才存在完整的明文 DEX。因此脱壳必须在运行时进行,且在解密完成的瞬间就完成 dump(部分壳会在加载后立即调用 memset(addr, 0, size) 清空解密缓冲区)。

Q2:抽取型加固和 VMP 加固脱壳的主要难点是什么?

A:

抽取型加固的难点:
(1)内存中永远不会同时存在完整的 DEX:每个方法的指令在第一次执行时才解密,执行后可能被重新加密或清空。
(2)需要主动触发所有方法:对于大型应用,可能有数万个方法,需要设计系统性的主动调用策略。
(3)部分方法在特定条件下才有意义(如网络回调),主动调用时参数难以构造。
(4)壳可能检测异常的调用模式(如短时间内大量方法被调用),触发反 dump 机制。

VMP 加固的难点:
(1)原始 Dalvik 字节码完全不存在:APK 中的方法体存储的是自定义 VM 的 opcode,即使 dump 出 DEX 也无法直接反编译。
(2)需要逆向 VM 解释器:理解壳自带的 VM 指令集、寄存器模型、内存布局,才能将 VMP opcode 翻译回可读的逻辑。
(3)VM 解释器通常经过重度混淆(OLLVM -fla -sub -bcf 三重混淆),逆向工作量大。
(4)解释器可能使用 JIT 技术:将 VMP opcode 动态编译为 Native 代码执行,进一步增加了分析难度。
(5)需要符号执行或 Concolic Execution 来自动化恢复控制流。

核心难点总结:抽取型是”数据收集”问题(如何获取所有代码),VMP 是”语义恢复”问题(如何理解代码的含义)。

Q3:如何检测一个应用使用了哪种加固方案?

A:

(1)静态检测:

  • apktool 解包后检查 lib/ 下的 so 文件特征:

    • libtup.so → 腾讯乐固
    • libjiagu.so / libjiagu_vm.so → 360加固
    • libSecShell.so / libDexHelper.so → 梆梆加固
    • libnqshield.so → 网易易盾
    • libnaga.so → 娜迦(几维)加固
    • libbaiduprotect.so → 百度加固
    • libegis.so → 通付盾
    • libprotectClass.so / libprotectJni.so → 几维加固
    • libtosprotection.so → 腾讯御安全
  • 检查 Manifest 中的壳 Application 类名:

    • com.qihoo.util.StubApplication / com.stub.StubApp → 360
    • com.secneo.apkwrapper.AW → 梆梆
    • com.tencent.StubShell.StubApp → 腾讯乐固
    • com.nqshield.NQApplication → 网易易盾
  • 检查 classes.dex 的内容:壳的 DEX 通常只有几个类(Application、ClassLoader、ShellWrapper 等),体积很小(几千到几十 KB)。使用 dexdump classes.dex | head 查看。

(2)自动化检测:

(3)运行时检测:

  • 检查 /proc/pid/maps 中加载的 so 文件
  • Frida 执行 Module.enumerateModules() 查看已加载模块
  • Hook dlopen 监控壳 so 的加载

Q4:Frida 的 Memory.scan 搜索 DEX 魔数有什么局限性?如何规避?

A:

局限性:
(1)壳可能在 dump 后清空内存:解密 DEX → 加载到 ART → 立即 memset 清零。Memory.scan 执行时明文 DEX 可能已经不存在了。

(2)搜索速度慢:对于大型应用(数百 MB 的 maps 空间),逐范围扫描耗时较长。4GB 的 maps 全体扫描可能需要数分钟。

(3)误报:一些随机数据可能恰好包含 “dex\n035” 字节序列。需要通过进一步的 DEX header 校验(checksum、SHA-1、file_size、string_ids_size 等)过滤。

(4)权限限制:部分内存区域可能无法读取(PROT_NONE 映射)。Process.enumerateRanges('r--') 只包含可读区域,但仍可能遇到 guard pages 导致读取异常。

(5)DEX 可能不完整:壳可能把 DEX 分批加载到不连续的内存区域,单个 DEX header 后面的数据可能不是完整的 DEX。

规避手段:

  • 将 Memory.scan 与 Hook DexFile 构造函数相结合:在 Hook 捕获到 DEX 加载事件时立即执行 Memory.scan,缩小扫描范围。
  • 使用 Frida Stalker 追踪 DEX 相关函数,在函数返回前(解密刚完成)截取数据。
  • 对扫描结果进行严格校验:header.checksum、header.signature (SHA-1)、header.file_size 范围内的内容必须构成有效的 DEX 结构。
  • 使用多轮扫描:应用启动时、操作应用后、应用后台时分别扫描,对比增量变化。

Q5:整体型加固的 DEX dump 为什么有时 dump 出的 DEX 不完整(部分类缺失)?

A:

(1)多 DEX 分片:大型应用可能将业务逻辑分散在多个 DEX 中(classes.dex, classes2.dex, classes3.dex)。壳可能在不同时机解密和加载不同的 DEX(例如首屏 DEX 先加载,其他功能模块的 DEX 按需延迟加载)。如果在应用刚启动时就 dump,只能获取到首屏相关的 DEX。

(2)DEX 在内存中的生命周期:某些壳使用”用完即焚”策略——加载完成后立即释放或覆盖内存中的明文数据。如果 dump 发生在加载完成之后,内存中已无完整数据。

(3)Android 版本差异:不同 Android 版本中 ART 将 DEX 编译为 OAT 后可能不再完整保留原始 DEX 在内存中。例如 Android 7.0+ 的 speed-profile 编译模式下,部分 DEX 方法被 AOT 编译为 oat 后,原始 DEX 的 code_item 可能被清除。

(4)壳的 DEX 分割策略:先进的加固将 DEX 按类或按包切割成多个小块,分别加密存储。运行时分别解密和加载。此时内存中同时存在的只是众多小块,没有”完整 DEX”这个概念。

解决思路:延迟 dump(等应用充分运行)、多次 dump + 合并去重、Hook DefineClass 逐个收集每个类的定义、Hook DEX 解析过程实时 dump 每个加载的 DEX 片段。

打赏
  • 微信
  • 支付宝

评论