目录
  1. 1. 一、加固原理概述
    1. 1.1. 加固技术演进路线图
    2. 1.2. 加固的加载流程(以腾讯乐固为例)
  2. 2. 二、检测加固类型
    1. 2.1. 加固检测脚本
  3. 3. 三、内存 DEX 脱壳
    1. 3.1. 脱壳原理详解
    2. 3.2. 方法一:Frida Dump
    3. 3.3. 方法二:使用 Objection 脱壳
    4. 3.4. 方法三:完整的内存扫描脱壳
    5. 3.5. 方法四:针对方法抽取型加固的主动调用脱壳
    6. 3.6. 脱壳后的验证
  4. 4. 四、对抗反调试
    1. 4.1. 反调试检测技术全景
    2. 4.2. 完整反调试绕过 Frida 脚本
  5. 5. 五、对抗 APK 签名校验
    1. 5.1. 签名校验的多层防御与绕过
  6. 6. 六、处理 Multi-DEX 应用
    1. 6.1. Multi-DEX 加载原理
  7. 7. 七、VMP(虚拟机保护)分析初步
  8. 8. 面试常考问题
【逆向安全技术-实战篇】逆向加固应用

一、加固原理概述

Android 应用加固(壳)的核心思路:将原始 DEX 文件加密后打包进 APK,类加载时由壳代码动态解密 DEX,并通过自定义 ClassLoader 加载到 ART 虚拟机中。常见加固厂商包括:腾讯乐固、360 加固保、梆梆安全、阿里聚安全等。

加固层次分为:DEX 整体加密DEX 方法抽取DEX 指令虚拟化(VMP),难度依次递增。

加固技术演进路线图

┌──────────────────────────────────────────────────────────────┐
│ Android 加固技术发展史 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 第一代:整体 DEX 加密 (2013-2015) │
│ ┌─────────────────────────────────────┐ │
│ │ classes.dex → AES 加密 → 存为 asset │ │
│ │ 壳 Application.attachBaseContext() │ │
│ │ → 解密 asset → 内存加载 DEX │ │
│ │ 防护级别:低(dump 内存即可脱壳) │ │
│ └─────────────────────────────────────┘ │
│ ↓ │
│ 第二代:DEX 分段 + 方法抽取 (2015-2017) │
│ ┌─────────────────────────────────────┐ │
│ │ 将 DEX 中的方法体代码 (code_item) 移除 │ │
│ │ 存为加密数据在 so 或 asset 中 │ │
│ │ 首次调用方法时 → Native 解密 → 回填 │ │
│ │ 防护级别:中(静态 dump 只能得到空方法体)│ │
│ └─────────────────────────────────────┘ │
│ ↓ │
│ 第三代:VMP 代码虚拟化 (2017-至今) │
│ ┌─────────────────────────────────────┐ │
│ │ 将 DEX 字节码转为壳自定义的虚拟机指令 │ │
│ │ 壳自带解释器执行自定义指令集 │ │
│ │ 传统反编译器无法还原原始逻辑 │ │
│ │ 防护级别:高(需要逆向解释器本身) │ │
│ └─────────────────────────────────────┘ │
│ ↓ │
│ 第四代:SO 加固 + 多种手段结合 (2019-至今) │
│ ┌─────────────────────────────────────┐ │
│ │ so 加壳 (UPX-style 压缩+加密) │ │
│ │ Native 层 VMP │ │
│ │ 多层反调试、完整性校验 │ │
│ │ 资源加密、字符串全量混淆 │ │
│ └─────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘

加固的加载流程(以腾讯乐固为例)

[App 启动]

AndroidManifest.xml 中指定壳 Application:
<application android:name="com.wrapper.StubApplication">

StubApplication.attachBaseContext()

加载 Native 壳 SO (libtup.so)

解密原始 DEX(AES/XXTEA 解密算法)

创建自定义 ClassLoader

通过反射替换系统的 ClassLoader (LoadedApk.mClassLoader)

反射调用原始 Application.attachBaseContext()

继续正常的 Activity 启动流程

二、检测加固类型

# 1. 解包 APK,观察结构
apktool d target.apk -o unpacked
ls unpacked/lib/armeabi-v7a/

