应用安全 --- 安卓安全 之 DEX壳
一个app启动时,启动一个没有任何业务代码、无实质内容的dex壳文件,这个文件一般是加固程序sdk提供的,比如爱加密
这个壳会开始解密 存放在assest文件中的加密了的dex文件 ,之后 加载、执行 这个解密后的DEX文件。这段解密、加载、执行的这段代码一般用c语言编写放置在lib文件夹的so文件中,增加逆向难度。
一、整体架构
┌─────────────────── 加固后的APK结构 ───────────────────┐
│ │
│ classes.dex ← 壳DEX(空壳,无业务代码) │
│ assets/ ← 加密后的原始DEX │
│ │ └── packed.dat (或其他名称,加密存储) │
│ lib/ ← Native层解密引擎 │
│ │ └── armeabi-v7a/ │
│ │ └── libjiagu.so ← C/C++编写的解密+加载逻辑 │
│ AndroidManifest.xml ← Application被替换为壳的入口类 │
│ META-INF/ ← 签名信息 │
│ res/ ← 资源文件(通常不加密) │
│ │
└────────────────────────────────────────────────────────┘
二、启动流程详解
┌──────────────────────────────────────────────────────────────┐
│ APP 启动流程 │
│ │
│ ① Android系统启动APP │
│ │ │
│ ▼ │
│ ② 加载 classes.dex(壳DEX) │
│ │ 壳DEX中只有一个 StubApplication 类 │
│ │ AndroidManifest.xml: │
│ │ android:name="com.shell.StubApplication" │
│ │ │
│ ▼ │
│ ③ StubApplication.attachBaseContext() 执行 │
│ │ │
│ ▼ │
│ ④ System.loadLibrary("jiagu") │
│ │ 加载 lib/libjiagu.so │
│ │ │
│ ▼ │
│ ⑤ SO中的 JNI_OnLoad() 执行 │
│ │ 注册Native方法 │
│ │ 反调试检测 │
│ │ 环境检测(root/模拟器/frida/xposed) │
│ │ │
│ ▼ │
│ ⑥ 调用SO中的解密函数 │
│ │ 读取 assets/packed.dat │
│ │ 在Native层解密(AES/DES/自定义算法) │
│ │ 解密结果 → 原始DEX字节流(在内存中) │
│ │ │
│ ▼ │
│ ⑦ 加载解密后的DEX │
│ │ 方式A: 写入私有目录 → DexClassLoader加载 │
│ │ 方式B: InMemoryDexClassLoader(Android 8.0+) │
│ │ 方式C: 直接操作 DexFile Native接口 │
│ │ │
│ ▼ │
│ ⑧ 替换 ClassLoader │
│ │ 通过反射将APP的ClassLoader替换为新的 │
│ │ 使得后续类加载都从解密后的DEX中查找 │
│ │ │
│ ▼ │
│ ⑨ 替换 Application │
│ │ 反射创建真正的 Application 实例 │
│ │ 调用真正 Application 的 onCreate() │
│ │ │
│ ▼ │
│ ⑩ APP正常运行(业务代码开始执行) │
│ │
└──────────────────────────────────────────────────────────────┘
三、壳DEX代码示例
/**
* 壳DEX中唯一的关键类
* 这就是"没有任何业务代码"的壳
*/
public class StubApplication extends Application {
static {
// ④ 加载Native库
System.loadLibrary("jiagu");
}
// Native方法声明(实现在SO中)
private static native byte[] decryptDex(Context context, String assetName);
private static native void antiDebug();
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// ⑤ 反调试
antiDebug();
// ⑥ 调用SO解密DEX
byte[] dexBytes = decryptDex(base, "packed.dat");
// ⑦ 加载解密后的DEX
loadDecryptedDex(base, dexBytes);
// ⑧ 替换ClassLoader
replaceClassLoader(base);
}
@Override
public void onCreate() {
super.onCreate();
// ⑨ 启动真正的Application
launchRealApplication();
}
// ... 省略具体实现
}
四、SO层解密核心逻辑(C/C++)
// libjiagu.so 中的核心逻辑(简化示意)
// ⑤ JNI_OnLoad - SO加载时自动执行
JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
vm->GetEnv((void**)&env, JNI_VERSION_1_6);
// 注册Native方法
registerNativeMethods(env);
// 反调试:检测 /proc/self/status 中的 TracerPid
anti_debug_check();
// 反Hook检测
detect_frida();
detect_xposed();
return JNI_VERSION_1_6;
}
// ⑥ 核心解密函数
JNIEXPORT jbyteArray JNICALL
native_decryptDex(JNIEnv *env, jclass clazz,
jobject context, jstring assetName) {
// 1. 读取 assets/packed.dat
AAssetManager *mgr = AAssetManager_fromJava(env, assetManager);
AAsset *asset = AAssetManager_open(mgr, "packed.dat", AASSET_MODE_BUFFER);
size_t size = AAsset_getLength(asset);
unsigned char *encrypted = (unsigned char*)AAsset_getBuffer(asset);
// 2. 解密(可能是AES、DES、RC4、自定义算法)
// 密钥可能来自:硬编码、签名计算、服务器下发
unsigned char key[16];
derive_key(key, env, context); // 密钥派生(可能基于APK签名)
unsigned char *decrypted = malloc(size);
// 分散构建的AES密钥(anti-strings技术)
// 而不是直接写 key = "MySecretKey12345"
key[0] = 0x4D; // 'M'
key[1] = 0x79; // 'y'
// ... 逐字节构建
aes_decrypt(encrypted, size, key, decrypted);
// 3. 可能还有解压缩
unsigned char *decompressed = NULL;
size_t decompressed_size = 0;
zlib_decompress(decrypted, size, &decompressed, &decompressed_size);
// 4. 验证DEX魔数
if (memcmp(decompressed, "dex\n035\0", 8) != 0) {
// DEX头校验失败,可能被篡改
abort();
}
// 5. 返回解密后的DEX字节数组
jbyteArray result = env->NewByteArray(decompressed_size);
env->SetByteArrayRegion(result, 0, decompressed_size, decompressed);
// 6. 清除内存中的敏感数据
memset(decrypted, 0, size);
memset(key, 0, 16);
free(decrypted);
free(decompressed);
return result;
}
五、完整对抗流程
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 逆向分析师 vs 加固方案 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 攻(逆向) │ VS │ 防(加固) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ │
│ ═══════════════ 第一层:静态分析对抗 ═══════════════ │
│ │
│ 攻:反编译APK 防:DEX加壳 │
│ │ apktool d app.apk │ classes.dex是空壳 │
│ │ jadx app.apk │ 看不到业务代码 │
│ │ │ │
│ │ 结果:只看到 │ assets/packed.dat │
│ │ StubApplication │ 加密的二进制blob │
│ │ 没有业务逻辑 │ │
│ │ │
│ ═══════════════ 第二层:脱壳 ═══════════════════ │
│ │
│ 攻方案A:内存Dump脱壳 │
│ │ │
│ │ 原理:不管怎么加密,DEX最终必须 │
│ │ 以明文形态加载到内存中运行 │
│ │ │
│ │ ┌────────────────────────────────────┐ │
│ │ │ 时机:解密完成后、加载到内存时 │ │
│ │ │ │ │
│ │ │ 方法1: Frida Hook │ │
│ │ │ Hook DexFile::Open() │ │
│ │ │ Hook InMemoryDexClassLoader │ │
│ │ │ 在DEX加载瞬间dump内存 │ │
│ │ │ │ │
│ │ │ 方法2: /proc/pid/maps + dd │ │
│ │ │ 找到DEX在内存中的映射 │ │
│ │ │ 直接dump对应内存区域 │ │
│ │ │ │ │
│ │ │ 方法3: FART (ART环境脱壳机) │ │
│ │ │ 修改Android源码自动dump │ │
│ │ │ │ │
│ │ │ 方法4: Xposed/Frida 通用脱壳 │ │
│ │ │ FDex2、DexDump等工具 │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ 防方案:对抗脱壳 │
│ │ │ │
│ │ ├─ 反Frida检测 │
│ │ │ 检测frida-server端口(27042) │
│ │ │ 检测/proc/maps中frida相关so │
│ │ │ 检测D-Bus协议通信 │
│ │ │ │
│ │ ├─ 反调试 │
│ │ │ ptrace(PTRACE_TRACEME)自占位 │
│ │ │ 检测TracerPid != 0 │
│ │ │ 时间差检测 │
│ │ │ │
│ │ ├─ 反Root检测 │
│ │ │ 检测su二进制 │
│ │ │ 检测Magisk │
│ │ │ │
│ │ ├─ 环境检测 │
│ │ │ 检测模拟器 │
│ │ │ 检测Xposed框架 │
│ │ │ │
│ │ └─ DEX碎片化加载 │
│ │ 不一次性加载完整DEX │
│ │ 按需解密单个类/方法 │
│ │ │
│ ═══════════════ 第三层:对抗反检测 ════════════════ │
│ │ │
│ │ 攻方案:绕过检测 │
│ │ │ │
│ │ ├─ Frida绕过 │
│ │ │ 重命名frida-server │
│ │ │ 修改默认端口 │
│ │ │ 使用frida-gadget注入 │
│ │ │ Hook检测函数使其返回安全值 │
│ │ │ │
│ │ ├─ 反调试绕过 │
│ │ │ Hook ptrace() 返回0 │
│ │ │ 修改 /proc/self/status │
│ │ │ 内核层patch │
│ │ │ │
│ │ ├─ Root隐藏 │
│ │ │ Magisk Hide / Shamiko │
│ │ │ 挂载命名空间隔离 │
│ │ │ │
│ │ └─ 模拟器伪装 │
│ │ 修改设备指纹 │
│ │ Hook Build类返回值 │
│ │ │
│ ═══════════════ 第四层:SO层对抗 ═══════════════ │
│ │ │
│ │ 防:解密逻辑在SO中(C/C++) │
│ │ │ SO本身也可能被保护: │
│ │ │ ├─ SO加壳(UPX等) │
│ │ │ ├─ 代码混淆(OLLVM) │
│ │ │ ├─ 字符串加密(anti-strings) │
│ │ │ ├─ 控制流平坦化 │
│ │ │ └─ 函数名strip │
│ │ │ │
│ │ 攻:分析SO │
│ │ │ │
│ │ ├─ IDA Pro 静态分析 │
│ │ │ 找到JNI_OnLoad │
│ │ │ 找到解密函数 │
│ │ │ 识别加密算法(AES S-Box特征等) │
│ │ │ │
│ │ ├─ 动态调试SO │
│ │ │ IDA远程调试 │
│ │ │ GDB attach │
│ │ │ 在解密函数处断点 │
│ │ │ │
│ │ └─ Frida Hook SO函数 │
│ │ Interceptor.attach(解密函数地址) │
│ │ 直接读取解密后的返回值 │
│ │ │
│ ═══════════════ 第五层:高级对抗 ═══════════════ │
│ │ │
│ │ 防:VMP(虚拟机保护)/ 指令抽取 │
│ │ │ 将关键Java方法的字节码抽取 │
│ │ │ 替换为Native调用 │
│ │ │ 运行时通过自定义解释器执行 │
│ │ │ 即使dump出DEX,关键方法体也是空的 │
│ │ │ │
│ │ 攻: │
│ │ ├─ FART主动调用脱壳 │
│ │ │ 主动触发每个方法执行 │
│ │ │ 在ART解释器层面dump方法体 │
│ │ │ │
│ │ └─ 分析VMP解释器 │
│ │ 逆向自定义指令集 │
│ │ 编写反汇编器 │
│ │ │
└─────────────────────────────────────────────────────────────────┘
六、具体脱壳实战
方法1:Frida 内存 Dump
// frida -U -f com.target.app -l dump_dex.js
Java.perform(function() {
// Hook 1: DexClassLoader构造函数
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
console.log("[+] DexClassLoader 加载: " + dexPath);
// 读取解密后的DEX文件
var file = new Java.use("java.io.File").$new(dexPath);
var fis = new Java.use("java.io.FileInputStream").$new(file);
var size = file.length();
var buffer = Java.array('byte', new Array(size).fill(0));
fis.read(buffer);
// 保存到可访问目录
var output = new Java.use("java.io.FileOutputStream")
.$new("/data/local/tmp/dumped_" + Date.now() + ".dex");
output.write(buffer);
output.close();
console.log("[+] DEX已dump!");
return this.$init(dexPath, optDir, libPath, parent);
};
// Hook 2: InMemoryDexClassLoader (Android 8.0+)
if (Java.available) {
try {
var InMemoryDex = Java.use("dalvik.system.InMemoryDexClassLoader");
InMemoryDex.$init.overload('java.nio.ByteBuffer', 'java.lang.ClassLoader')
.implementation = function(buf, parent) {
console.log("[+] InMemoryDexClassLoader detected!");
var remaining = buf.remaining();
var bytes = Java.array('byte', new Array(remaining).fill(0));
buf.get(bytes);
buf.position(0); // 重置position
// 写入文件
var fos = new Java.use("java.io.FileOutputStream")
.$new("/data/local/tmp/inmem_" + Date.now() + ".dex");
fos.write(bytes);
fos.close();
console.log("[+] 内存DEX已dump! Size: " + remaining);
return this.$init(buf, parent);
};
} catch(e) {}
}
});
方法2:Hook openDexFile(更底层)
// 直接Hook ART层的DEX打开函数
Interceptor.attach(Module.findExportByName("libart.so",
"_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_"),
{
onEnter: function(args) {
// args[1] = DEX数据指针
// args[2] = DEX大小
var base = args[1];
var size = args[2].toInt32();
console.log("[+] DexFile::OpenMemory called, size: " + size);
// 检查DEX魔数
var magic = Memory.readUtf8String(base, 4);
if (magic === "dex\n") {
// dump DEX
var dexData = Memory.readByteArray(base, size);
var file = new File("/data/local/tmp/art_dump_" + size + ".dex", "wb");
file.write(dexData);
file.close();
console.log("[+] ART层DEX dump成功!");
}
}
});
方法3:通用脱壳(遍历内存搜索DEX)
// 扫描内存中所有DEX文件
function scanMemoryForDex() {
Process.enumerateRanges('r--', {
onMatch: function(range) {
try {
// 搜索DEX魔数: "dex\n035\0" 或 "dex\n037\0"
var results = Memory.scanSync(range.base, range.size,
"64 65 78 0a 30 33"); // "dex\n03"
results.forEach(function(match) {
// 读取DEX头部获取文件大小
var fileSize = Memory.readU32(match.address.add(32));
if (fileSize > 0x70 && fileSize < 0x10000000) {
console.log("[+] Found DEX at: " + match.address +
" Size: " + fileSize);
var dexBytes = Memory.readByteArray(
match.address, fileSize);
var f = new File("/data/local/tmp/scan_" +
match.address + ".dex", "wb");
f.write(dexBytes);
f.close();
}
});
} catch(e) {}
},
onComplete: function() {
console.log("[*] Memory scan complete");
}
});
}
// 延迟执行,等壳解密完成
setTimeout(scanMemoryForDex, 10000);
七、加固方案分级
┌────────────────────────────────────────────────────────────┐
│ 加固强度分级 │
│ │
│ Level 1: 整体加密(一代壳) │
│ ├─ 整个DEX加密存储 │
│ ├─ 运行时整体解密加载 │
│ ├─ 对抗难度: ★☆☆☆☆ │
│ └─ 脱壳方法: 内存dump即可,DEX完整还原 │
│ │
│ Level 2: 类抽取(二代壳) │
│ ├─ DEX中的类数据被抽取 │
│ ├─ 运行时动态回填 │
│ ├─ 对抗难度: ★★★☆☆ │
│ └─ 脱壳方法: Hook defineClass,主动加载所有类后dump │
│ │
│ Level 3: 方法体抽取/指令抽取(三代壳) │
│ ├─ 方法的字节码指令被抽空 │
│ ├─ CodeItem 被置空或替换为Native跳板 │
│ ├─ 运行时才回填指令 │
│ ├─ 对抗难度: ★★★★☆ │
│ └─ 脱壳方法: FART主动调用 + CodeItem dump │
│ │
│ Level 4: VMP虚拟化保护(四代壳) │
│ ├─ 关键方法被编译为自定义指令集 │
│ ├─ 通过自定义虚拟机解释执行 │
│ ├─ 即使dump也无法还原原始逻辑 │
│ ├─ 对抗难度: ★★★★★ │
│ └─ 脱壳方法: 逆向VMP解释器 / trace指令流 │
│ │
│ 商业方案对应: │
│ ├─ 爱加密 → Level 2~3 │
│ ├─ 梆梆安全 → Level 2~4 │
│ ├─ 腾讯乐固 → Level 2~3 │
│ ├─ 360加固 → Level 2~3 │
│ └─ 网易易盾 → Level 2~4 │
│ │
└────────────────────────────────────────────────────────────┘
八、核心哲学
┌──────────────────────────────────────────────────┐
│ │
│ 不管加密多复杂、壳多厚、检测多严格, │
│ 有一个不可打破的事实: │
│ │
│ ┌──────────────────────────────────┐ │
│ │ CPU 只能执行明文指令 │ │
│ │ DEX 最终必须以明文加载到内存 │ │
│ │ 这个瞬间,就是脱壳的窗口 │ │
│ └──────────────────────────────────┘ │
│ │
│ 加固的目标不是"不可破解" │
│ 而是"提高破解成本,使其超过收益" │
│ │
└──────────────────────────────────────────────────┘
免责声明
本文档所有内容仅供安全研究、学术交流与技术学习使用,严禁用于任何未经授权的逆向破解、网络攻击、隐私窃取、恶意软件开发及其他违反《中华人民共和国网络安全法》《数据安全法》等法律法规的行为,使用者应确保已获得目标软件权利人的合法授权并自行承担因使用本文档内容所产生的一切法律责任与后果,作者不对任何直接或间接损害承担任何责任,继续阅读即视为您已知悉并同意上述全部条款。
浙公网安备 33010602011771号