应用安全 --- 安卓加固 之 绑企加固
简介:
https://dev.bangclexxx.com/home/introduction
加固样本:
雅玛逊购物 万年理 7.3.7 油正懒投 某数TV ,需要LD过签,而且不支持Android15 7.2.2.0.apk_123云盘免登录下载不限速 辛巴克 智已汽车 卖档佬 中果英航 易懂哀家 某雷 森空岛在某一个版本使用了绑企,但是之后自己去除了,是最好的研究样本
特征:
这个软件有多个版本,不同版本加密的方法不一样
202411旧版本特征文件
libDexHelper-x86.so 这是解密dex的x86版本
libDexHelper.so 这是解密dex的arm版本
libdatajar.so 这是加密后的dex
2025新版特征

壳入口

对抗:
1.https://56.al/ 上传apk脱dex
2.将脱ke后的dex批量修复dex并重命名classes{2}.dex
3.将脱ke后的dex加入apk中,注意不要全程不要勾选自动签名!!!!!!!
4.打开第一个dex,找到H类,将app名称和acf名称复制出来,打开AndroidManifest.xml替换掉
例如
<application
android:name="com.youloft.core.CApp"
android:appComponentFactory="androidx.core.app.CoreComponentFactory"
删除
<provider
android:name="com.secneo.apkwrapper.CP"
android:exported="false"
android:authorities="cn.amazon.mShop.android.CP"
android:initOrder="2147483647" />
5.删除lib下的两个特征 libDexHelper.so libDexHelper-x86.so 和assets下的meta-data目录
6.去签名校验并勾选签名
原理分析:
libDexHelper.so原理
Java层入口 → init函数 → JNI_OnLoad → DEX解密 → DEX加载 → 反调试处理
步骤1:入口定位
1. 检查 AndroidManifest.xml 中的 Application 类
2. 找到 attachBaseContext 方法
3. 发现加载了 libDexHelper.so
步骤 2:init 函数分析(SO 初始化)
遇到的问题与解决
| 问题 | 解决方法 |
|---|---|
| F5 反编译失败 | 修正函数参数声明 |
| sp-analysis failed | Options → General → Stack pointer,找到破坏堆栈的指令并 NOP |
init 函数核心逻辑
void init_proc() {
// 步骤1: mmap 内存,拷贝 seg000 段内容
sub_D0F50("");
// 步骤2: 解密代码段和数据段
sub_D0C30("", &dword_103D4, v0);
}
Dump 解密代码的方法
在 sub_D0C30 函数末尾的两个 mprotect 处下断点:
├── 第1个 mprotect: 修改代码段权限 → dump 解密后的代码段
└── 第2个 mprotect: 修改数据段权限(含GOT表) → dump 解密后的数据段
步骤 3:JNI_OnLoad 分析(绕过 OLLVM)
OLLVM 混淆处理流程
1. 使用 trace 脚本记录所有执行的指令
↓
2. 过滤出真实执行的基本块
↓
3. 按执行顺序分析每个真实块
↓
4. 在关键块下断点进行动态分析
RegisterNatives 注册的方法
Native方法 对应函数
────────────────────────────────
"attach" → sub_2775C
"b" → sub_17210
"c" → sub_18B10
"d" → sub_20384
"e" → sub_224A0
"f" → sub_1CE90 (关键: makeDexElements)
... (共18个方法)
步骤 4:DEX 解密
解密流程图
┌─────────────────────────────────────────────────────────────┐
│ DEX 解密过程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 反射获取 java/lang/Class │
│ ↓ │
│ 2. 获取 dexCache 对象 │
│ ↓ │
│ 3. 反射获取 java/lang/DexCache 的 dexFile │
│ ↓ │
│ 4. GetIntField 转换为 native 指针 │
│ ↓ │
│ 5. 指针+4 → 壳 DEX 在内存中的位置 │
│ ↓ │
│ 6. 匹配特征找到 injected_data │
│ ↓ │
│ 7. 调用解密函数解密 DEX │
└─────────────────────────────────────────────────────────────┘
额外资源解密:
• v1filter.jar: 从 assets/resthird.data 解密释放到 .cache 目录
步骤5:DEX 加载(Hook 技术)
Hook 点与作用
┌────────────────────────────────────────────────────────────────────┐
│ Hook 目标 │ 作用 │
├────────────────────────────────────────────────────────────────────┤
│ art::DexFileVerifier::verify │ 恒返回 true,跳过验证 │
│ art::ClassLinker::OpenDexFilesFromOat │ 拦截 DEX 加载,注入逻辑 │
└────────────────────────────────────────────────────────────────────┘
Hook 特征识别:
BX PC ; 保证 LDR 指令 4 字节对齐
LDR PC, [PC-4] ; 跳转到 fake 函数
DCD fake_addr ; fake 函数地址
DEX 加载流程
1. 在 .cache 目录创建 10 个 DEX 文件 (仅写 4 字节 magic)
↓
2. 调用 native 方法 "f" (sub_1CE90)
↓
3. 调用 makeDexElements 加载 DEX
↓
4. OpenDexFilesFromOat 被 Hook,进入 fake 函数 sub_433E8:
├── v1Filter.jar → 调用原 OpenDexFilesFromOat (文件加载)
└── classes.dex → 调用 OpenMemory (内存加载)
↓
5. 替换原 ClassLoader 的 dexPathList.dexElements
Dump DEX 的方法
# 在 OpenMemory 处下断点,dump 所有 DEX
# 使用 dex2apk 工具合并 DEX 为 APK
步骤6:反调试与检测机制
检测机制汇总
┌─────────────────────────────────────────────────────────────────────┐
│ 检测类型 │
├─────────────────────────────────────────────────────────────────────┤
│ 【Magisk 检测】 │
│ • 检查进程名是否为 ":bbs:com.secneo.apkwrapper.r.S" │
├─────────────────────────────────────────────────────────────────────┤
│ 【Frida 检测】 │
│ • /proc/self/task/<tid>/status 的 name 字段: │
│ - "gum-js-loop" │
│ - "gmain" │
│ • /proc/self/fd/<fd-id> 链接内容: │
│ - "linjector" │
├─────────────────────────────────────────────────────────────────────┤
│ 【反调试】 │
│ • Hook ptrace → fake 函数调用 svc kill │
│ • inotify_add_watch 监控 /proc/self/mem │
│ • 检测 /proc/self/status 和 /proc/self/task/<tid>/status: │
│ - "T (stopped)" │
│ - "(zombie)" │
│ - "t (tracing stop)" │
│ • 父子进程管道通信验证 (写入/读取 0xFF) │
└─────────────────────────────────────────────────────────────────────┘
反调试架构
┌─────────────┐
│ 父进程 │
├─────────────┤
fork │ • Hook ptrace
─────→ │ • 监控 /proc/self/mem
│ • 检测进程状态
│ • 管道通信验证
└──────┬──────┘
│ pipe 通信
↓
┌─────────────┐
│ 子进程 │
├─────────────┤
│ • 监控 /proc/self/mem
│ • 检测进程状态
│ • 管道通信验证
│ (不 Hook ptrace)
└─────────────┘
步骤 7:后续处理
JNI_OnLoad 完成后:
↓
Java 层 AW.attachBaseContext
↓
调用 H.attach (sub_2775C)
↓
• 资源替换
• Application 替换
绕过方法总结
| 保护机制 | 绕过方法 |
|---|---|
| OLLVM 混淆 | Trace 脚本 + 真实块分析 |
| 代码加密 | mprotect 断点 dump |
| DEX 加密 | OpenMemory 断点 dump |
| Frida 检测 | 使用 hluda 的 spawn 模式 |
| ptrace 反调试 | Frida attach 会崩,需用其他方法 |
| 状态检测反调试 | 编译修改内核绕过 |
关键断点位置
┌──────────────────────────────────────────────────────────────┐
│ 地址/函数 │ 用途 │
├──────────────────────────────────────────────────────────────┤
│ sub_D0C30 的 mprotect │ dump 解密后的代码/数据段 │
│ 0x27366 │ DEX 解密函数调用处 │
│ OpenMemory │ dump 解密后的 DEX │
│ 0x2A024 RegisterNatives │ 获取 native 方法映射 │
└──────────────────────────────────────────────────────────────┘
这个分析流程适用于类似的 VMP 壳,核心思路是:找加密点 → dump 解密数据 → 绕过反调试 → 分析业务逻辑。
1.找到java入口,检查Application类的attachBaseContext方法,或者其他方法也可能出现,方法内部会反射调用so文件。
2.找到c的入口,我们发现入口是init_proc方法,
void init_proc()
{
sub_D0F50(""); // ① mmap内存,把seg000段内容拷贝过去
sub_D0C30(...); // ② 解密代码段和数据段
}
3.解密so方法
sub_D0C30函数结尾两个mprotect调用处
├─ 第1个mprotect → 修改代码段权限 → 可dump解密后代码段
└─ 第2个mprotect → 修改数据段权限(含GOT表) → 可dump解密后数据段
4.注册jni函数
0x2A024 调用 RegisterNatives,注册com/secneo/apkwrapper/H类的所有native方法(attach, b, c, d等13个方法) ,建立 Java 方法 ↔ Native 函数映射
| Java方法 | Native函数 |
|---|---|
| attach | sub_2775C |
| b | sub_17210 |
| c | sub_18B10 |
| d | sub_20384 |
| e | sub_224A0 |
| f | sub_1CE90 |
5.找到关键解密dex方法
解密DEX内存数据
0x2A030: 调用sub_265E8
↓
0x2721C: 调用sub_49604, 通过反射获取当前壳DEX在内存中的位置
├─ 反射获取 java/lang/Class 的 dexCache
├─ 反射获取 java/lang/DexCache 的 dexFile
├─ GetIntField 转为native指针
├─ 指针+4字节 → 壳DEX内存位置
└─ 匹配特征找到 injected_data(这是壳厂商在DEX中注入的加密数据区)
↓
0x27366: 调用解密函数,传入 injected_data 中某个位置的加密数据,解密算法将加密数据原地解密成真正的DEX文件 → 【可dump DEX】
↓
0x26E6A: access检查v1filter.jar是否存在
↓
0x26D26: 解密assets/resthird.data → 释放v1filter.jar(这是一个工具类DEX,包含壳需要用到的辅助类和方法,在后续加载真实DEX时会用到)
解密DEX是获取真实APP代码,释放v1filter.jar是获取壳的辅助工具,两者配合完成完整的脱壳加载流程。
6.加载dex
0x273C6: 调用sub_3FA6D
│
├─ 0x44A52: Hook art::DexFileVerifier::verify
│ → 恒返回true(跳过验证)
│
├─ 0x426BA: Hook art::ClassLinker::OpenDexFilesFromOat
│ → fake函数: sub_433E8
│
└─ dlsym获取 art::DexFile::OpenMemory 地址
↓
0x26756: 调用sub_1A7C4
└─ 对每个classes.dex在.cache创建文件(仅写4字节magic)
↓
0x1AE44: 调用native方法f
├─ 调用makeDexElements加载dex
└─ 替换classloader的dexElements
sub_433E8(fake函数)逻辑:
判断加载目标
│
├─ v1Filter.jar → 0x435EE调用原函数(文件加载)
│
└─ classes.dex → 0x4352E找到dex数据
→ 0x43568调用OpenMemory(内存加载)
→ 【在此处下断点可dump所有DEX】
需要处理的可能错误情况
1.ollvm控制流混淆
步骤1: 识别平坦化混淆
↓
步骤2: 使用trace脚本记录指令日志
↓
步骤3: 过滤虚假块,提取真实块
↓
步骤4: 逐块分析真实逻辑
Trace输出示例:
index: 0, start ea: 0x28868
index: 2, start ea: 0x288ae
...
index: 118, start ea: 0x2ac46
2.堆栈不平衡,开启Stack pointer视图,找到破坏堆栈的指令并NOP掉
3.参数不一致,F5失败(0xD0688) 修正函数参数声明为 void __fastcall sub_D0C30(_DWORD *a1, int a2, int a3)
4.4 框架检测和反调试
4.4.1 Magisk检测
位置: 0x2AB2C
检测方式: 检查进程名是否为 ":bbs:com.secneo.apkwrapper.r.S"
4.4.2 Frida检测
位置: 0x5fd12 → sub_5A088
检测点1: /proc/self/task/<tid>/status
└─ Name字段匹配 "gum-js-loop"
检测点2: /proc/self/task/<tid>/status
└─ 匹配 "gmain"
检测点3: readlink /proc/self/fd/<fd-id>
└─ 匹配 "linjector"
4.4.3 反调试机制
┌─────────────────────────────────────────────────────────────┐
│ 反调试架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 0x5fd00/0x5fd06: 创建两个pipe用于父子进程通信 │
│ ↓ │
│ 0x5f8ee: fork() │
│ ┌──────────┴──────────┐ │
│ 父进程 子进程 │
│ │ │ │
│ ┌─────┴─────┐ ┌─────┴─────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Hook 开线程 同父进程 管道通信 │
│ ptrace ×2 (不hook 验证 │
│ │ │ ptrace) │
│ ▼ ▼ │
│ fake函数 ┌──┴──┐ │
│ kill进程 │ │ │
│ ▼ ▼ │
│ inotify 读取State字段 │
│ 监控mem 匹配: │
│ - "T (stopped)" │
│ - "(zombie)" │
│ - "t (tracing stop)" │
│ │
│ 管道通信: 写入0xFF,读取验证,非0xFF则kill │
└─────────────────────────────────────────────────────────────┘
加固技术总结
| 技术类型 | 实现方式 |
|---|---|
| 代码保护 | OLLVM平坦化混淆 |
| DEX保护 | 加密存储 + 内存加载 |
| 完整性校验 | Hook DexFileVerifier::verify |
| 反调试 | 双进程守护 + ptrace检测 + 状态监控 |
| 反Frida | 进程名/线程名/fd特征检测 |
绕过方案
| 保护类型 | 绕过方法 |
|---|---|
| DEX加密 | 在OpenMemory处下断点dump |
| 反Frida | 使用hluda的spawn模式 |
| 反调试 | 编译修改内核绕过 |
libdexjni.so原理
libdexjni.so也是一个特征文件。实际上 libdexjni.so在不同的APP中体积会不一样,应该是硬编码写入key和自定义指令导致的
这个加固的 VMP 属于 DEX-VMP ,原理大致是
1.将每个java中的activity类的onCreate方法的字节码转义为自定义字节码保存在so文件内部(630加固会将java 函数为 native,邦加固会反射)。
2.执行时从so中获取 自定义字节码数据 传入内部 vmpEntry 解析器执行。
3.最终调用 jni 方式实现c语言调用java代码执行。
so使用了vmp、ollvm和加密壳。虽然使用了最强加密方法,但是还是有迹可循,
问题1,这个vmp没有完美实现虚拟化,只是利用了jni简化执行代码。我们可以使用黑盒方法猜测还原代码而不关心内部是如何实现的。一个自定义操作字节码对应一个jni函数,jni函数可以猜测这个smali指令。
问题2,同一个版本的指令映射表应该是一样的
我们的目的就是找到这个自定义指令和jni码表
流程图
┌─────────────────────────────────────────────────────────────┐
│ VMP 解密执行流程 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤1: 读取加密的opcode │
│ LDRH R6, [R6] ; R6 = 0x1000 (加密值) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤2: 计算key地址 │
│ R2 = 当前insn地址 - insn基址 │
│ R7 = key表基址 │
│ key = [R7 + R2] ; key = 0x89 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤3: XOR解密 │
│ EOR.W R1, R2, R6 ; R1 = 0x89 XOR 0x1000 = 0x1089 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤4: 计算handler跳转地址 │
│ R1 = R1 & 0xFF ; 取低8位 = 0x89 │
│ handler_addr = handler_table + (R1 << 2) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤5: 跳转到对应handler执行 │
│ MOV PC, R0 ; 跳转到 invoke-super 的handler │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤6: Handler内部调用JNI函数 │
│ FindClass("java/lang/Object") │
│ GetMethodID(class, "<init>", "()V") │
│ CallNonvirtualVoidMethodA(...) │
└─────────────────────────────────────────────────────────────┘
分析步骤
1. 环境准备与反调试绕过
- 编写Python脚本辅助调试
- 在libDexHelper.so加载时绕过反调试(patch地址0x2A142处的指令)
- 等待JNI_OnLoad解密完成
2. 定位VMP入口
- 找到不同返回类型的VMP入口方法(cV等函数)
- 这些方法都注册到libdexjni.so中
3. 找到CodeItem
通过回溯分析发现:
- Java层传入vmpId参数索引vmpInfo结构体
- vmpInfo结构体包含:vmpId、codeItem等字段
- codeItem偏移在结构体+0x8位置
4. 指令分析
通过trace日志和断点JNI函数(如FindClass、GetMethodID)分析出:
指令格式:
opcode(2字节) | operand(2字节) | 参数(2字节) | opcode(2字节)
示例指令:
00 10 CB 28 00 00 47 0000 10:invoke-super的opcode(需要XOR解密)CB 28:字符串索引操作数00 00:参数(寄存器)47 00:return-void的opcode
5. 指令解密机制
- opcode会进行XOR解密
- 解密key的获取公式:
[[[GOT + 0xD8C50 + 0x8] + vmpId * 4] + addr_cur_insn - addr_insn_base] - 每条指令的opcode部分都需要解密
6. 识别的指令类型
通过JNI函数调用推断出对应的smali指令:
invoke-super:调用父类方法return-void:空返回const-string:字符串常量iput-object:对象字段赋值
技术要点
- trace脚本的使用:记录执行流程和寄存器状态,便于回溯分析
- 回溯法:从JNI函数断点向上追踪参数来源
- 结构体推断:通过内存访问模式推断数据结构定义
- 指令映射:将VMP指令映射到原始Dalvik指令
难点
- OLLVM混淆增加了分析难度
- 地址随机化(ASLR)
- IDA无法下内存断点
- 完整的指令映射表需要大量时间建立
- 内存中的VMP指令是加密的,增加了patch难度
也就是还原出字节码需要加密的自定义操作码,key和自定义操作码与smali的映射关系
具体步骤
1.一切的开端,找java入口的MainActivity类
我们脱dex后打开,搜索oncreate找到加密位置


