领域特定虚拟机DSVM指令完整还原
副标题:一款针对 UE4 移动游戏外挂的深度混淆分析与反混淆实践
摘要
本文记录了对 PhysX-遍历.sh(一个伪装为 shell 脚本的 ARM64 ELF 可执行文件)进行逆向分析的完整过程。该文件最初被判定为 OLLVM 控制流平坦化(CFF)重度混淆,但深入分析后揭示其真实面目是一个领域特定虚拟机(DSVM)——一套针对 Unreal Engine 4 游戏对象遍历的自定义字节码解释器。本文详述从误判到真相的推理链、Capstone + Unicorn 工具链的工程实践、790 个操作码的语义提取,以及恶意代码检测结论。
1. 引言
1.1 目标概况
PhysX-遍历.sh 提取自某游戏外挂 APK,基本属性:
| 属性 | 值 |
|---|---|
| 文件大小 | 198,496 字节(193.8 KB) |
| 文件类型 | AArch64 ELF 可执行文件(PIE),扩展名伪装为 .sh |
| 符号状态 | 完全剥离(stripped) |
| 编译器 | Clang 14.0.7, Android NDK r450784d1 |
| 外部导入 | 76 个函数(libc + libdl + libm) |
| 目标游戏 | 7 款腾讯 UE4 手游(PUBG Mobile、三角洲行动、CoD Mobile 等) |
1.2 初始判断
初始 OLLVM 检测器报告该文件含有 49 个 CFF 混淆函数(占 238 个函数的 20.6%),其中最严重的 0x14350 包含 1843 条指令和 113 个比较分支。这似乎是一个重度 OLLVM 混淆的典型案例——直到两个关键异常出现。
2. OLLVM 检测与第一道裂缝
2.1 检测方法
使用基于 Capstone 的静态分析器扫描 .text 段,对每个函数统计四个 CFF 特征:
- cmp 指令密度:调度器中比较链的数量
- 间接跳转(br reg):switch 分发器特征
- 状态变量模式:
mov/movz到w8的频率(OLLVM 常用状态寄存器) - 基本块拓扑:入度异常高的分发器块
检测到的 Top 10 可疑函数:
| 函数地址 | 指令数 | CMP数 | 间接跳转 | CFF评分 |
|---|---|---|---|---|
| 0x14350 | 1843 | 113 | 3 | 10 |
| 0x13374 | 414 | 49 | 2 | 10 |
| 0x16ae8 | 608 | 39 | 5 | 10 |
| 0x23608 | 323 | 31 | 7 | 10 |
| 0x18234 | 1031 | 65 | 13 | 8 |
| 0x20664 | 707 | 42 | 14 | 8 |
| 0x1eb28 | 500 | 40 | 4 | 8 |
| 0x1f4ec | 220 | 24 | 4 | 8 |
| 0x21a80 | 103 | 8 | 4 | 8 |
| 0x21c1c | 146 | 11 | 6 | 8 |
使用项目自带的 ollvm_deobfuscator.py 进一步确认了 7 个 CFF 函数及其状态变量和调度器地址。
2.2 异常出现
对 func_0x12f28 的 Unicorn 模拟触发了 4,998 次 syscall——这在 OLLVM 混淆函数中不可能出现(OLLVM 只修改控制流,不引入大量系统调用)。
更关键的异常在 0x18234:该函数被检测为单个 4124 字节的巨型函数,但内部包含 **12 个独立的”调度器”**——标准 OLLVM 每个函数只有一个分发器。
这两处异常迫使我们重新审视整个判断。
3. DSVM 架构揭示
3.1 PAC 序言修正函数边界
ARM64 PAC(Pointer Authentication Code,ARMv8.3-A)保护的函数使用非传统序言:
1 | paciasp ; PAC 认证 |
扩展函数检测以包含 PAC 序言后,函数数量从 238 跃升到 291。0x18234 区域实际包含 3 个独立函数:
1 | 0x18234 - 0x1828c (88 bytes) : VTable wrapper |
3.2 字节码协议发现
对 0x1828c 入口代码的分析发现了关键模式:
1 | 0x182ec: ldrb w11, [x8] ; 读取首字节 |
这是经典的字节码解释器分发循环。”gs”(0x67, 0x73)是魔数前缀,后续每个字节是操作码,通过跳转表映射到处理器。
3.3 架构判定:从 CFF 到 DSVM
| 维度 | 初始误判(CFF混淆) | 真实架构(DSVM) |
|---|---|---|
| 12个调度器 | 多级混淆状态机 | 操作码分配网络 |
| 2580个”状态” | 混淆状态值 | 合法操作码映射 |
| 80个case块 | 被混淆的真实块 | 操作码处理器 |
ldrh [table, idx*2] |
混淆查找表 | 操作码→处理器跳转表 |
递归 bl 0x18288 |
混淆间接调用 | 递归下降解析器 |
3.4 12 调度器架构
解释器包含 12 个调度器,各自负责不同的操作码范围和语义域:
| 调度器 | 地址 | 跳转表 | 语义域 |
|---|---|---|---|
| D1 | 0x18350 | 0x9818 | 主操作码表 |
| D2 | 0x18390 | 0x9abc | 字符串字段解析 |
| D3 | 0x183d4 | 0x98a0 | 数组/向量字段 |
| D4 | 0x1843c | 0x9b26 | 对象引用字段 |
| D5 | 0x18480 | 0x9afe | 变换/矩阵字段 |
| D6 | 0x185d8 | 0x9a78 | 标志枚举字段 |
| D7 | 0x1861c | 0x99fe | 物理属性字段 |
| D8 | 0x1865c | 0x99bc | 网络状态字段 |
| D9 | 0x186a0 | 0x98c6 | 渲染字段 |
| D10 | 0x18730 | 0x9a2e | 碰撞检测字段 |
| D11 | 0x18774 | 0x996a | 音频/动画字段 |
| D12 | 0x18818 | 0x991c | 输入/UI 字段 |
3.5 DSVM vs VMP 辨析
分析过程中自然提出了”这是否为 VMProtect”的疑问。从四个维度对比:
处理器语义。 VMP 处理器是通用的(Add/Sub/Push/Pop/Jmp/Call),而本系统所有处理器都是领域特定的——ParseInt(ASCII→整数)、HandleBone(骨骼矩阵)、HandlePhysics(PhysX 坐标)、HandleAI(行为树数据)。83 次 BL 调用中,领域特定调用的占比为 98.4%。
循环结构。 VMP 使用单层平面循环(一个中央分发器),本系统实现递归下降解析——33% 的调用是递归 bl 0x18288,支持嵌套子对象遍历。
字节码格式。 VMP 是紧密二进制编码。本系统使用 “gs” 魔数 + 单字节操作码的文本式格式,rodata 段 71% 为 printable 字符。
虚拟栈。 VMP 标志性地使用虚拟栈。本系统中未检测到虚拟栈模式——不存在单个寄存器大量用作 ldr/str 基址的情况。
结论: 这是一个借鉴 VMP 架构模式(跳转表分发、字节码解释)但指令集完全领域化的自定义虚拟机,类似于 SQL 引擎与通用 CPU 的关系。
4. 操作码语义提取与还原结果
4.1 提取方法
对 12 个调度器的 53 个跳转表进行静态解析。每个跳转表条目是 16 位偏移,指向解释器函数内的 case 块。通过解析每个 case 块的指令序列(特别是 bl 调用目标),建立操作码到处理器的映射。
4.2 五大操作码类别
READ 类(331 个,41.9%)— 字节流解析
1 | '1'-'9' → ParseInt (0x1aa6c) |
INIT 类(218 个,27.6%)— 上下文初始化
InitScan(0x1a400)是最频繁调用的处理器,每次切换字段类型时设置目标缓冲区地址、字段映射表和起始偏移。
RECURSE 类(122 个,15.4%)— 递归嵌套遍历
1 | 'i', 'q' → 递归调用 parse_bytecode (bl 0x18288) |
PROCESS 类(96 个,12.2%)— 数据计算
| 处理器 | 地址 | 功能 |
|---|---|---|
| HandleBone | 0x1b518 | 骨骼变换矩阵计算 |
| HandleVector | 0x1b3dc | FVector (X,Y,Z) 解析 |
| HandleFloat | 0x1a6a4 | IEEE 754 单精度浮点 |
| HandleEnum | 0x19f34 | 枚举值映射 |
| HandleTransform | 0x1b8e0 | FTransform 组合 |
STORE 类(27 个,3.4%)— 结果持久化
StoreField(0x16018)写入输出结构体,LinkFields(0x1a560)建立父子关系,Finalize(0x1924c)标记解析完成。
4.3 UE4 引擎覆盖
| UE4 子系统 | 处理器 | 遍历对象 |
|---|---|---|
| PhysX 物理 | HandlePhysics | PxScene → PxRigidActor → PxShape |
| 骨骼动画 | HandleBone + HandleSkeletal | USkeletalMeshComponent → FBoneTransform[] |
| 世界管理 | HandleWorld + HandleLevel | UWorld → ULevel → AActor[] |
| AI 系统 | HandleAI | UAIController → UBehaviorTree → UBlackboard |
| 网络复制 | HandleNet | AActor → FObjectReplicator |
| 渲染 | HandleRender | UPrimitiveComponent → FSceneProxy |
| 碰撞 | HandleCollision | FCollisionShape → FHitResult |
| 武器系统 | HandleWeapon + HandleDamage | AWeapon → ProjectileComponent |
4.4 解释器伪代码
1 | parse_bytecode(ctx): |
4.5 核心 PhysX 遍历路径
1 | UPrimitiveComponent → BodyInstance → PxRigidActor |
完整的内存偏移链:
1 | Component + 0x0170 → FBodyInstance |
5. Unicorn 动态模拟验证
5.1 模拟器设计
构建了专用 Unicorn 全模拟器以验证静态分析结论。核心参数:
1 | 内存布局: |
5.2 关键 PLT 钩子
1 | # malloc — bump allocator |
5.3 执行追踪结果
使用合成字节码 "gs1a" 输入,成功追踪解释器完整执行路径(50,000 条指令,295 个唯一基本块):
1 | 0x1828c: 序言 → 0x182ec: 魔数检查 → "gs" 匹配 ✓ |
其他输入验证一致:"gsi0" 触发递归处理器,"gsL0" 触发结构体解析,"gs12ab" 展示连续多操作码分发。
6. 技术难点与解决方案
6.1 PAC 序言导致的函数边界误判
传统 stp x29, x30 检测遗漏了 PAC 保护函数。扩展为三种序言模式后函数数从 238 增至 291:
1 | # Pattern 1: stp x29, x30, [sp, #...] (标准) |
6.2 跳转表与字符串数据的区分
rodata 段中同时包含跳转表(16 位偏移数组)和 C++ Itanium ABI demangler 名称表。解决方案:验证每个跳转表条目的目标地址是否在 .text 段内,排除指向 0x7xxx(字符串区)的条目。
6.3 多调度器状态变量追踪
12 个调度器使用不同状态变量(x9、x10、x12),同一寄存器在不同阶段持有不同语义。通过静态数据流分析追踪每个变量的定义-使用链,确定调度器间的传递关系。
6.4 外部函数依赖的模拟策略
76 个 PLT 导入函数需要合理模拟才能避免模拟器陷入循环或崩溃:
| 类别 | 函数 | 策略 |
|---|---|---|
| 内存 | malloc/free/realloc | bump allocator |
| I/O | ioctl/system/popen | 返回安全默认值 |
| 字符串 | strlen/strcmp/strchr | 直接在 Unicorn 内存操作 |
| 格式化 | vasprintf/printf | 分配虚拟缓冲区并填充最小有效值 |
6.5 偏移机制澄清
一个重要细节:二进制中的偏移不是 libUE4.so 偏移,而是外挂自身运行时上下文字段索引:
1 | ctx + 0x0a40 = GWorld* (运行时解析后存储的指针) |
实际的 libUE4.so 偏移(通常在 0x04000000+ 范围)不存在于 ARM 指令中的任何 MOVZ/MOVK 常量中。这与 DSVM 架构一致——外挂是通用引擎,所有游戏特定数据(GWorld 偏移、字节码程序)作为运行时配置加载。
6.6 恶意代码检测结论
对 76 个导入函数和全部内嵌字符串进行恶意行为扫描:
| 检测项 | 结果 |
|---|---|
| 网络通信 (socket/connect/send) | 无——0 个相关导入 |
| 文件删除 (remove/unlink) | 无 |
| 加密 (aes/rsa/sha) | 无 |
| 提权 (su/root 字符串) | 无 |
| 后门 (bind/accept) | 无 |
可疑函数的真实用途:
system("pidof -s %s")— 查找游戏进程 PIDpopen("ls -l /proc/*/exe | grep deleted")— 检测反调试opendir("/proc")— 遍历进程列表ioctl/mknod— 与内核外挂驱动通信fopen("/proc/%d/maps")— 读取游戏内存布局
结论:纯游戏外挂程序,不具备任何恶意软件特征。 没有网络功能意味着无法外传数据或接收远程指令。
7. 总结与启示
7.1 方法论
不要急于下结论。 初始”49 个 CFF 函数”的判断如果被接受,整个分析会走错方向。关键异常(4998 次 syscall、12 个调度器在一个函数中)必须追查到底。
动静结合的必要性。 纯静态分析只能看到跳转表和分发器,纯动态模拟会陷入无限循环。静态提取跳转表 + 动态验证调度流是揭示真相的必要组合。
编译器特征即信息。 PAC 指令和 BTI 着陆点不仅是保护特性,它们提供了函数边界和间接跳转目标的精确标记,反而辅助了逆向分析。
DSVM 是语义级混淆。 传统 OLLVM 混淆代码的执行逻辑,DSVM 混淆的是语义意图。面对 DSVM,理解”它在做什么”(UE4 遍历)比”它怎么做的”(操作码分发)更重要。
7.2 安全启示
该 DSVM 体现了外挂开发的工业化水平:
- 多游戏支持:同一解释器 + 不同字节码程序 = 覆盖 7 款游戏
- 更新便利:修改遍历逻辑只需更换 rodata 中的字节码,无需重编译
- 分析抗性:DSVM 架构天然抵抗基于签名的检测
- 灵活部署:字节码程序可加密/压缩存储,运行时解密加载
这种架构级别的保护手段值得游戏安全从业者重视——单纯的特征码检测或完整性校验无法防御这种灵活的解释器架构。
附录
A. 工具链与产出
1 | 分析工具(自研): |
B. 参考文献
- Unreal Engine 4 Source:
Engine/Source/Runtime/Engine/ - NVIDIA PhysX SDK Documentation
- ARM Architecture Reference Manual ARMv8-A: Pointer Authentication
- Quarkslab: “Deobfuscating OLLVM with Unicorn” (2019)