# 常见特征文件:
# libtup.so → 腾讯乐固(Tencent Legu)
# libjiagu.so → 360 加固保
# libSecShell.so → 梆梆安全
# libDexHelper.so → 顶象安全
# libexec.so → 爱加密
# libprotectClass.so → 阿里聚安全
# libbaiduprotect.so → 百度加固
# libnqshield.so → 网秦加固
# libshell.so → 通付盾
# libtianyu.so → 几维安全 (KiwiVM)
# 2. 检查 classes.dex 大小
# 如果 classes.dex 只有几十 KB,大概率是壳的 DEX
ls -la target/classes.dex

# 3. 查看 Manifest,壳通常会添加自己的组件
grep -r "StubShell\|StubApp\|Wrapper" unpacked/AndroidManifest.xml

# 4. 检查是否有壳的特征 Application 类
grep "StubApplication\|ProxyApplication\|WrapperApplication" \
unpacked/AndroidManifest.xml

# 5. 检查 so 文件的段是否异常(加固 so 常见特征)
readelf -S unpacked/lib/armeabi-v7a/libtup.so | grep -E "UPX|\.shell|\.wrapper|\.packed"

# 6. 使用专业的加固检测工具
# APKiD: https://github.com/rednaga/APKiD
apkid target.apk
# 输出示例:
# [!] 360加固 → lib/armeabi/libjiagu.so
# [!] packed → classes.dex suspicious size

加固检测脚本

#!/usr/bin/env python3
"""自动检测 APK 加固类型"""

import zipfile
import sys
import re

SHELL_SIGNATURES = {
'腾讯乐固': ['libtup.so', 'libshella-*.so', 'libshellx-*.so', 'mix.dex', 'mixz.dex'],
'360加固保': ['libjiagu.so', 'libjiagu_a64.so', 'libjiagu_x86.so', 'libjiagu_x64.so'],
'梆梆安全': ['libSecShell.so', 'libSecShell-x86.so', 'libDexHelper.so'],
'爱加密': ['libexec.so', 'libexecmain.so', 'ijm_lib/armeabi/libexec.so'],
'阿里聚安全': ['libmobisec.so', 'libmobisecyun.so', 'libsgmain.so', 'libsgsecuritybody.so'],
'顶象安全': ['libDexHelper.so', 'libDexHelper-x86.so'],
'百度加固': ['libbaiduprotect.so'],
'几维安全': ['libkwscmm.so', 'libkwscr.so', 'libtianyu.so'],
'通付盾': ['libshell.so', 'libshell_x86.so'],
'网易易盾': ['libnesec.so', 'libnesec_art.so'],
'娜迦加固': ['libddog.so', 'libedog.so'],
}

def detect_shell(apk_path):
"""检测 APK 使用的加固方案"""
detected = []
try:
with zipfile.ZipFile(apk_path, 'r') as zf:
file_list = zf.namelist()

for vendor, signatures in SHELL_SIGNATURES.items():
for sig in signatures:
pattern = re.compile(sig.replace('*', '.*').replace('.', r'\.'))
for f in file_list:
if pattern.search(f):
detected.append((vendor, f))
break

# 检查 classes.dex 大小(过小可能是壳)
try:
dex_info = zf.getinfo('classes.dex')
if dex_info.file_size < 50 * 1024: # < 50KB
detected.append(('UNKNOWN_SHELL',
f'classes.dex size: {dex_info.file_size} bytes (suspicious)'))
except KeyError:
pass

# 检查 Application 类名
for f in file_list:
if 'AndroidManifest.xml' in f:
manifest = zf.read(f).decode('utf-8', errors='ignore')
suspicious_apps = ['StubApplication', 'ProxyApplication',
'WrapperApplication', 'ShellApplication']
for sa in suspicious_apps:
if sa in manifest:
detected.append(('SUSPICIOUS_APPLICATION',
f'Application class: {sa}'))
except Exception as e:
print(f"Error: {e}")
return []

return detected

if __name__ == '__main__':
if len(sys.argv) > 1:
results = detect_shell(sys.argv[1])
for vendor, detail in results:
print(f"[+] {vendor}: {detail}")
else:
print("Usage: python detect_shell.py <target.apk>")

三、内存 DEX 脱壳