2.解密so,找到c入口的init_proc方法
跟 libDexHelper 中一样有 init_proc 函数,位于 ELF 文件的 .init_proc 段,负责在库加载时执行必要的解密so,包括 JNI_OnLoad 。 dump so后完成解密。
__int64 __fastcall init_proc(__int64 a1) { __int64 v1; // x0 v1 = sub_176734((char *)&elf_hash_chain[285] + (_QWORD)&off_176078); //内存分配与数据复制 ((void (__fastcall *)(char *, __int64 (*)(), __int64))loc_17650C)( //解密so (char *)&elf_hash_chain[285] + (_QWORD)&off_176078, sub_95A0, v1); return a1; }
3.动态注册,找到解密后的jni_onload方法
jint JNI_OnLoad(JavaVM *vm, void *reserved) { jint v2; // w19 __int64 v3; // x0 int v5[2]; // [xsp+8h] [xbp-18h] BYREF *(_QWORD *)v5 = 0; v2 = 65540; if ( (*vm)->GetEnv(vm, (void **)v5, 65540) ) return -1; j_j__lS0lIllO_l_0lSll_5l__IIllSIlI05SlISS0I_5Ol_lO0llS5_(); //这里是thub函数只用于强制调转执行,跳转两次到达最终执行函数,j_j_就是跳转两次,属于混淆的一种
v3 = j_pE42128F1981DEFB860D59DE036EF9F09(); j_j__lI5_SOlIO5I_ll0_SllOlO__lOISSO5SllOO_l0Ill5lO5IIS5_(v3); // 前三个j_j__函数解密 vmp 执行所需的指令及解密指令的 key
j_j__l5I0l_l5lSIIl_ISlIl_Sl5Il0OOI_OSI55l__IS_SSSO5SSS5_(v5[0], "com/fort/andjni/JniLib"); //动态注册本地方法并将反射获取 java 类及 methodId ,并把它们保存到全局变量上
return v2; }
j_j__l5I0l_l5lSIIl_ISlIl_Sl5Il0OOI_OSI55l__IS_SSSO5SSS5_的最终执行函数
__int64 (__fastcall *__fastcall j__l5I0l_l5lSIIl_ISlIl_Sl5Il0OOI_OSI55l__IS_SSSO5SSS5_(
__int64 a1,
char *a2,
__int64 (__fastcall *a3)()))()
{
char v3; // w25
size_t v7; // x0
int v8; // w22
size_t v9; // x23
void *v10; // x24
__int64 (__fastcall *result)(); // x0
int v12; // w28
unsigned int v13; // w8
const char *v14; // x22
unsigned int v15; // w8
if ( a2 )
{
v7 = strlen(a2);
v8 = v7;
v9 = (__int64)((v7 << 32) + 0x100000000LL) >> 32;
v10 = malloc(v9);
qword_175398 = (__int64)v10;
memset(v10, 0, v9);
strncpy((char *)v10, a2, v8);
}
((void (__fastcall *)(__int64))loc_1061C)(a1); //这里面首先是调用 反射获取 java 类及 methodId ,并把它们保存到全局变量上
qword_175A68 = (__int64)sub_17AC4; //接着注册 com.fort.andjni.JniLib 的所有 native 方法,后面以 cV 函数为例
qword_175A58 = (__int64)byte_847A0; //我们发现这里没有识别出字符串cV但是不要紧,我们在目标字符串定义的位置中选中字符串按下a重新定义为字符串即可。
qword_175A60 = (__int64)"([Ljava/lang/Object;)V";
qword_175A80 = (__int64)sub_17AEC;
qword_175A70 = (__int64)"cI";
qword_175A78 = (__int64)"([Ljava/lang/Object;)I";
qword_175A98 = (__int64)sub_17B18;
qword_175A88 = (__int64)"cL";
qword_175A90 = (__int64)"([Ljava/lang/Object;)Ljava/lang/Object;";
qword_175AB0 = (__int64)sub_17B44;
qword_175AA0 = (__int64)"cS";
result = sub_17C4C;
qword_175AA8 = (__int64)"([Ljava/lang/Object;)S";
qword_175AC8 = (__int64)sub_17B70;
qword_175AB8 = (__int64)"cC";
qword_175AC0 = (__int64)"([Ljava/lang/Object;)C";
v12 = 30745;
qword_175AE0 = (__int64)sub_17B9C;
v13 = 9;
qword_175AD0 = (__int64)"cB";
qword_175AD8 = (__int64)"([Ljava/lang/Object;)B";
qword_175AF8 = (__int64)sub_17BC8;
qword_175AE8 = (__int64)"cJ";
qword_175AF0 = (__int64)"([Ljava/lang/Object;)J";
qword_175B10 = (__int64)sub_17BF4;
qword_175B00 = (__int64)"cZ";
qword_175B08 = (__int64)"([Ljava/lang/Object;)Z";
qword_175B28 = (__int64)sub_17C20;
qword_175B18 = (__int64)"cF";
qword_175B20 = (__int64)"([Ljava/lang/Object;)F";
qword_175B40 = (__int64)sub_17C4C;
qword_175B30 = (__int64)"cD";
qword_175B38 = (__int64)"([Ljava/lang/Object;)D";
if ( qword_175398 )
v14 = (const char *)qword_175398;
else
v14 = "com/fort/andjni/JniLib";
while ( 1 )
{
switch ( v13 )
{
case 0u:
case 3u:
case 4u:
case 0xCu:
return result;
case 1u:
if ( a3 )
v13 = 5;
else
v13 = 4;
break;
case 2u:
result = (__int64 (__fastcall *)())(*(__int64 (__fastcall **)(__int64, __int64 (__fastcall *)()))(*(_QWORD *)a1 + 184LL))(
a1,
a3);
v13 = 3;
break;
case 5u:
result = (__int64 (__fastcall *)())(*(__int64 (__fastcall **)(__int64, __int64 (__fastcall *)(), __int64 *, __int64))(*(_QWORD *)a1 + 1720LL))(
a1,
a3,
&qword_175A58,
10);
if ( (_DWORD)result )
v13 = 10;
else
v13 = 11;
v12 = 20516;
break;
case 6u:
result = (__int64 (__fastcall *)())(*(__int64 (__fastcall **)(__int64, __int64 (__fastcall *)()))(*(_QWORD *)a1 + 184LL))(
a1,
a3);
v13 = 0;
break;
case 7u:
result = (__int64 (__fastcall *)())(*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(
a1,
v14);
a3 = result;
v13 = 1;
v3 = 1;
break;
case 8u:
result = (__int64 (__fastcall *)())(*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(
a1,
v14);
a3 = result;
v13 = 40 - v12 + 83 * ((unsigned int)(50534 * v12) >> 22);
v3 = 1;
break;
case 9u:
v3 = 0;
if ( a3 )
v13 = 1;
else
v13 = 7;
break;
case 0xAu:
v15 = v12 - 157 * ((unsigned int)(53431 * v12) >> 23);
if ( (v3 & 1) != 0 )
{
v13 = 112 - v15;
v3 = 1;
}
else
{
v3 = 0;
v13 = 106 - v15;
}
break;
case 0xBu:
if ( (v3 & 1) != 0 )
v13 = 2;
else
v13 = 3;
break;
default:
continue;
}
}
}
4.java调用注册的c的参数解析方法,cV函数
这是一个"万能方法调用器" —— 它可以根据传入的参数描述,动态调用任意 Java 方法。
简化后的流程 C void parseAndInvokeJniMethod(JNIEnv* env, jobjectArray args, int64_t* result) { // 第1步:读取方法描述 jobject methodInfo = args[0]; MethodDesc desc = parseMethodInfo(methodInfo); // 第2步:遍历所有参数,拆箱 for (int i = 1; i < args.length; i++) { jobject param = args[i]; char type = desc.paramTypes[i-1]; switch (type) { case 'I': nativeArgs[i] = env->CallIntMethod(param, intValue); break; case 'J': nativeArgs[i] = env->CallLongMethod(param, longValue); break; case 'D': nativeArgs[i] = env->CallDoubleMethod(param, doubleValue); break; case 'Z': nativeArgs[i] = env->CallBooleanMethod(param, booleanValue); break; // ... 其他类型 } } // 第3步:调用实际方法 *result = invokeRealMethod(desc, nativeArgs); }
4.分析vmp解密原理
VMP 指令分析
搞清楚 VMP 保护的指令格式,以及如何解密和还原原始 smali 指令
分析流程图
┌─────────────────────────────────────────────────────────────────┐
│ VMP 指令分析完整流程 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第1步:准备工作 - 修改 trace 脚本打印寄存器值 │
│ │
│ 格式:指令 D:[目的寄存器值] S:[源寄存器值] │
│ 目的:能看到数据的变化过程 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第2步:找到指令解密过程 │
│ │
│ 搜索 insn 的值(如 0x1000),找到异或操作: │
│ EOR.W R1, R2, R6 ; R2=0x89(key), R6=0x1000(加密opcode) │
│ 结果:0x1000 ^ 0x89 = 0x1089(解密后的opcode) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第3步:观察跳转,确定这是 handler 分发 │
│ │
│ UXTB R1, R1 ; 取低8位 = 0x89 │
│ ADD.W R0, R0, R1,LSL#2 ; 计算跳转表地址 │
│ LDR R0, [R0] ; 加载 handler 地址 │
│ MOV PC, R0 ; 跳转到对应 handler │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第4步:观察 handler 调用了什么 JNI 函数 │
│ │
│ FindClass("java/lang/Object") │
│ GetMethodID(..., "<init>", ...) │
│ CallNonvirtualVoidMethodA(...) │
│ │
│ → 推断:这是 invoke-super 指令,调用父类构造函数 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第5步:分析完整指令格式 │
└─────────────────────────────────────────────────────────────────┘
指令解密机制
发现过程
第一次执行 cV 函数:
加密指令:00 10 CB 28 00 00 47 00
解密key: 89 ← 87
解密结果:89 10 CB 28 00 00 C0 00
↑ ↑
0x1000^0x89=0x1089 0x47^0x87=0xC0
第二次执行 cV 函数:
加密指令:69 10 CB 28 00 00 8B 00
解密key: 同上
解密结果:89 10 CB 28 00 00 C0 00 ← 结果相同!
关键发现
结论:只有 opcode 需要异或解密,操作数(operand)不需要解密
每次执行时 opcode 的加密值不同,但解密后结果相同 。
这说明 key 是动态变化的,增加了静态分析的难度
VMP 指令格式
┌─────────────────────────────────────────────────────────────────┐
│ VMP 指令格式(与 Dalvik 相同) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 00 10 CB 28 00 00 47 00 │
│├───┤ ├───┤ ├───┤ ├───┤ │
│ opcode operand 参数 opcode │
│ (加密) (明文) (明文) (加密) │
│ ↓ ↓ │
│ 解密后 解密后 │
│ 0x1089 0x00C0 │
│ ↓ ↓ │
│ invoke-super return-void │
│ │
└─────────────────────────────────────────────────────────────────┘
通过 JNI 函数推断 smali 指令
推断表
| JNI 函数调用 | 对应的 smali 指令 |
|---|---|
FindClass + GetMethodID + CallNonvirtualVoidMethodA |
invoke-super |
FindClass + GetMethodID + CallStaticXxxMethod |
invoke-static |
GetFieldID + SetObjectField |
iput-object |
GetFieldID + GetObjectField |
iget-object |
| 无 JNI 调用,直接返回 | return-void |
实际分析案例
观察到的 JNI 调用序列:
┌─────────────────────────────────────────────────────────────────┐
│ 1. GetFieldID("cn/.../i", "a", "Ljava/lang/String;") │
│ 2. SetObjectField(obj, fieldId, stringValue) │
├─────────────────────────────────────────────────────────────────┤
│ 推断:这是给对象的字符串字段赋值 │
│ 对应 smali:iput-object v0, v1, Lcn/.../i;->a:Ljava/lang/String;│
└─────────────────────────────────────────────────────────────────┘
完整指令还原示例
加密的 VMP 指令
5B 10 CB 28 01 00 41 00 00 00 DA 10 38 02 9D 00
解密过程
5B 10 → 5B^?? = 89 → invoke-super
CB 28 → 操作数(类/方法索引)
01 00 → 参数(使用 v1 寄存器)
41 00 → 41^?? = 75 → const-string
00 00 → 操作数
DA 10 → DA^?? = F5 → iput-object
38 02 → 操作数(字段索引)
9D 00 → 9D^?? = C0 → return-void
还原的 smali
invoke-super {v1}, Ljava/lang/Object;-><init>()V
const-string v0, "xxx"
iput-object v0, v1, Lcn/.../i;->a:Ljava/lang/String;
return-void
┌─────────────────────────────────────────────────────────────────┐
│ VMP 指令分析方法论 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Trace 记录:打印完整执行流程 + 寄存器值 │
│ │
│ 2. 找解密点:搜索 EOR/XOR 指令,找到 opcode 解密位置 │
│ │
│ 3. 观察跳转:解密后的 opcode 用于计算 handler 地址并跳转 │
│ │
│ 4. 分析 handler: │
│ • 记录调用了哪些 JNI 函数 │
│ • 根据 JNI 函数组合推断 smali 指令 │
│ • 参考 dex2c 项目了解 JNI 实现细节 │
│ │
│ 5. 多次执行对比: │
│ • 确认哪些部分被加密(opcode) │
│ • 哪些部分是明文(operand) │
│ │
│ 6. 建立映射表: │
│ • 解密后 opcode → smali 指令类型 │
│ • 有了映射表就能批量还原 !!!!!!! │
│ │
└─────────────────────────────────────────────────────────────────┘
核心要点
1. VMP 指令格式与 Dalvik 相同,只是 opcode 被加密了
2. 解密方式:opcode ^ key = 真实opcode
3. key 是动态的,每次执行可能不同
4. 通过观察 JNI 函数调用序列,可以反推 smali 指令
5. 操作数(operand)不加密,可以直接用于索引字符串/类/方法
5.找到vmpInfo(哪一个oncreate方法的加密指令集合)、codeItem(某个加密指令集合)、insns(指令)、opcode(指令的一个加密操作码) 和key(解密指令的关键)在内存中的位置
vmpInfo结构体关系图
┌─────────────────────────────────────────────────────────────┐
│ 全局 vmpInfo 数组 │
├─────────────────────────────────────────────────────────────┤
│ vmpInfo[0] │ vmpInfo[1] │ vmpInfo[2] │ ... │
└──────┬───────┴──────┬───────┴──────┬───────┴────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ vmpInfo │ │ vmpInfo │ │ vmpInfo │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ +0x0: vmpid=0 │ │ +0x0: vmpid=1 │ │ +0x0: vmpid=2 │
│ +0x4: ??? │ │ +0x4: ??? │ │ +0x4: ??? │
│ +0x8:(*)codeItem ┼─┤ +0x8: (*)codeItem ┼─┤ +0x8: (*)codeItem ┼──┐
└─────────────┘ └─────────────┘ └─────────────┘ │
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ CodeItem │ │ CodeItem │
├──────────────────────────┤ ├──────────────────────────┤
│ +0x00: registers_size 方法中寄存器总数 │ │ +0x00: registers_size │
│ +0x02: ins_size 传入参数占用的寄存器数│ │ +0x02: ins_size │
│ +0x04: outs_size 调用其他方法时需要的寄存器数│ │ +0x04: outs_size │
│ +0x06: tries_size try-catch 块的数量 │ │ +0x06: tries_size │
│ +0x08: debug_info_off 调试信息偏移 │ │ +0x08: debug_info_off │
│ +0x0C: insns_size 指令数量(以2字节为单位)│ │ +0x0C: insns_size │
// 头部固定 16 字节,之后是变长的指令数据
├──────────────────────────┤ ├──────────────────────────┤
│ +0x10: insns[0] 指令数组(变长) │ │ +0x10: insns[0] │
│ +0x12: insns[1] │ │ +0x12: insns[1] │
│ +0x14: insns[2] │ │ +0x14: insns[2] │
│ ... │ │ ... │
└──────────────────────────┘ └──────────────────────────┘
实际内存示例
地址 内容 含义
─────────────────────────────────────────────────
+0x00 01 00 registers_size = 1
+0x02 01 00 ins_size = 1
+0x04 01 00 outs_size = 1
+0x06 00 00 tries_size = 0
+0x08 16 38 09 00 debug_info_off = 0x93816
+0x0C 04 00 00 00 insns_size = 4(共4条指令)
─────────────────────────────────────────────────
+0x10 00 10 CB 28 insns[0]: invoke-super 操作码+操作数
+0x14 00 00 insns[1]: 参数
+0x16 47 00 insns[2]: return-void
获取 vmpInfo 的计算方式
// 根据分析,获取 vmpInfo 的公式:
// vmpInfo地址 = 全局数组基址 + vmpId * sizeof(VmpInfo)
// 获取 codeItem 的公式:
CodeItem* codeItem = vmpInfo->code_item; // vmpInfo + 0x8
// 获取 insns 的公式:
uint16_t* insns = codeItem->insns; // codeItem + 0x10
// 或者
uint16_t* insns = (uint16_t*)((uint8_t*)codeItem + 0x10);
推断依据(来自 trace 日志)
; 根据索引获取 vmpInfo 0x000142C8: BLX j_153f0 ; 返回 vmpInfo 地址 0x000142CC: STR R0, [SP] ; 保存 vmpInfo 到栈 ; 从 vmpInfo 中取出 codeItem 0x0001433A: LDR R1, [SP] ; 取出 vmpInfo 0x00014352: LDR R0, [R1,#8] ; vmpInfo + 0x8 = codeItem 指针 ✓ 0x00014354: STR R0, [SP,#0x13C] ; 保存 codeItem ; 计算 insns 地址 0x0001462E: LDR R0, [SP,#0x13C] ; 取出 codeItem 0x00014634: ADDS R0, #0x10 ; codeItem + 0x10 = insns 地址 ✓ 0x00014636: STR R0, [R5] ; 保存 insns 地址给 vmpEntry 使用
找到 codeItem?
因为原始的vmp引擎十分复杂,我们要找到codeItem的位置,这里采用的是倒推法
分析一个VMP加固的程序,遇到了一块未知的内存(vmpInfo结构体)。他不知道这块内存里各个字段的含义,特别是不知道codeItem(存放原始指令的地方)在哪里。
核心思路:倒推法
用一个生活比喻来解释
想象你在找一个秘密仓库的位置:
你不知道仓库在哪,但你知道一件事:
→ 每天有个快递员会从仓库取货,然后送到一个固定的收货点
你的策略:
1. 在收货点蹲守,等快递员送货时拦住他
2. 问他:"这货从哪取的?"
3. 他说:"从A点拿的"
4. 你去A点调监控,发现货是从B点转运来的
5. 你再去B点查,发现货是从C点发出的
6. 一步步往回追,最终找到秘密仓库!
实际分析过程
第1步:蹲点
VMP执行Java代码时,必然要调用JNI函数
比如 FindClass —— 用来查找Java类
选择在 FindClass 处下断点
这就是"蹲守点"
第2步:断点触发,开始追踪
断点触发了!发现VMP在调用:
FindClass("java/lang/Object")
此时查看LR寄存器(返回地址)
发现调用者是 vmpEntry 函数(偏移0x1D0B0)
第3步:追踪参数来源
问题:FindClass的参数"java/lang/Object"这个字符串从哪来的?
使用trace脚本记录执行过程,然后往回看:
0x0002163C: LDR.W R1, [SP,#0x950] ← 字符串地址从栈上取
0x00021642: LDR R1, [R1] ← 再解引用一次
0x00021646: BLX R2 ← 调用FindClass
所以:字符串地址 = [[SP, #0x950]]
第4步:继续往上追
问题:[SP,#0x950] 这个位置的值是谁存进去的?
在trace日志中搜索 "SP,#0x950",找到:
0x0003A93C: LDR.W R1, [SP,#0x9A4] ← 取出一个"字符串索引"
0x0003A940: MOV R0, R4
0x0003A942: BLX sub_xxx ← 调用函数,用索引获取字符串
0x0003A946: STR.W R0, [SP,#0x950] ← 返回值存到这里!
发现:有个"字符串索引"被用来获取字符串
第5步:追踪字符串索引的来源
问题:这个"字符串索引"又是从哪来的?
继续往上追踪 [SP,#0x9A4]...
最终发现:
字符串索引 = [[[R0]] + 2]
而 R0 是 vmpEntry 的第一个参数!
第6步:关键发现
既然字符串索引在 vmpEntry 的第一个参数指向的内存里,
而字符串索引是指令的一部分(操作数),
那么:
vmpEntry 的第一个参数 = 指令(insn)的地址!
第7步:找到 codeItem
读取内存,发现指令内容是:
00 10 CB 28 00 00 47 00
已知 codeItem 结构:头部16字节 + 指令数据
所以往前偏移16字节,就是 codeItem:
01 00 01 00 01 00 00 00 16 38 09 00 04 00 00 00
验证:最后4字节 = 04 00 00 00 = insnsSize = 4条指令 ✓
完整的倒推链
FindClass("java/lang/Object")
↑ 参数从哪来?
[[SP,#0x950]]
↑ 这个值谁存的?
函数返回值(用字符串索引获取字符串)
↑ 字符串索引从哪来?
[[[R0]] + 2]
↑ R0是什么?
vmpEntry的第一个参数 = insn地址
↑ insn和codeItem什么关系?
insn = codeItem + 16
↑
所以:codeItem = insn地址 - 16
额外收获:推断 vmpInfo 结构
继续回溯,发现 insn 地址是这样来的:
0x000142CC: STR R0, [SP] ; 保存vmpInfo到栈
...
0x0001433A: LDR R1, [SP] ; 取出vmpInfo
...
0x00014352: LDR R0, [R1,#8] ; 从vmpInfo+8处取出codeItem!
0x00014354: STR R0, [SP,#0x13C] ; 保存codeItem
...
0x00014634: ADDS R0, #0x10 ; codeItem + 16 = insn地址
0x00014636: STR R0, [R5] ; 保存insn地址
由此推断出:vmpInfo 偏移0x8处 = codeItem 字段
核心方法论
┌─────────────────────────────────────────────────────┐
│ 倒推分析法 │
├─────────────────────────────────────────────────────┤
│ │
│ 1. 找锚点:选一个VMP必然调用的函数(如FindClass) │
│ │
│ 2. 下断点:等它触发 │
│ │
│ 3. 记录trace:用脚本记录完整执行过程 │
│ │
│ 4. 逆向追踪:从函数参数开始,往回追溯数据来源 │
│ - 看到 LDR R1, [SP,#xxx] → 搜索谁写了这个地址 │
│ - 一层层往回追 │
│ │
│ 5. 推断结构:根据偏移量,反推结构体字段含义 │
│ │
│ 原理:VMP再怎么保护,执行时一定要读取原始指令 │
│ 找到读指令的地方,就能找到指令存放的位置! │
│ │
└─────────────────────────────────────────────────────┘
简单说就是:顺藤摸瓜,从结果倒推原因,从使用者追溯到数据源头。
在so中的java字节码opcode 是加密存储的,我们要找到解密 opcode 的 key 存放在哪里?
我们采用回溯法 ,以为key是异或解密的,从异或指令开始倒推
EOR.W R1, R2, R6 ; R2=0x89(key), R6=0x1000(加密opcode)
↑
这个 key 从哪来的?
↑
LDR R2, [R7, R2] ; 从 R7 指向的表中取出 key
↑
R7 是什么?
↑
R7 是 key 表的基地址
key表内存布局图
GOT + 0xD8C50 + 0x8
│
▼
┌───────────────────┐
│ 0x9C100000 │ ← 存放 key表指针数组 的基地址
└─────────┬─────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ key表指针数组 │
│ (每个元素4字节,是一个指针) │
├───────────┬───────────┬───────────┬───────────┬─────────────────┤
│ [0] │ [1] │ [2] │ [3] │ ... │
│ 0x9C0FD000 │ 0x9C0FD100 │ 0x9C0FD1C0 │ 0x9C0FD280 │ │
│ │ │ │ │ │ │ │ │ │
└────┼──────┴────┼──────┴────┼──────┴────┼──────┴─────────────────┘
│ │ │ │
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 方法0的 │ │ 方法1的 │ │ 方法2的 │ │ 方法3的 │
│ key表 │ │ key表 │ │ key表 │ │ key表 │
│ │ │ │ │ │ │ │
│ 89 A2 .. │ │ 45 F3 .. │ │ 87 C1 .. │ │ 91 B4 .. │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
↑ ↑ ↑ ↑
vmpId=0 vmpId=1 vmpId=2 vmpId=3
实际计算示例
# 假设:
GOT = 0x9BF00000
vmpId = 5
当前insn地址 = 0xB399E3BC
insn基址 = 0xB399E3B8
# 第1步:找到 key表数组 基地址
key_array_ptr = 读取内存(GOT + 0xD8C50 + 0x8)
# 假设得到 0x9C100000
# 第2步:找到当前方法的 key表
key_table = 读取内存(key_array_ptr + vmpId * 4)
# key_table = 读取内存(0x9C100000 + 5 * 4) = 0x9C0FD1C0
# 第3步:计算指令偏移,获取 key
offset = 当前insn地址 - insn基址
# offset = 0xB399E3BC - 0xB399E3B8 = 4
key = 读取内存(key_table + offset)
# key = 读取内存(0x9C0FD1C0 + 4) = 0x89
# 第4步:解密 opcode
加密opcode = 0x1000
真实opcode = 加密opcode ^ key
# 真实opcode = 0x1000 ^ 0x89 = 0x1089
我再用通俗的语言表述这一个过程,
先找到key表数组,也就是key的起始地址,在找到某个函数对应的key表,函数编号对应数组角标,最后找到加密操作码对应的key表的值。
也就是说,key表数组角标=函数索引,key表索引=指令操作码位置
这样设计的原因是:
1. 每个方法有独立的 key表 ,破解一个方法不影响其他方法
2. 每条指令有独立的 key,不能用同一个 key 解密所有指令
3. key 和指令分开存储, 增加静态分析难度
4. 通过多层指针间接寻址,不容易直接定位 key 的位置
一句话总结:
key 的存放采用了"三级索引"结构:
全局基址 → vmpId索引方法 → 偏移量索引指令 → 得到key
这样每个被保护的方法、每条指令都有独立的 key,增加了破解难度。
6.恢复完整的opcode和smali映射表
构建 解密后 VMP opcode → 标准 smali 指令 的完整映射表。这个表是通用的,但是在内存中的 vmp 指令是被动态的key加密过的,无法批量修复vmp指令。
也就是说我们的码表获取后,key的值的获取就成为了关键。
7.自动修复 VMP 指令脚本
企业级加固VMP解释执行与指令还原详解
这是一篇关于Android VMP(虚拟机保护)壳的逆向分析文章。我将按步骤详细解释整个分析过程。
📚 第一部分:DEX指令格式基础
1.1 理解DEX字节码格式
在分析VMP之前,必须先理解标准的DEX指令格式。
┌─────────────────────────────────────────────────────────────┐
│ DEX 指令格式 (35c) │
├─────────────────────────────────────────────────────────────┤
│ 格式: A|G|op BBBB F|E|D|C │
│ │
│ 字节布局: │
│ ┌────┬────┬────────┬────┬────┬────┬────┐ │
│ │ A │ G │ op │ BBBB │ F │E│D│C│ │
│ └────┴────┴────────┴─────────┴────┴──┴──┘ │
│ 4bit 4bit 8bit 16bit 各4bit │
└─────────────────────────────────────────────────────────────┘
1.2 实例解析
Java源码:
public class BaseActivity extends FragmentActivity {
public Resources getResources() {
return super.getResources();
}
}
对应DEX指令:
6F 10 1C 33 01 00
解析过程:
6F 10 → 拆分为 A|G|op = 1|0|6F
A = 1 (参数数量)
G = 0
op = 0x6F (invoke-super)
1C 33 → BBBB = 0x331C (方法索引)
01 00 → F|E|D|C = 0|0|0|1
C = 1 表示使用 v1 寄存器
还原结果:
invoke-super {p0}, Landroidx/fragment/app/FragmentActivity;->getResources()Landroid/content/res/Resources;
🛡️ 第二部分:反调试处理
2.1 发现的反调试点
┌─────────────────────────────────────────────────────────────┐
│ 反调试检测点 │
├──────────────┬──────────────────────────────────────────────┤
│ 地址 │ 反调试类型 │
├──────────────┼──────────────────────────────────────────────┤
│ 0x47CAC │ 创建线程检测运行时间 │
│ │ getpid → syscall(__NR_kill) 杀死进程 │
├──────────────┼──────────────────────────────────────────────┤
│ 0x047C70 │ cmdline反调试 │
│ │ 检测 /proc/[pid]/cmdline │
├──────────────┼──────────────────────────────────────────────┤
│ 0x489EC │ /proc/status 检测 │
│ │ 检测 TracerPid 字段 │
└──────────────┴──────────────────────────────────────────────┘
2.2 绕过方法
在 JNI_OnLoad 处找到反调试的总入口,直接NOP掉:
ARM64 NOP指令: mov w1, w1
对应HEX: E1 03 01 2A
2.3 Dump DEX脚本
import struct
start = 0x75172191ec # DEX起始地址(内存中搜索 "dex\n035")
length = 0x6ee27c # DEX大小
dump_so = "/Users/beita/tmp/bangbang/dump_vmp.dex"
fn = AskStr(dump_so, "save as:")
with open(fn, "wb+") as f:
for addr in range(start, start + length):
f.write(struct.pack("B", Byte(addr)))
print("success to save as")
🔍 第三部分:VMP核心分析
3.1 VMP调用特征识别
dump出的DEX反编译后,发现VMP特征:
public void o() {
// 索引17标识要执行的函数
JniLib.cV(new Object[] { this, Integer.valueOf(17) });
throw new VerifyError("bad dex opcode"); // 垃圾代码,阻止静态分析
}
protected void onCreate(Bundle paramBundle) {
// 索引18标识onCreate函数
JniLib.cV(new Object[] { this, paramBundle, Integer.valueOf(18) });
throw new VerifyError("bad dex opcode");
}
3.2 VMP数据结构分析
JavaInfo结构体(根据索引获取):
struct JavaInfo {
uint32_t index; // 0x12 - Java层传递的函数索引
uint32_t unknow2; // 0x2E - 未知字段
uint64_t dexcode; // DexCode指针 → 指向VMP加密指令
uint32_t unknow4; // 0x03
uint32_t unknow5; // 0x02
uint32_t unknow6; // 0x02
};
DexCode结构体:
struct DexCode {
u2 registersSize; // 寄存器数量 = 3
u2 insSize; // 输入参数数量 = 2
u2 outsSize; // 输出参数数量
u2 triesSize; // try块数量
u4 debugInfoOff; // 调试信息偏移
u4 insnsSize; // 指令数量 = 0x0F (15条)
u2 insns[1]; // VMP加密指令数组 ↓
};
// VMP加密后的指令 (非标准DEX opcode)
insns[] = {
A3 20 5C 00 21 00 // 第1条
6B 10 // 第2条
CC 20 13 02 01 00 // 第3条
55 11 6D 00 // 第4条
53 10 6D 00 // 第5条
72 10 60 01 00 00 // 第6条
69 00 // 第7条
};
3.3 VMP执行流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ VMP 执行流程 │
└─────────────────────────────────────────────────────────────────────────┘
Java层调用
│
▼
┌─────────────────────┐
│ JniLib.cV(Object[]) │ 传入: this, paramBundle, Integer.valueOf(18)
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 解析Object数组 │ 获取最后一个Integer作为函数索引
│ 提取函数索引=18 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 根据索引获取JavaInfo │ JavaInfo结构体包含DexCode指针
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 获取DexCode │ 包含VMP加密后的insns[]数组
│ 栈地址: SP+var_1460│ ← 指令指针存放位置
└──────────┬──────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ vm_parse (地址: 0x29b70) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 超大switch dispatcher │ │
│ │ (被OLLVM混淆, 几千个case) │ │
│ └───────────┬───────────┬───────────┬───────────┬─────────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │invoke │ │ const │ │ iget │ │ iput │ ... │
│ │-super │ │ │ │-object │ │-object │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │ │ │ │ │
│ └───────────┴───────────┴───────────┘ │
│ │ │
│ ▼ │
│ JNI函数调用实现 │
│ (FindClass, GetMethodID, │
│ CallNonVirtualMethod等) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ 函数执行完毕 │
└─────────────────────┘
🔬 第四部分:指令还原详解
4.1 分析方法:逆向追踪
由于vm_parse被OLLVM严重混淆(FLA+BCF),F5无法使用,采用以下策略:
┌───────────────────────────────────────────────────────────────┐
│ 逆向追踪分析策略 │
├───────────────────────────────────────────────────────────────┤
│ │
│ 1. 在GetMethodID/FindClass等JNI函数下断点 │
│ ↓ │
│ 2. 观察参数值(类名、方法名、签名) │
│ ↓ │
│ 3. 反向追踪这些参数从哪里来 │
│ ↓ │
│ 4. 找到读取VMP指令的位置 │
│ ↓ │
│ 5. 建立 VMP opcode ↔ 真实opcode 映射关系 │
│ │
└───────────────────────────────────────────────────────────────┘
4.2 关键断点追踪
在GetMethodID断点观察:
.text:7517CF9988 LDR X8, [X8, #0x30] ; FindClass
.text:7517CF999C LDR X8, [X8, #0x108] ; GetMethodID
; 寄存器内容:
; X2 = "onCreate" ; 方法名
; X3 = "(Landroid/os/Bundle;)V" ; 方法签名
方法信息结构体:
struct MethodInfo {
char* class_name; // "android/support/v4/app/FragmentActivity"
char* method_sig; // "(Landroid/os/Bundle;)V"
char* method_name; // "onCreate"
};
IDAPython提取信息:
base = 0x7517D666A0
index = 0x5C
# 获取类名
idc.GetString(idc.Qword(idc.Qword(idc.Qword(base) + index * 8) + 8 * 0))
# → "android/support/v4/app/FragmentActivity"
# 获取方法签名
idc.GetString(idc.Qword(idc.Qword(idc.Qword(base) + index * 8) + 8 * 1))
# → "(Landroid/os/Bundle;)V"
# 获取方法名
idc.GetString(idc.Qword(idc.Qword(idc.Qword(base) + index * 8) + 8 * 2))
# → "onCreate"
4.3 指令指针追踪脚本
import re
def fn_f8():
idaapi.step_over()
GetDebuggerEvent(WFNE_SUSP | WFNE_CONT, -1)
def run_next():
fn_f8()
pc = idc.GetRegValue('pc')
asm_str = idc.GetDisasm(pc)
# 查找写入指令指针的位置
match = re.match(r'STR\s+(\S+),\s\[SP,#0x15A0\+var_1460\]', asm_str, re.I)
if match:
reg = match.group(1)
print(f'Found instruction pointer store at {hex(pc)}: {asm_str}')
return
run_next()
run_next()
4.4 逐条指令解析
第一条指令: A3 20 5C 00 21 00
┌────────────────────────────────────────────────────────────────┐
│ VMP指令: A3 20 5C 00 21 00 │
├────────────────────────────────────────────────────────────────┤
│ 解析过程: │
│ ┌──────┬──────┬──────────┬──────────┐ │
│ │ A3 │ 20 │ 5C 00 │ 21 00 │ │
│ ├──────┴──────┼──────────┼──────────┤ │
│ │ A|G|op │ BBBB │ F|E|D|C │ │
│ │ 2|0|A3 │ 0x5C │ 0|0|2|1 │ │
│ └─────────────┴──────────┴──────────┘ │
│ │
│ A = 2 (2个参数) │
│ op = 0xA3 → 映射到真实opcode 0x6F (invoke-super) │
│ BBBB = 0x5C → 方法索引(从MethodInfo表查找) │
│ 21 & 0xF = 1 → 参数寄存器 │
├────────────────────────────────────────────────────────────────┤
│ 真实指令: │
│ invoke-super {p0, p1}, │
│ Landroidx/fragment/app/FragmentActivity;-> │
│ onCreate(Landroid/os/Bundle;)V │
├────────────────────────────────────────────────────────────────┤
│ 汇编执行流程: │
│ LDR X8, [SP,#0x15A0+var_1460] ; 取指令指针 │
│ LDRH W26, [X8,#4] ; 取0x0021 │
│ LDRH W8, [X8,#2] ; 取0x005C(方法索引) │
│ STR W8, [SP,#0x15A0+var_7F4] ; 保存方法索引 │
│ AND X8, X26, #0xF ; 解密参数: 0x21→0x01 │
└────────────────────────────────────────────────────────────────┘
第二条指令: 6B 10
┌────────────────────────────────────────────────────────────────┐
│ VMP指令: 6B 10 │
├────────────────────────────────────────────────────────────────┤
│ 6B → 映射到 0x12 (const) │
│ 10 → v0, 0x1 │
├────────────────────────────────────────────────────────────────┤
│ 真实指令: const v0, 0x1 │
└────────────────────────────────────────────────────────────────┘
第三条指令: CC 20 13 02 01 00
┌────────────────────────────────────────────────────────────────┐
│ VMP指令: CC 20 13 02 01 00 │
├────────────────────────────────────────────────────────────────┤
│ CC → 映射到 0x6E (invoke-virtual) │
│ 0213 → 方法索引 (requestWindowFeature) │
│ 0001 → 参数 │
├────────────────────────────────────────────────────────────────┤
│ 真实指令: │
│ invoke-virtual {p0, v0}, │
│ Lcom/abing/appvmp/BaseActivity;->requestWindowFeature(I)Z │
└────────────────────────────────────────────────────────────────┘
完整映射表
┌─────────────────────────────────────────────────────────────────────────┐
│ VMP Opcode ↔ 真实DEX Opcode 映射表 │
├──────────────────────────┬──────────────────────────┬───────────────────┤
│ VMP 指令 │ 真实 DEX 指令 │ 操作类型 │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ A3 20 5C 00 01 00 │ 6F 20 02 15 21 00 │ invoke-super │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ 6B 10 │ 12 10 │ const │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ CC 20 13 02 01 00 │ 6E 20 96 B1 01 00 │ invoke-virtual │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ 55 11 6D 00 │ 5B 11 80 69 │ iput-object │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ 53 10 6D 00 │ 54 10 80 69 │ iget-object │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ 72 10 60 01 00 00 │ 71 10 AD AD 00 00 │ invoke-static │
├──────────────────────────┼──────────────────────────┼───────────────────┤
│ 69 00 │ 0E 00 │ return-void │
└──────────────────────────┴──────────────────────────┴───────────────────┘
🔧 第五部分:DEX修复
5.1 修复前后对比
修复前(VMP保护):
protected void onCreate(Bundle paramBundle) {
JniLib.cV(new Object[] { this, paramBundle, Integer.valueOf(18) });
throw new VerifyError("bad dex opcode");
}
修复后(还原真实逻辑):
protected void onCreate(Bundle paramBundle) {
super.onCreate(paramBundle);
requestWindowFeature(1);
this.a = this;
c.a(this.a);
}
5.2 修复注意事项
┌─────────────────────────────────────────────────────────────────────────┐
│ DEX修复注意事项 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 寄存器数量必须正确 │
│ - 修改 registersSize 字段 │
│ - 否则 dex2jar 转换失败 │
│ │
│ 2. 指令长度对齐 │
│ - 多余位置用 NOP (0x0000) 填充 │
│ - 最后确保有 return-void (0x0E00) │
│ │
│ 3. 方法索引需要重新计算 │
│ - VMP的0x5C要转换为真实DEX的方法索引 │
│ - 需要在DEX的method_ids表中查找 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
📊 第六部分:总结
6.1 VMP技术特点
┌─────────────────────────────────────────────────────────────────────────┐
│ VMP技术特点分析 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ✓ 使用JNI解释执行opcode │
│ └─ 最终通过 FindClass, GetMethodID, CallXxxMethod 实现 │
│ │
│ ✓ Opcode被替换 │
│ └─ 0xA3 → 0x6F (invoke-super) │
│ └─ 0x6B → 0x12 (const) │
│ └─ 0xCC → 0x6E (invoke-virtual) │
│ │
│ ✓ 参数寄存器编号不变 │
│ └─ A|G字段保持原样,便于VMP解析 │
│ │
│ ✓ 方法信息预存储 │
│ └─ 类名、方法名、签名存储在全局表中 │
│ └─ 用索引快速查找,提高效率 │
│ │
│ ✓ OLLVM混淆保护 │
│ └─ 控制流平坦化 (FLA) │
│ └─ 虚假控制流 (BCF) │
│ └─ 导致IDA F5失效 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
6.2 分析方法论
分析VMP的关键步骤
① 绕过反调试
↓
② Dump内存中的DEX
↓
③ 识别VMP调用特征 (JniLib.cV)
↓
④ 分析数据结构 (JavaInfo, DexCode)
↓
⑤ 在JNI函数下断点逆向追踪
↓
⑥ 建立opcode映射表
↓
⑦ 还原真实指令并修复DEX
6.3 防护建议
┌─────────────────────────────────────────────────────────────────────────┐
│ 当前VMP的弱点: │
│ • 仍依赖JNI调用,可以hook JNI函数获取信息 │
│ • opcode只是简单替换,存在固定映射关系 │
│ • 效率较低(每条指令都需JNI调用) │
│ │
│ 更安全的方案: │
│ • Java2C + ARM指令虚拟化 │
│ • 自定义完整解释器(不依赖JNI) │
│ • 动态opcode映射(每次加载不同) │
└─────────────────────────────────────────────────────────────────────────┘
一、问题根源分析
这段代码无法被IDA自动识别为函数的主要原因有:
1. 代码位于非标准段 (.text&ARM.extab)
.text&ARM.extab:0000000000018F94
- 这是 ARM Exception Table 段,通常存放异常处理信息
- IDA默认不会在此段进行函数识别
- 这是一种反调试/反分析技术:将可执行代码伪装成异常表数据
2. 缺少标准函数序言
STP X28, X27, [SP,#-0x60]! ; 直接开始保存寄存器
STP X26, X25, [SP,#0x10]
- 没有标准的
STP X29, X30, [SP, #-XX]!开头 - IDA的启发式函数识别算法无法匹配
3. 嵌入了伪造的数据
.text&ARM.extab:0000000000019398 DCQ 0xE04FC5930BA00336, ...
.text&ARM.extab:00000000000193B0 DCQ 0x129041F3DF695441, ...
- 在代码中间插入了64位数据 (DCQ = Define Quad word)
- 这些数据会破坏代码流分析
- 这是典型的代码/数据混淆技术
4. 使用间接跳转表
LDRSW X8, [X20,X9,LSL#2] ; 从跳转表加载偏移
ADD X8, X8, X20 ; 计算目标地址
BR X8 ; 间接跳转
```
- Switch语句通过计算跳转,IDA难以追踪所有分支
- 跳转表地址动态计算
### 5. **混淆的函数名**
```
j__lS0lIllO_l$0lSll_5l$$IIllSIlI05SlISS0I_5Ol$lO0llS5$
- 超长且无意义的名称
- 包含特殊字符
$,增加分析难度
二、解决方案(多种方法)
方法1: 强制定义函数(最快速)
操作步骤:
- 定位到函数起始地址
0x18F94 - 按
P键(Create Function)强制创建函数 - 如果失败,先执行以下操作:
# IDA Python 脚本
import idaapi
import idc
# 函数起始地址
func_start = 0x18F94
func_end = 0x19454 # RET指令地址 + 4
# 1. 取消现有定义
idc.del_items(func_start, idc.DELIT_SIMPLE, func_end - func_start)
# 2. 将区域标记为代码
idc.create_insn(func_start)
# 3. 强制创建函数
idc.add_func(func_start, func_end)
print(f"Function created at 0x{func_start:X}")
方法2: 处理嵌入的数据块
问题代码段:
0x19398: DCQ 0xE04FC5930BA00336 ; 这些不是代码!
0x193A0: DCQ 0x76D66DD5BB77DF45
...
0x193C8: DCQ 0x8CB0EE2B2636D202
0x193D0: MOV W8, #3 ; 代码恢复
IDA Python 修复脚本:
import idc
import idaapi
# 数据块地址范围
data_start = 0x19398
data_end = 0x193D0
# 1. 删除错误的指令定义
idc.del_items(data_start, idc.DELIT_SIMPLE, data_end - data_start)
# 2. 定义为数据
for addr in range(data_start, data_end, 8):
idc.create_qword(addr) # 定义为64位数据
idc.set_cmt(addr, "Fake data for anti-analysis", 0)
# 3. 从数据块后重新创建代码
idc.create_insn(data_end)
# 4. 重新分析函数
idc.plan_and_wait(0x18F94, 0x19454)
方法3: 修复跳转表引用
IDA Python 脚本:
import idc
import idaapi
# 跳转表地址
jump_table_addr = 0x19458
# Switch语句地址
switch_addr = 0x1901C
# 1. 定义跳转表
for i in range(17): # 17个case
offset_addr = jump_table_addr + i * 4
idc.create_dword(offset_addr)
# 读取偏移值
offset = idc.get_wide_dword(offset_addr)
# 计算目标地址(相对于跳转表基址)
target = jump_table_addr + offset
# 添加代码交叉引用
idc.add_cref(switch_addr, target, idc.fl_JN)
print(f"Case {i}: offset=0x{offset:X}, target=0x{target:X}")
# 2. 在switch地址添加注释
idc.set_cmt(switch_addr, f"Switch jump table at 0x{jump_table_addr:X}", 1)
方法4: 完整自动化修复脚本
"""
IDA Pro 自动修复脚本
修复 .text&ARM.extab 段中的混淆函数
"""
import idc
import idaapi
import ida_segment
import ida_funcs
def fix_obfuscated_function():
"""修复混淆的函数"""
# ========== 配置 ==========
func_start = 0x18F94
func_end = 0x19454
fake_data_start = 0x19398
fake_data_end = 0x193D0
jump_table = 0x19458
num_cases = 17
print("[+] Starting function recovery...")
# ========== 步骤1: 清理现有定义 ==========
print("[*] Step 1: Cleaning existing definitions...")
idc.del_items(func_start, idc.DELIT_EXPAND, func_end - func_start)
# ========== 步骤2: 处理嵌入的假数据 ==========
print("[*] Step 2: Handling fake embedded data...")
for addr in range(fake_data_start, fake_data_end, 8):
idc.del_items(addr, idc.DELIT_SIMPLE, 8)
idc.create_qword(addr)
idc.set_cmt(addr, "Anti-analysis fake data", 0)
# ========== 步骤3: 重新创建代码 ==========
print("[*] Step 3: Recreating code...")
# 从函数开始处创建指令
current_addr = func_start
while current_addr < fake_data_start:
if idc.create_insn(current_addr) == 0:
print(f"[!] Failed to create instruction at 0x{current_addr:X}")
current_addr += 4
else:
current_addr = idc.next_head(current_addr)
# 跳过假数据区域
current_addr = fake_data_end
while current_addr < func_end:
if idc.create_insn(current_addr) == 0:
current_addr += 4
else:
current_addr = idc.next_head(current_addr)
# ========== 步骤4: 修复跳转表 ==========
print("[*] Step 4: Fixing jump table...")
switch_insn = 0x1901C # BR X8 的地址
for i in range(num_cases):
offset_addr = jump_table + i * 4
idc.create_dword(offset_addr)
# 读取偏移(有符号)
offset = idc.get_wide_dword(offset_addr)
if offset & 0x80000000: # 负数
offset = offset - 0x100000000
target = jump_table + offset
# 确保目标是代码
idc.create_insn(target)
# 添加交叉引用
idc.add_cref(switch_insn, target, idc.fl_JN)
# 添加注释
idc.set_cmt(offset_addr, f"Case {i} -> 0x{target:X}", 0)
# ========== 步骤5: 创建函数 ==========
print("[*] Step 5: Creating function...")
if idc.add_func(func_start, func_end):
print(f"[+] Function successfully created at 0x{func_start:X}")
# 设置函数名
new_name = "parse_and_allocate_data_structures"
idc.set_name(func_start, new_name, idc.SN_FORCE)
print(f"[+] Function renamed to: {new_name}")
# 添加函数注释
idc.set_func_cmt(func_start,
"Deobfuscated function\n"
"Original name: j__lS0lIllO_l$0lSll_5l$$IIllSIlI05SlISS0I_5Ol$lO0llS5$\n"
"Function parses input data and allocates multiple data structures\n"
"Uses state machine with 17 cases for control flow obfuscation",
1)
else:
print("[!] Failed to create function, trying manual analysis...")
ida_funcs