领域特定虚拟机DSVM指令完整还原
副标题:一款针对 UE4 移动游戏外挂的深度混淆分析与反混淆实践
日期:2026-06-21
标签:ARM64 · Unicorn · OLLVM · DSVM · UE4 · PhysX · 反混淆 · 字节码解释器
摘要
本文记录了对 PhysX-遍历.sh(一个 ARM64 ELF 可执行文件)进行逆向分析的完整过程。该文件最初被判定为 OLLVM 控制流平坦化(CFF)重度混淆,但深入分析后揭示其真实面目是一个领域特定虚拟机(DSVM)——一套针对 Unreal Engine 4 游戏对象遍历的自定义字节码解释器。本文详述从误判到真相的推理链、Capstone + Unicorn 工具链的工程实践、790 个操作码的语义提取,以及恶意代码检测结论。
1. 引言
1.1 目标概况
PhysX.sh 基本属性:
| 属性 |
值 |
| 文件大小 |
198,496 字节(193.8 KB) |
| 文件类型 |
AArch64 ELF 可执行文件(PIE),扩展名伪装为 .sh |
| 符号状态 |
完全剥离(stripped) |
| 编译器 |
Clang 14.0.7, Android NDK r450784d1 |
| 外部导入 |
76 个函数(libc + libdl + libm) |
| 目标游戏 |
7 款 UE4 手游 |
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)保护的函数使用非传统序言:
paciasp ; PAC 认证
sub sp, sp, #0x60 ; 分配栈空间
stp x29, x30, [sp, #0x30] ; 保存帧指针
扩展函数检测以包含 PAC 序言后,函数数量从 238 跃升到 291。0x18234 区域实际包含 3 个独立函数:
0x18234 - 0x1828c (88 bytes) : VTable wrapper
0x1828c - 0x19250 (4036 bytes) : 字节码解释器主体 ★
0x19250 - 0x19308 (184 bytes) : Cleanup helper
3.2 字节码协议发现
对 0x1828c 入口代码的分析发现了关键模式:
0x182ec: ldrb w11, [x8] ; 读取首字节
0x182f4: cmp w11, #0x67 ; 'g' = 0x67
0x182fc: ldrb w12, [x8, #1] ; 读取第二字节
0x18300: cmp w12, #0x73 ; 's' = 0x73
0x18308: add x8, x8, #2 ; 消费 "gs" 前缀
; --- 操作码分发 ---
0x18330: sub w12, w11, #0x31 ; byte - '1' = 操作码索引
0x18334: cmp w12, #0x43 ; 上限检查
0x1833c: adrp x13, #0x9000 ; 跳转表基址
0x18348: ldrh w15, [x13, x12, lsl #1] ; table[opcode]
0x1834c: add x14, x14, x15, lsl #2 ; target = base + offset*4
0x18350: br x14 ; 分发跳转
这是经典的字节码解释器分发循环。"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'-'9' → ParseInt (0x1aa6c)
算法: value = value*10 + digit - '0'
实现: madd 累加器循环
'L' → ParseStruct (0x19304) — UE4 结构体布局描述符
'T','X' → ParseTypeTag (0x174fc) — 游戏对象类型标识
'N','s' → ReadNameTable (0x15e28) — UE4 FName 全局池查询
INIT 类(218 个,27.6%)— 上下文初始化
InitScan(0x1a400)是最频繁调用的处理器,每次切换字段类型时设置目标缓冲区地址、字段映射表和起始偏移。
RECURSE 类(122 个,15.4%)— 递归嵌套遍历
'i', 'q' → 递归调用 parse_bytecode (bl 0x18288)
子上下文: Component→子Component, Actor→Component
专用递归: World / Actor / Physics / Animation / AI / Network
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 解释器伪代码
parse_bytecode(ctx):
if remaining < 2: return NULL
if cursor[0]=='g' && cursor[1]=='s':
cursor += 2; flag |= GS_ACTIVE
while cursor < end:
opcode = *cursor++
if '1' <= opcode <= '9': parse_integer(ctx, opcode)
elif opcode == 'L': parse_struct_layout(ctx)
elif opcode == 'T': parse_type_tag(ctx)
elif opcode == 'N': lookup_name_table(ctx)
elif opcode in ('i','q'): recurse_sub_object(ctx)
else: dispatch_extended(ctx, opcode)
if flag & PARSE_COMPLETE: break
return ctx->result
4.5 核心 PhysX 遍历路径
UPrimitiveComponent → BodyInstance → PxRigidActor
├─ getGlobalPose() → PxTransform (世界坐标)
├─ getNbShapes() → int (碰撞体数量)
└─ getShapes(buffer, n) → PxShape[]
└─ PxShape::getLocalPose() → 局部 PxTransform
└─ getGeometry() → 球/盒/胶囊/凸包 参数
完整的内存偏移链:
Component + 0x0170 → FBodyInstance
BodyInstance + 0x0018 → PxRigidActor*
PxRigidActor + 0x0030 → PxTransform (缓存世界姿态)
+0x00: q.x, +0x04: q.y, +0x08: q.z, +0x0C: q.w
+0x10: p.x, +0x14: p.y, +0x18: p.z
验证: 0.5 < norm(q) < 1.5 → valid; else try +0x0040
PxRigidActor + 0x0058 → Shape 链表头 → 遍历 PxShape*
PxShape + 0x0020 → localPose (相对 Actor 的局部变换)
PxShape + 0x0030 → geometry 类型 + 参数
+0x00: type (0=球, 2=胶囊, 3=盒, 4=凸包)
+0x08: BOX→halfExtents / SPHERE→radius / CAPSULE→radius+halfHeight
PxShape + 0x0050 → userData (回指 UPrimitiveComponent*)
5. Unicorn 动态模拟验证
5.1 模拟器设计
构建了专用 Unicorn 全模拟器以验证静态分析结论。核心参数:
内存布局:
ELF 映射: 0x00000000 - 0x00040000 (ALL-RWX, 绕过 BTI 权限问题)
堆栈: 0x40000000 - 0x41000000 (16 MB)
堆: 0x50000000 - 0x51000000 (16 MB, bump allocator)
钩子系统:
- 76 个 PLT 函数全量模拟
- 20+ syscall 处理(exit 抑制/mmap/write 等)
- 全 PLT 范围钩子 (0x2cf70 - 0x2d450)
- 基本块级执行追踪
5.2 关键 PLT 钩子
# malloc — bump allocator
alloc_addr = (self.heap_ptr + 15) & ~15
self.heap_ptr = alloc_addr + size_arg
uc.reg_write(UC_ARM64_REG_X0, alloc_addr)
# ioctl — 返回成功,避免无限重试
uc.reg_write(UC_ARM64_REG_X0, 0)
# vasprintf — 分配有效缓冲区,避免 NULL 解引用
uc.mem_write(alloc_addr, b'0\x00')
uc.mem_write(strp_ptr, struct.pack('<Q', alloc_addr))
uc.reg_write(UC_ARM64_REG_X0, 1)
5.3 执行追踪结果
使用合成字节码 "gs1a" 输入,成功追踪解释器完整执行路径(50,000 条指令,295 个唯一基本块):
0x1828c: 序言 → 0x182ec: 魔数检查 → "gs" 匹配 ✓
→ 0x18350: 操作码 '1' 分发 → 0x18354 (case_01)
→ bl ParseInt (0x1aa6c)
→ 初始化解析上下文 → ASCII→整数 madd 循环
[x10 = x10*10 + digit - 0x30]
← 返回到 0x18360 → 主循环 → 流结束检查 → ret (正常退出)
其他输入验证一致:"gsi0" 触发递归处理器,"gsL0" 触发结构体解析,"gs12ab" 展示连续多操作码分发。
6. 技术难点与解决方案
6.1 PAC 序言导致的函数边界误判
传统 stp x29, x30 检测遗漏了 PAC 保护函数。扩展为三种序言模式后函数数从 238 增至 291:
# Pattern 1: stp x29, x30, [sp, #...] (标准)
# Pattern 2: paciasp / sub sp, sp, #N (PAC保护)
# Pattern 3: sub sp, sp, #N / stp x29, x30, [sp, #M] (大栈帧)
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 偏移,而是外挂自身运行时上下文字段索引:
ctx + 0x0a40 = GWorld* (运行时解析后存储的指针)
ctx + 0x0a48 = GNames*
ctx + 0x0170 = BodyInstance
ctx + 0x01d8 = RootComponent*
实际的 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") — 查找游戏进程 PID
popen("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. 工具链与产出
相关代码在仓库https://github.com/brszzz/android-sandbox
分析工具(过程中使用到的代码):
├── physx_deobfuscator.py # 76-PLT-hook Unicorn 全模拟器
├── trace_interpreter.py # 字节码解释器动态追踪器
├── extract_opcodes.py # 操作码语义自动提取器
└── malware_scan.py # 恶意行为检测扫描器
分析产出:
├── complete_opcodes.json # 790 操作码完整映射
├── dispatcher_analysis.json # 53 跳转表数据
├── complete_offset_reference.md # 99 个结构偏移参考
├── physx_memory_chain.h # PhysX 数据链路 (C++ 头文件)
├── physx_traversal_demo.h # 遍历 + 掩体检测 Demo
├── demo_main.cpp # ESP 渲染 + 掩体输出主程序
├── cover_detection_detail.cpp # 掩体检测原理详解
└── deobfuscation_results.json # Unicorn 执行追踪数据
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)