加固的 DEX 最终必须加载到内存才能执行。利用此特点,在运行时从内存中 Dump 解密后的 DEX。

脱壳原理详解

┌──────────────────────────────────────────────────┐
│ 内存 DEX 脱壳原理 │
├──────────────────────────────────────────────────┤
│ │
│ ART 加载 DEX 的过程: │
│ │
│ DexFile::DexFile() │
│ ↓ │
│ DexFile::OpenFile() 或 DexFile::OpenMemory() │
│ ↓ │
│ 创建 DexFile 对象,解析 DEX Header: │
│ - magic: "dex\n035\0" │
│ - file_size: 完整 DEX 大小 │
│ - data_size: 数据段大小 │
│ ↓ │
│ DexFile 对象存储在内存中,其起始地址 │
│ 向前偏移可找到完整的 DEX 文件数据 │
│ │
│ 脱壳的关键时间点: │
│ 1. DexFile 构造函数完成后(DEX 已解密) │
│ 2. defineClass 将类加载到 ClassTable 之前 │
│ 3. 任何时机:扫描内存中的 DEX magic 头 │
│ "dex\n035\0" 或 "dex\n037\0" 或 "dex\n038\0" │
│ (分别对应 Android 4-7, 8, 9+) │
│ │
└──────────────────────────────────────────────────┘

方法一:Frida Dump

// frida_dex_dump.js — 增强版 Dex Dump 脚本
// 原理:Hook ClassLoader.loadClass 和 DexFile 构造函数

var dex_files = {};

// 方法1:Hook DexFile 构造函数获取文件路径
Java.perform(function() {
var DexFile = Java.use("dalvik.system.DexFile");

// Hook 构造方法(从文件路径加载)
DexFile.$init.overload('java.lang.String').implementation = function(path) {
console.log("[+] DexFile loaded from: " + path);
var result = this.$init(path);
dex_files[path] = this;
return result;
};

// Hook 构造方法(从 ByteBuffer 加载 — 加固壳常用)
DexFile.$init.overload('java.nio.ByteBuffer').implementation = function(buf) {
console.log("[+] DexFile loaded from ByteBuffer, size: " + buf.limit());

// 尝试从 ByteBuffer 定位 DEX 数据
try {
var bufferClass = Java.use("java.nio.Buffer");
var address = bufferClass.address.call(buf);
console.log("[+] ByteBuffer address: " + address);

// 从内存读取 DEX magic
var magic = Memory.readByteArray(ptr(address), 8);
console.log("[+] Magic: " + hexdump(magic, { offset: 0, length: 8, header: false }));
} catch(e) {
console.log("[-] Failed to read ByteBuffer: " + e);
}

return this.$init(buf);
};
});

// 方法2:Hook BaseDexClassLoader 获取所有已加载的 DEX 信息
Java.perform(function() {
var BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");
var pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
pathListField.setAccessible(true);

var DexPathList = Java.use("dalvik.system.DexPathList");
var dexElementsField = DexPathList.class.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);

var Element = Java.use("dalvik.system.DexPathList$Element");
var dexFileField = Element.class.getDeclaredField("dexFile");
dexFileField.setAccessible(true);

function listDexFiles() {
var classLoader = Java.classFactory.loader;
// 遍历 ClassLoader 链
while (classLoader) {
if (classLoader.$className === "dalvik.system.BaseDexClassLoader" ||
classLoader.$className === "dalvik.system.PathClassLoader" ||
classLoader.$className === "dalvik.system.DexClassLoader") {
var pathList = pathListField.get(classLoader);
var elements = dexElementsField.get(pathList);
for (var i = 0; i < elements.length; i++) {
var dexFile = dexFileField.get(elements[i]);
if (dexFile) {
console.log("[*] Found DexFile: " + dexFile);
}
}
}
classLoader = classLoader.getParent();
}
}

listDexFiles();
});
frida -U -f com.target.app -l frida_dex_dump.js --no-pause

方法二:使用 Objection 脱壳

objection -g com.target.app explore

# 在 objection 终端中
# 1. 列出所有已加载类
android hooking list classes

# 2. 搜索 DexFile 实例
android heap search instances dalvik.system.DexFile

