提到 VM 保护,多数人的第一反应是 VMProtect/自研栈管理/通用虚拟指令集/虚拟上下文。但现实中还存在另一条技术路线——把一段专用的数据处理逻辑(解密、解压、解析)改写成"字节码 + 解释器"的形式,仅凭一个固定循环驱动。这类 VM 不做通用计算,控制流几乎是一条直线,语义全部藏在数据流里。
我们暂时管这种类型叫做非标准 VM。它与标准 VM 的差异如下:
本文的目标函数是 sub_522E8:一个 0x6D60 字节(约 28KB)的巨型函数,样本本身带花指令与 CFF 混淆(篇幅所限不展开去混淆过程)。于是分析结合到动态侧:用 unidbg 跑出全量指令级 trace,从数据中找结构。
sub_522E8的控制流图
对 trace 搜索 `0x523B0`与`0x5814c`(相关trace日志附件会提供)
x0指向模块自身的只读数据段(0x4027fbxx)——字节码常驻在 SO 里,解释器按 32 位字消费。循环尾部(0x5814C)做 `add x0, x8, #4` 后写回指针。仅凭循环还不能断定是 VM,它也可能只是普通数据循环。继续看解码部分。
这 17 条移位是无条件、恒定移位量的——不对数据做任何判断,只是机械拆包。这正是"指令解码器"的形态:与数据无关、每次都执行、输出喂给后续逻辑。
证据三:两级分发结构
0x4005245C: "and w15, w8, #0x3f" ; w15 ← 低 6 位
低 6 位 `w15 = word & 0x3f` 是**第一级操作码**。对 12 个样本共 92513 次取指统计 w15 分布(共出现 29 种取值,下表为前 15 种,占 97.6%)
分布高度集中 :0x35 一个操作码占 42.85%。如果这是普通数据处理的 switch,分支频率不会如此悬殊;这正是"指令序列"的特征——程序里总有几个高频指令。
第二级操作码 藏在 w15=0x35 内部,解码公式为:
w10 = ror32((word & 0xfff) - 0x35, 6)
trace切片代码(trace009.txt 1760306行):
逐级比较,最终落入 0x21 的 handler 0x40057D7C 对应的伪c如下:
对 12 样本全量统计,w10 共出现24 种取值
至此"两级分发"成立:w15 是 29 个大类,w15=0x35 内部再按 w10 分出 24 种编码。这是与"单级跳转表"式 VM 的显著差异。
证据四:跨 handler 共享的状态载体
所有 handler 都通过栈上的两块共享区域通信
跨 handler 交互实例一:寄存器文件 (trace009.txt)
0x35,0x21写table[6],0x2f写table[7],0x35,0x13同时读table[6]与table[7],3个handler中只有一张共享的table表进行交互。
跨 handler 交互实例二:数据内存
0x30(写数据内存)把寄存器文件里的 64 位状态暂存到数据内存槽位,随后的 0x2f(取指针)把它读回——两个 handler 通过数据内存交换数据。
判定结论与"回绕"机制
四条证据合起来:固定取指循环→ 恒定解码前导→ 两级分发→ 共享寄存器文件。这是一个 VM,且控制流近似直线——但并非完全没有循环。
三种状态在主循环入口的差异
状态0:0x58168 → 0x581F0 魔数检查 → 0x52394,走完整入口,先查第一边界 `cmp x0,[sp+0x20]`(相等则从 0x57F38 退出),再查第二边界 `cmp x0,[sp+0x28]`(相等则从 0x5902C 退出),还有 `cbz x0` 空指针检查,出现次数最多,应该是普通顺序执行。
状态2:0x5816C → 0x523A0,跳过第一边界检查([sp+0x20] 终点判断),只查第二边界与空指针,回绕前过渡迭代的快速路径。
状态3:0x58178 回绕路径 → 0x581F0 魔数检查 → 0x52394,指针已被 0x581EC 改写为回绕目标,从目标地址重新取指,循环回绕。
字节码里没有任意跳转指令。它只有一种受控的循环原语——字节码先把"回绕目标"写进 [x27-0x18]、把"循环状态"写进 [x27-0x20],解释器尾部按状态决定继续还是回绕,就是说:数据流携带条件性,控制流不携带语义。打个比方:标准程序像有岔道的铁路,火车走哪条道岔就是程序的意思;这个 VM 像一条环形传送带,轨道永远是那一圈,工人(handler)往工件(表里的值)上贴标签,传送带尽头的固定闸门(循环尾部)看一眼标签,决定"再转一圈"还是"放出去"。轨道从不变,货物决定一切。这与标准 VM"用跳转指令实现程序逻辑"的思路截然不同,也就是为什么静态分析无法识别这个VM的原因。
语义还原不需要一条条硬啃。我的方法是用unidbg协助倒推先找到所有 handler 中唯一对外产生输出的那条指令,然后沿数据流反推它的输入来自哪些 handler
`strb`(单字节写)只出现在一个地方:
0x40053420: "strb w10, [x8, x9]" 全 VM 唯一的字节输出点
我们先找0x78(unidbg反base64后第一个加密输出字节)的trace切片进行分析(trace009.txt)
从两个handler可以确认两条指令的语义:
w15=0x35,w10=0x20(异或):table[c] = table[a] ^ table[b]
w15=0x33(输出数据):*(uint8_t*)(table[d] + sext(off)) = (uint8_t)table[c]
w15=0x2f——数据内存取指针
语义:table[w] = *(uint64_t*)(data_area + bitfield_offset),这是字节码从数据内存取指针的指令
w15=0x30——写数据内存
语义:data[bitfield_offset] = table[a],与0x2f成对可以构成数据内存读写通道
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。