# 3. 使用内置的 dexdump 插件
plugin load /usr/share/objection/plugins/Wallbreaker
plugin wallbreaker classdump --fullname com.example.MainActivity

# 4. 扫描内存中的 DEX
android heap execute 0x1234 # 对可疑地址 dump

方法三:完整的内存扫描脱壳

#!/usr/bin/env python3
# dexdump_memory.py
# 通过 Frida 扫描进程内存,寻找并 dump 所有 DEX 文件

import frida
import sys
import struct

DEX_SCRIPT = """
var DEX_MAGICS = [
[0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00], // dex\\n035
[0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x37, 0x00], // dex\\n037
[0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x38, 0x00], // dex\\n038
[0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x39, 0x00], // dex\\n039
];

function scanAndDump(filename) {
var modules = Process.enumerateRanges({protection: 'r--', coalesce: true});

modules.forEach(function(range) {
var size = range.size;
if (size < 1024) return;

try {
var data = Memory.readByteArray(range.base, Math.min(size, 100 * 1024 * 1024));
var arr = new Uint8Array(data);

for (var i = 0; i < arr.length - 8; i++) {
var matched = false;
for (var m = 0; m < DEX_MAGICS.length; m++) {
var match = true;
for (var j = 0; j < 8; j++) {
if (arr[i + j] !== DEX_MAGICS[m][j]) {
match = false;
break;
}
}
if (match) {
matched = true;
break;
}
}

if (matched) {
// 读取 file_size (offset 32, 4 bytes, little-endian)
var fileSize = arr[i + 32] | (arr[i + 33] << 8) |
(arr[i + 34] << 16) | (arr[i + 35] << 24);

console.log("[+] Found DEX at " + range.base.add(i) +
", size: " + fileSize);

var dexData = Memory.readByteArray(range.base.add(i), fileSize);
send({
type: 'dex',
address: range.base.add(i).toString(),
size: fileSize
}, dexData);

i += fileSize - 1; // 跳过当前 DEX
}
}
} catch(e) {
// 不可读区域,跳过
}
});
}

rpc.exports = {
scan: scanAndDump
};
"""

def on_message(message, data):
if message['type'] == 'send':
payload = message['payload']
if payload.get('type') == 'dex':
filename = f"dump_{payload['address']}_{payload['size']}.dex"
with open(filename, 'wb') as f:
f.write(data)
print(f"[+] Saved: {filename}")

def main():
if len(sys.argv) < 2:
print("Usage: python dexdump_memory.py <package_name>")
sys.exit(1)

device = frida.get_usb_device()
pid = device.spawn([sys.argv[1]])
session = device.attach(pid)

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

device.resume(pid)
script.exports.scan("output")

input("Press Enter to stop...")
session.detach()

if __name__ == '__main__':
main()

方法四:针对方法抽取型加固的主动调用脱壳

// active_load_dex.js
// 主动触发所有未加载的方法,迫使壳回填方法体
// 原理:方法抽取型加固在方法首次调用时才解密回填 code_item
// 主动遍历所有类的方法并执行,可触发全部解密

Java.perform(function() {
// 获取 Runtime 中所有已加载的类
var Class = Java.use("java.lang.Class");
var Method = Java.use("java.lang.reflect.Method");

// 获取 ClassLoader
var ActivityThread = Java.use("android.app.ActivityThread");
var currentApplication = ActivityThread.currentApplication();
var classLoader = currentApplication.getClassLoader();

// 遍历 DexFile 中的类
var DexFile = Java.use("dalvik.system.DexFile");
// ... 获取 DexFile 实例,遍历类名 ...

// 对每个类,获取其所有方法并主动调用
function activeInvoke(dexFile) {
var entries = dexFile.entries();
while (entries.hasMoreElements()) {
var className = entries.nextElement();
try {
var clazz = classLoader.loadClass(className);
var methods = clazz.getDeclaredMethods();

for (var i = 0; i < methods.length; i++) {
try {
methods[i].setAccessible(true);
// 准备默认参数
var paramTypes = methods[i].getParameterTypes();
var args = [];
for (var j = 0; j < paramTypes.length; j++) {
args.push(getDefaultValue(paramTypes[j]));
}

if (Modifier.isStatic(methods[i].getModifiers())) {
methods[i].invoke(null, args);
}
console.log("[+] Invoked: " + className + "." + methods[i].getName());
} catch(e) {
// 忽略调用错误
}
}
} catch(e) {
// 忽略类加载错误
}
}
}

function getDefaultValue(type) {
// 返回类型的默认值
if (type == int.class) return Java.int(0);
if (type == long.class) return Java.long(0);
if (type == boolean.class) return Java.boolean(false);
if (type == float.class) return Java.float(0.0);
if (type == double.class) return Java.double(0.0);
if (type == byte.class) return Java.byte(0);
if (type == short.class) return Java.short(0);
if (type == char.class) return Java.char('\0');
return null;
}
});

注意:主动调用脱壳需要处理参数类型匹配、构造函数的对象实例化、类初始化顺序等问题。对于加固应用,类初始化器中可能有反调试检测,需要先用 Frida 绕过。

脱壳后的验证

# 验证 dump 出的 DEX 是否完整
dexdump -d dump_0x7a1234000_1234567.dex | head -20

# 用 JADX 尝试打开
jadx-gui dump_0x7a1234000_1234567.dex

# 检查 DEX header
xxd dump_0x7a1234000_1234567.dex | head -4
# 输出应包含: 6465 780a 3033 38 (dex\n038)

四、对抗反调试

加固应用通常会检测调试状态:

# /proc/self/status 中的 TracerPid 为非 0 表示正在被调试
adb shell cat /proc/$(adb shell pidof com.target.app)/status | grep TracerPid

反调试检测技术全景

┌──────────────────────────────────────────────────────┐
│ Android 反调试检测技术分类 │
├──────────────────────────────────────────────────────┤
│ │
│ 1. ptrace 检测 │
│ - ptrace(PTRACE_TRACEME) → 失败 = 已被调试 │
│ - 定时检查 (fork 子进程循环检测) │
│ │
│ 2. /proc 文件系统检测 │
│ - /proc/self/status → TracerPid != 0 │
│ - /proc/self/stat → 调试标志 │
│ - /proc/self/wchan → "ptrace_stop" │
│ - /proc/self/task/<tid>/stat │
│ │
│ 3. 调试器端口扫描 │
│ - netstat 扫描 23946 (IDA) │
│ - netstat 扫描 27042 (Frida) │
│ - 读取 /proc/net/tcp 检查连接 │
│ │
│ 4. 断点指令检测 │
│ - CRC/MD5 校验代码段 │
│ - 搜索 BRK #0 指令 │
│ - 检查代码是否被 0xCC (x86) 或软件断点修改 │
│ │
│ 5. 环境特征检测 │
│ - /system/app/ 中是否存在 Superuser.apk │
│ - selinux 是否处于 Permissive 模式 │
│ - ro.debuggable 系统属性 = 1 │
│ - /system/bin/su 是否存在 │
│ - /system/xbin/su 是否存在 │
│ │
│ 6. Frida 专项检测 │
│ - 扫描 /proc/self/maps 中的 frida/ 字符串 │
│ - 检查默认端口 27042 │
│ - D-Bus 通信检测(Frida 使用 D-Bus 协议) │
│ - 检查 so 列表中是否有 frida-agent 相关的 │
│ - 遍历线程名查找 "frida" │
│ │
│ 7. IDA 专项检测 │
│ - 检查 android_server 进程 │
│ - 检测 23946 端口 │
│ │
│ 8. 时间检测 │
│ - 测量代码块执行时间 → 异常长的执行时间 = 单步调试 │
│ - clock_gettime(CLOCK_MONOTONIC) 前后对比 │
│ │
└──────────────────────────────────────────────────────┘

对抗 ptrace 反调试的方法:

// Frida 脚本:Hook ptrace 调用
Interceptor.attach(Module.findExportByName("libc.so", "ptrace"), {
onEnter: function(args) {
// 检查是否是反调试检测
if (args[0].toInt32() == 0) { // PTRACE_TRACEME
console.log("[*] ptrace(PTRACE_TRACEME) called, bypassing...");
}
},
onLeave: function(retval) {
retval.replace(0); // 返回 0 表示成功,绕过检测
}
});

对抗 TracerPid 检测:直接 patch /proc/self/status 的读取结果,或使用 MagiskHide 隐藏调试状态。

完整反调试绕过 Frida 脚本

// universal_anti_anti_debug.js
// 综合绕过常见反调试检测

var LIBC = "libc.so";

// 1. 绕过 ptrace
var ptrace = Module.findExportByName(LIBC, "ptrace");
if (ptrace) {
Interceptor.attach(ptrace, {
onEnter: function(args) {
if (args[0].toInt32() === 0) { // PTRACE_TRACEME
this.bypass = true;
}
},
onLeave: function(retval) {
if (this.bypass) {
retval.replace(0);
}
}
});
}

// 2. 绕过 fopen/proc/self/status 读取
var fopen = Module.findExportByName(LIBC, "fopen");
Interceptor.attach(fopen, {
onEnter: function(args) {
var path = Memory.readUtf8String(args[0]);
if (path && path.indexOf("/proc/") !== -1 &&
(path.indexOf("status") !== -1 || path.indexOf("stat") !== -1)) {
console.log("[*] fopen hooked: " + path);
this.hooked = true;
}
}
});

// 3. 绕过 strstr 检测 (Frida 特征字符串)
var strstr = Module.findExportByName(LIBC, "strstr");
Interceptor.attach(strstr, {
onEnter: function(args) {
try {
var haystack = Memory.readUtf8String(args[0]);
var needle = Memory.readUtf8String(args[1]);
if (needle && (needle.indexOf("frida") !== -1 ||
needle.indexOf("gum-js") !== -1 ||
needle.indexOf("linjector") !== -1)) {
console.log("[*] strstr anti-frida: " + needle);
this.bypass = true;
}
} catch(e) {}
},
onLeave: function(retval) {
if (this.bypass) {
retval.replace(ptr(0)); // NULL = 未找到
}
}
});

// 4. 绕过 pthread_create (阻止反调试线程创建)
var pthread_create = Module.findExportByName(LIBC, "pthread_create");
Interceptor.attach(pthread_create, {
onEnter: function(args) {
var startRoutine = args[2];
// 检查线程入口是否在可疑的 so 中
var module = Process.findModuleByAddress(startRoutine);
if (module) {
console.log("[*] New thread: " + module.name + " + " +
startRoutine.sub(module.base));
}
}
});

// 5. 绕过 exit / _exit / abort (防止反调试检测后自杀)
["exit", "_exit", "abort"].forEach(function(funcName) {
var func = Module.findExportByName(LIBC, funcName);
if (func) {
Interceptor.attach(func, {
onEnter: function(args) {
console.log("[!] " + funcName + "(" + args[0] + ") blocked!");
// 不退出,等待人工分析
while(true) {
Thread.sleep(1);
}
}
});
}
});

console.log("[+] Anti-anti-debug hooks installed!");

五、对抗 APK 签名校验

加固应用常调用 PackageManager.getPackageInfo() 校验 APK 签名:

// Frida Hook 签名校验
var pkgMgr = Java.use("android.content.pm.PackageManager");
pkgMgr.getPackageInfo.implementation = function(pkg, flags) {
console.log("[*] getPackageInfo called for: " + pkg);
// 返回原始签名信息,绕过校验
return this.getPackageInfo(pkg, flags);
};

签名校验的多层防御与绕过

┌─────────────────────────────────────────────────────┐
│ 签名校验的多层防护结构 │
├─────────────────────────────────────────────────────┤
│ │
│ Layer 1: Java 层签名校验 │
│ ├─ PackageManager.getPackageInfo(pkg, GET_SIGNATURES)│
│ └─ 绕过:Frida Hook 返回原始签名 │
│ │
│ Layer 2: Native 层签名校验 │
│ ├─ 通过 JNI 调用 PackageManager API │
│ ├─ 直接读取 META-INF/*.RSA 文件手动解析 │
│ ├─ 调用 Java Signature API 验证 │
│ └─ 绕过:Hook JNI GetMethodID/CallObjectMethod │
│ Hook libc open/read 拦截证书文件读取 │
│ IDA 中 Patch 比较指令 │
│ │
│ Layer 3: SO 自身完整性校验 │
│ ├─ CRC32 / MD5 / SHA256 of .text section │
│ ├─ 对比硬编码在 .rodata 中的预期哈希 │
│ └─ 绕过:Hook 最终比较逻辑 (strcmp/memcmp) │
│ 修改 .rodata 中的哈希值为实际值 │
│ │
│ Layer 4: 服务器端签名校验 │
│ ├─ 将签名信息发往服务器验证 │
│ └─ 绕过:Hook 网络请求,替换请求体或响应体 │
│ (难度高,服务器逻辑不可控) │
│ │
└─────────────────────────────────────────────────────┘

六、处理 Multi-DEX 应用

加固后的应用通常包含多个 DEX:

# JADX 打开所有 DEX
jadx-gui classes.dex classes2.dex classes3.dex

# 将 DEX 合并
java -jar dx.jar --dex --output=merged.dex *.dex

Multi-DEX 加载原理

// Android 5.0+ Multi-DEX 加载流程(MultiDex.install)
public static void install(Context context) {
// 1. 获取主 DEX 文件
File sourceApk = new File(context.getApplicationInfo().sourceDir);

// 2. 获取辅 DEX 目录
File dexDir = new File(context.getDataDir(), "secondary-dexes");

// 3. 加载所有 DEX 文件
// classes.dex → 主 DEX(包含 Application 和 MultiDex 类)
// classes2.dex, classes3.dex ... → 辅 DEX

// 4. 获取当前的 ClassLoader
ClassLoader loader = context.getClassLoader();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
// 4.1 通过反射修改 pathList.dexElements
Object dexPathList = getField(loader, "pathList");
Object[] existingElements =
(Object[]) getField(dexPathList, "dexElements");
Object[] newElements = makeDexElements(dexFiles, optimizedDir);
Object[] combined = combineArray(existingElements, newElements);
setField(dexPathList, "dexElements", combined);
}
}

七、VMP(虚拟机保护)分析初步

VMP 是当前最难以分析的保护方式:

┌───────────────────────────────────────────────┐
│ VMP 保护原理与分析思路 │
├───────────────────────────────────────────────┤
│ │
│ 原始 DEX 字节码: │
│ const/4 v0, 0x1 │
│ invoke-static {v0}, LClass;->method(I)V │
│ if-eqz v0, :label │
│ │
│ VMP 转换后: │
│ .byte 0xA3 0x01 0x00 │
│ .byte 0xB7 0x12 0x34 │
│ .byte 0xC2 0x05 │
│ (自定义指令集格式) │
│ │
│ 运行时解释器: │
│ while (true) { │
│ opcode = *ip++; │
│ switch (opcode) { │
│ case 0xA3: // MOV_CONST │
│ reg = *ip++; │
│ value = *(int*)ip; ip += 4; │
│ regfile[reg] = value; │
│ break; │
│ case 0xB7: // INVOKE_STATIC │
│ ... │
│ } │
│ } │
│ │
│ 分析策略: │
│ 1. 定位解释器主循环 (while + switch 结构) │
│ 2. 逆向每个 opcode handler 的语义 │
│ 3. 编写专用的反汇编器/反编译器 │
│ 4. 使用 Symbolic Execution 辅助分析 │
│ 5. 使用 Trace 工具记录指令序列 │
│ │
└───────────────────────────────────────────────┘

面试常考问题

Q1:Frida 脱壳的原理是什么?被检测到怎么办?

A:Frida 脱壳利用应用运行时 DEX 已在内存中解密的特点,通过 Hook ClassLoader 或直接遍历内存将 DEX dump 出来。核心原理是:无论加固多复杂,DEX 在执行前必然在内存中处于明文状态。脱壳时机包括:DexFile 构造函数完成后、ClassLoader.loadClass 执行前后、或主动扫描内存中的 DEX magic(dex\n035/037/038)。被检测的对抗措施:(1) 使用 Frida 的 anti-detection 脚本(hook strstr 检测 frida 特征字符串、hook fopen 检测 /proc/self/maps 读取);(2) 使用改名的 Frida Server(如 hluda-server),修改默认端口、进程名和 D-Bus 标识;(3) 编译自定义 Frida gadget 嵌入 APK,使用进程内注入而非远程调试协议;(4) 使用 Magisk 的 Zygisk 模块加载 Frida gadget,躲过常规检测。

Q2:什么是 VMP(虚拟化保护)?如何分析?

A:VMP 将 DEX 字节码转换为自定义的虚拟机指令,运行时由壳自带的解释器执行。传统反编译工具无法还原。分析需要逆向壳本身的虚拟机解释器,理解其指令编码格式,编写专用的反汇编器。具体步骤:(1) 在 IDA 中定位解释器的主循环——通常是大 while(true) + 巨型 switch 结构;(2) 逆向每个 case 对应的指令 handler,记录语义(如 0xA3 = MOV_REG_CONST、0xB7 = INVOKE_STATIC);(3) 构建指令集手册;(4) 编写 Python 脚本读取加密的 DEX section,按照指令集手册反汇编;(5) 对反汇编后的代码进行控制流分析和数据流分析,尝试还原出高级逻辑。辅助手段:使用 Frida Stalker 记录所有执行的指令 trace,分析热点路径;使用符号执行引擎(如 angr)对解释器进行符号执行,推导某段 VMP 代码的实际语义。

Q3:加固应用如何做 so 层分析?

A:先用工具(如 Frida 的 dlopen Hook)确认 so 文件被加载到哪个内存地址,然后使用 IDA Pro 的 attach 功能进行动态调试。对于加密的 so,可以通过 /proc/pid/maps 查看内存布局后,用 dd 命令从内存中 dump 解密后的 so。具体流程:(1) 找到 so 加载时机——使用 Frida Hook android_dlopen_extdlopen;(2) 记录 so 在内存中的基址;(3) 从 /proc/<pid>/maps 中读取 so 对应的内存段范围(含代码段、数据段等);(4) 使用 Frida 的 Memory.readByteArrayadb shell dd if=/proc/<pid>/mem 从对应范围 dump 解密后的 so;(5) 将 dump 出的 so 加载到 IDA/Ghidra 中进行静态分析。对于做了代码自修改(SMC)的 so,可能在运行过程中动态修改代码段,需要在关键函数执行前后分别 dump 对比。

Q4:方法抽取型加固(第二代)的原理和脱壳方法?

A:方法抽取型加固的核心是将 DEX 中每个方法的实际字节码(code_item)从 DEX 文件中剥离,替换为空方法体或指向错误地址的 stub。原始字节码加密存储在 so 的 .rodata 段或 APK 的 assets 中。当方法首次被调用(ART 需要执行 code_item 中的指令)时,触发壳的回填机制:Native 层的 Hook 拦截 ART 的方法执行入口 → 从加密存储中读取该方法的原始 code_item → 解密后将 code_item 写回内存中的 DEX 结构 → 方法正常执行。脱壳方法:(1) 主动调用——遍历所有类和方法,触发所有方法的回填,然后 dump 内存中完整的 DEX;(2) Hook ART 内部函数——Hook ArtMethod::Invoke()ClassLinker::GetMethod(),在回填完成后 dump;(3) 逆向壳的回填算法——提取加密的 code_item 和数据,编写脚本离线解密后拼接回 DEX 文件。

Q5:如何检测一个 Android 应用是否被加固?加固后又如何判断加固厂商?

A:检测方法:(1) 解包 APK,检查 classes.dex 的大小——如果仅为几十 KB(正常应用通常 1-10MB+),很可能被加固;(2) 检查 lib/ 目录下是否有特征 so 文件——腾讯乐固的 libtup.so、360 的 libjiagu.so、梆梆的 libSecShell.so 等;(3) 检查 AndroidManifest.xml 中的 Application 类名——壳常替换为 StubApplicationWrapperApplication 等;(4) 使用 APKiD 等专用工具——apkid target.apk 可通过规则匹配自动识别包括加固在内的多种保护方案;(5) 尝试 JADX 打开 APK——如果只能看到少量类(如只有 Application 和几个 stub 类),而不是应用完整的代码结构,说明被加固;(6) 运行时使用 Frida 查看 ClassLoader 的实际 DEX 来源路径——加固应用通常从非标准路径加载。判断厂商主要通过特征 so 文件名、Application 类名、assets 中的配置文件(如 armeabi/dataopijiami.dat 等),以及加固工具的特定行为(如乐固的 mix.dex、梆梆的 classes.dgc)。

打赏
  • 微信
  • 支付宝

评论