首页
社区
课程
招聘
[原创]抖音 VMP 分析:从入口到 Dispatch
发表于: 2小时前 102

[原创]抖音 VMP 分析:从入口到 Dispatch

2小时前
102

抖音 VMP 分析:从入口到 Dispatch

作者: 人生导师
日期: 2026 年 5 月 22 日
版本: 抖音 38.1.0
so 加密文件:libmetasec_ml.so

最近在看抖音的签名保护,发现核心逻辑藏在一个自研的 VMP 里。这篇算是我的分析笔记,记录了从找到 VM 入口到搞清楚 dispatch loop 的过程。水平有限,如果有分析错误的地方欢迎指正。


一、找到 VM 入口

方法:trace + IDA 逐行对照

trace 是执行记录,格式长这样:

行号  : 绝对地址  [偏移]  "指令"  寄存器变化
923   : 0x71ee4bd618  [0x2a6618]  "sub sp, sp, #0xa0" (r)sp=0x725beaea40 (w)sp=0x725beae9a0

一份 trace 最少五百万行汇编,最多一千一百万行。我手上有三份不同请求的 trace,目前在分析最短的那份(五百万行),另外还有一份七百万行的留着交叉验证。

追法很朴素:从入口开始,一条一条看地址偏移的末位。ARM64 指令固定 4 字节,正常顺序执行的话偏移末位是 0 → 4 → 8 → C → 0 → 4 → 8 → C 循环递增。一旦这个节奏断了——比如从 0x2a6618 突然跳到 0x2ab240——就说明有跳转发生了,这时候去 IDA 里看这条指令是什么:bblblr、还是条件跳转。

但是也要看全,不能只盯末位。有时候两段不连续的代码正好拼在一起,末位看着是连续的,实际已经跳了。所以每次节奏"看起来正常"的时候也得瞄一眼完整偏移有没有突变。

遇到 bl(函数调用)就记录子函数的入口和返回点,确定它的范围。遇到 blr(间接调用)就更要注意,因为目标地址在寄存器里,可能跳到完全不同的地方。

包裹函数和真函数

从 7 神入口 0x2A6E38 往里追,很快遇到一个 bl

91f  : 0x71ee4bf434  [0x2a8434]  "bl #0x71ee4c2238"   // 包裹函数
922  : 0x71ee4c2240  [0x2ab240]  "b #0x71ee4bd618"    // 跳到真函数开头

这里的结构是:入口先调用一个包裹函数(wrapper),包裹函数里面再 b 跳转到真正干活的函数。b 不是 bl,不压返回地址,说明这是尾调用——包裹函数本身不做什么事,就是个跳板。

VM 入口定位

继续往下追,找到了关键调用:

c4d0  : 0x71ee4c4544  [0x2ad544]  "blr x8" (r)x8=0x71ee4c5a90
c4d1  : 0x71ee4c5a90  [0x2aea90]  "sub sp, sp, #0x30"  // VM 入口
2e0e3 : 0x71ee4c4548  [0x2ad548]  "ldp x29, x30, [sp, #0x20]"  // 返回后继续

blr x8 间接调用,跳到 0x2AEA90。从行号看,c4d12e0e3(返回点),中间执行了差不多 18 万行指令。这就是 VM 的主体了。

对比三份 trace:两份里这个 VM 入口内部自己循环了 11 次,一份循环了 10 次。说明 VM 不是一条直线跑完的,内部有 dispatch loop,根据输入数据的不同,循环次数会变。

但是有个好消息:VM 入口只被外部调用了一次。不用担心别的地方还会再次进入 VM、重新设置初始状态。分析范围是确定的。

VM 前的初始化段

既然 VM 入口只进一次,那进入之前的那段代码就是初始化逻辑——准备 VM 需要的上下文、参数、状态。

确定范围:

c465  : 0x71ee4b46cc  [0x29d6cc]  "stp x29, x30, [sp, #-0x60]!"  // 初始化开始
c4c4  : 0x71ee4b4780  [0x29d780]  "bl #0x71ee4c4518"              // 调用 VM

从行号 c465(50279)到 c4c4(50372),大概 93 行指令。这段就是进入 VM 前的全部准备工作。

另外还有一个封装函数 0x2AA36C,它是从 7 神入口到 VM 的桥梁。

阶段小结

内容 偏移 状态
7 神入口 0x2A6E38 已定位
包裹函数 → 真函数 0x2A84340x2A6618 已确认,hook 验证
VM 前初始化段 0x29D6CC ~ 0x29D780 已确定范围
VM 入口 0x2AEA90 已定位,内部循环 10~11 次

二、VM 前置初始化与架构猜想

EnterVM:7 神到 VM 的桥梁

从 7 神入口到 VM 之间的封装函数,地址 0x2AA36C

__int64 EnterVM(parser_obj@X0, cookie_map@X2, flag@W3, output@X8)
{
  if (!*(*(parser_obj+16)+48))    // VM 未启用,直接返回空
    return empty_output;
  if (!*(cookie_map+24))          // cookie_map 没数据,走 fallback
    return fallback;

  v10 = sub_2AA968(stack_buf);    // 在栈上构造临时缓冲区
  obj = *(*(sub_15F060(v10)) + 304);  // 取 vtable+304 处的对象
  val = sub_25BE84(...);          // 计算一个值
  *(obj+8) |= 0x80;              // 设置标志位
  sub_282144(obj, bitwise_op(val, dword_3D5DF0));  // 位运算处理

  // 关键:通过虚函数表调用 vm_entry
  vtable = *(*(parser_obj+16));
  func = vtable[0x60/8];         // 第 12 个虚函数槽位
  func(*(parser_obj+16), v10, cookie_map, flag);
}

逻辑很直白:检查两个前置条件(VM 启用 + cookie 有数据),然后通过虚函数表间接调用 vm_entry

从 trace 里确认的参数:

参数 寄存器 含义
parser_obj X0 0x71ee5fdb58 解析器对象
cookie_map X2 0x725beaee40 cookie 映射结构
flag W3 0x171 标志位
output X8 0x725beaed90 输出缓冲区

输出就是那堆签名 header:X-Perseus、X-Medusa、X-Argus、X-Helios、X-Ladon、X-Khronos、X-Gorgon。

vm_entry:初始化 VM 上下文

EnterVM 通过虚函数表跳到 vm_entry0x29D6CC),这就是那 93 行初始化段:

__int64 vm_entry(parser_obj@X0, input_buf@X1, flag@W3, output_buf@X8)
{
  // 1. 确保字节码表只初始化一次
  pthread_once(&vm_once_flag, vm_bytecode_init);

  // 2. 在栈上分配 ~25KB 的 VM 上下文
  ctx = vm_ctx_init(stack);

  // 3. 把外部参数映射到 VM 寄存器
  vm_ctx_set_reg(ctx, 8,  output_buf);   // 输出缓冲区
  vm_ctx_set_reg(ctx, 9,  parser_obj);   // parser 内部对象
  vm_ctx_set_reg(ctx, 10, input_buf);    // 输入数据
  vm_ctx_set_reg11_cookie_map(ctx);      // cookie_map
  vm_ctx_set_reg(ctx, 12, flag);         // 标志位 0x171

  // 4. 启动 VM
  vm_run(vm_bytecode_program, ctx);
}

干净利落,五步走完。重点是第 3 步——外部世界的参数通过 vm_ctx_set_reg 映射到 VM 的虚拟寄存器里,VM 内部只通过寄存器槽位来访问这些数据。

VM Context 结构体

vm_ctx_init0x2AD554)的实现:

__int64 vm_ctx_init(__int64 ctx) {
  *(uint32_t*)(ctx + 0) = 0;                 // PC/状态字清零
  *(uint64_t*)(ctx + 8) = 0;                 // 栈帧指针清零
  memset(ctx + 0x6050, 0, 0x280);            // 80 个通用寄存器清零
  *(uint64_t*)(ctx + 0x6058) = ctx + 0x6010; // reg_ptr 指向寄存器基址
  return ctx;
}

整个 VM 上下文大约 25KB,布局如下:

偏移        大小        用途
────────────────────────────────────────────────
0x0000      4 bytes    PC / 状态字
0x0008      8 bytes    栈帧指针(嵌套调用用)
0x0010      ~24KB      VM 执行栈(字节码运行时的临时数据区)
0x6010      0x40       保留寄存器 slot 0-7(不清零,VM 内部管理)
0x6050      0x280      通用寄存器 slot 8-79808 字节槽位,初始化为 00x6058      8 bytes    reg_ptr → ctx + 0x6010

寄存器存取公式:*(ctx + slot*8 + 0x6050) = value

slot 0-7 没被 memset 清零,说明是 VM 内部的特殊寄存器(PC、SP、条件标志之类的),由 dispatch loop 自己管理。

初始寄存器分配

Slot 偏移 含义
8 0x6090 0x725beaed90 output 缓冲区
9 0x6098 0x737662c110 parser 内部对象
10 0x60A0 0x725beae790 输入数据缓冲区
11 0x60A8 0x725beaee40 cookie_map
12 0x60B0 0x171 flag 标志位

vm_bytecode_init:字节码加载与 handler 注册

pthread_once 保证这个函数只跑一次。它干的事情:

1. 构造 handler 表

在栈上构造三张表:

  • CF handler 表:85 个条目(CF0 ~ CF54,十六进制),每个是一个 native 函数指针
  • G handler 表:9 个条目(G0 ~ G8),全局/内置操作码
  • 入口点描述表:3 个条目,描述 VM 程序的命名入口

2. 构建 VM 程序

program = sub_2AD3E0(
  &unk_387D30,   // 加密字节码数据(.rodata 段)
  0x30CD,        // 数据大小:12493 字节
  temp_buf,      // 临时缓冲区
  0,             // 未使用
  cf_handlers,   // 85 个 CF handler
  0x55,          // CF 数量
  g_handlers,    // 9 个 G handler
  9,             // G 数量
  entry_descs,   // 入口点描述表
  3              // 入口点数量
);

内部流程(sub_2CFBC8):

第一步:XOR 解密字节码

字节码存在 .rodata0x387D30 处,12493 字节,XOR 加密。解密 key 的选取:

int index = bytecode_size % entry_count;  // 0x30CD % 3 = 1
uint8_t key = entry_table[index].xor_key; // entry[1].xor_key = 0xF3
// 然后整块 XOR(NEON 加速,每次 32 字节)
for (i = 0; i < size; i++)
    bytecode_data[i] ^= 0xF3;

入口点描述表的结构:

条目 type XOR key 参数数量
entry[0] 0x52 'R' 0x1B 6
entry[1] 0x52 'R' 0xF3 4
entry[2] 0x52 'R' 0xF5 2

第二步:校验解密结果(可能是 CRC/hash,确保字节码完整)

第三步:解析字节码(原始字节流 → 指令数组 + 常量池 + 函数表 + 字符串表)

第四步:注册 handler(把 85 个 CF + 9 个 G 的函数指针绑定到程序对象)

3. 查找命名入口点

qword_3E64F8    = sub_2AD420(program, "F0");  // 备用入口
vm_bytecode_program = sub_2AD420(program, "F1");  // 主入口

程序对象内部有个 名称 → 字节码偏移 的哈希表。vm_entry 用的是 F1 入口,F0 目前没看到被调用。

vm_run:启动执行

vm_run0x2AD518)本身很薄:

__int64 vm_run(__int64 entry_point, __int64 vm_ctx) {
  exec_mode = *(entry_point + 56);           // 取执行模式索引
  exec_func = off_367F08[exec_mode];         // 从函数指针表选执行器
  call_frame[1] = entry_point;
  call_frame[0] = &vm_ctx;
  return exec_func(call_frame);
}

off_367F08 是一个三元素的函数指针表:

索引 地址 功能
0 0x2AE9AC 简单执行(直接调用入口点函数)
1 0x2AE9E0 带栈帧的执行(设置帧头后进 dispatch loop)
2 0x2AEA90 vm_execute_function:完整的 VM 函数执行器

实际走的是索引 2,也就是 vm_execute_function

vm_execute_function:设置栈帧并进入 dispatch loop

__int64 vm_execute_function(__int64 **call_frame) {
  entry_point = call_frame[1];
  vm_ctx = **call_frame;

  stack_size = entry_point[1];              // 从入口点取栈帧大小(0x1d0)
  prev_frame = *(vm_ctx + 8);              // 保存当前栈帧(嵌套调用恢复用)
  new_stack_top = *(vm_ctx + 0x6058) - stack_size;  // 计算新栈顶

  // 栈溢出检查
  if (new_stack_top < vm_ctx + 16) crash();

  // 构造栈帧头:{PC偏移=0x154b, 栈大小=0x1d0}
  frame_header = {*entry_point, stack_size};
  *(vm_ctx + 8) = &frame_header;           // 设置新栈帧

  // 进入 dispatch loop
  vm_dispatch_loop(entry_point + 8, vm_ctx);

  // 恢复上一层栈帧
  *(vm_ctx + 8) = *(*(vm_ctx + 8) + 8);
}

关键信息:字节码从偏移 0x154b 开始执行,VM 栈帧大小 0x1d0(464 字节)。

完整执行流程图

EnterVM (0x2AA36C)
│  检查 VM 启用 + cookie 有数据
│  通过虚函数表调用 ↓
│
vm_entry (0x29D6CC)
│  pthread_once → vm_bytecode_init
│  vm_ctx_init: 分配 25KB 上下文
│  设置 VM 寄存器 slot 8-12
│  vm_run(vm_bytecode_program, ctx)
│       │
│       ↓
│  vm_run (0x2AD518)
│       │  取 exec_mode → 选择执行器
│       │  exec_mode=2 → vm_execute_function
│       ↓
│  vm_execute_function (0x2AEA90)
│       │  设置栈帧: PC=0x154b, stack_size=0x1d0
│       │  栈溢出检查
│       ↓
│  vm_dispatch_loop (0x2AF5CC)
│       │  threaded dispatch: 每个 handler 直接跳下一个
│       │  opcode handler: 纯运算(ROR/DIV/LOAD/FADD/...)
│       │  CF handler: 调用 native 函数(85 个)
│       │  G handler: 全局操作(9 个)
│       │  循环 10~11 次(取决于输入)
│       ↓
│  返回签名结果
↓
输出: X-Perseus, X-Medusa, X-Argus, X-Helios, X-Ladon, X-Khronos, X-Gorgon

架构猜想

到这里,VM 的骨架已经清楚了:

  1. 这是一个寄存器机,不是栈机。有 80 个通用寄存器、独立的浮点区和条件标志区。
  2. Threaded dispatch:不走 switch-case,每个 handler 末尾直接跳下一个。这让静态分析很难追踪控制流,因为 IDA 看不到 handler 之间的调用关系。
  3. 字节码 XOR 加密:key 从入口点描述表里取,用 size % count 选索引。简单但够用——防止直接 dump 字节码做模式匹配。
  4. CF handler 是突破口:VM 内部的纯运算很难追,但 CF handler 是 VM 和外部世界的接口。85 个 CF handler 里肯定包含了 MD5、SHA、AES 之类的密码学原语,把它们逐个识别出来就能还原算法骨架。
  5. F0 和 F1 两个入口:目前只用了 F1,F0 可能是另一种签名模式或者调试入口。

vm_bytecode_init 的全局变量

地址 名称 用途
0x3E64F0 vm_once_flag pthread_once 标志
0x3E6500 vm_bytecode_program F1 入口点(主入口)
0x3E64F8 qword_3E64F8 F0 入口点(备用)
0x3D5DF0 dword_3D5DF0 EnterVM 位运算常量

三、正式开始的 VM 分析

前面看了初始化和架构猜想,现在该来搞实际的 VM 了。那个跳转表快 800 个 handler,再加上 94 个 CF 和 G handler,接近 900 个。

Dispatch Loop:VM 的心脏

; void __fastcall vm_dispatch_loop(__int64 instruction_stream, _DWORD *vm_ctx)
vm_dispatch_loop:
STP             X29, X30, [SP,#-0x60]!
STP             X28, X27, [SP,#0x10]
STP             X26, X25, [SP,#0x20]
STP             X24, X23, [SP,#0x30]
STP             X22, X21, [SP,#0x40]
STP             X20, X19, [SP,#0x50]
MOV             X29, SP
MOV             X20, X0          ; X20 = instruction_stream (字节码指令流)
LDR             X0, [X0]         ; X0 = *instruction_stream = 第一条指令的元数据指针
MOV             W8, #0x6050
ADRL            X24, opcode_handler_table  ; X24 = 跳转表基址
ADD             X23, X1, X8      ; X23 = vm_ctx + 0x6050 = 整数寄存器数组基址
LDR             X8, [X0,#0x28]   ; X8 = 第一条指令的 opcode 索引
MOV             W9, #0x61D0
ADD             X25, X1, X9      ; X25 = vm_ctx + 0x61D0 = 浮点寄存器区
MOV             W9, #0x6150
MOV             X19, X1          ; X19 = vm_ctx 基址
STR             WZR, [X1]        ; *(vm_ctx) = 0,PC 归零
LDR             X8, [X24,X8,LSL#3]  ; X8 = handler_table[first_opcode]
ADD             X26, X1, X9      ; X26 = vm_ctx + 0x6150 = 条件标志区
BR              X8               ; 跳到第一个 handler,开始执行

IDA 把下面的 handler 全识别为独立函数,但实际上不是函数,而是一个正常执行的流程。这就是 threaded dispatch——每个 handler 执行完直接 BR 跳到下一个 handler,永远不回到这个入口点。整个 VM 执行期间,这些寄存器全程保持不变:

寄存器 用途
X19 vm_ctx VM 上下文基址,*(X19) = PC
X20 instruction_stream 字节码指令流指针
X23 vm_ctx + 0x6050 整数寄存器数组(通过 X23 + idx*8 读写)
X24 opcode_handler_table handler 跳转表
X25 vm_ctx + 0x61D0 浮点寄存器区
X26 vm_ctx + 0x6150 条件标志区

记住这张表,后面所有 handler 都靠这些寄存器工作。

指令元数据结构

每条 VM 指令不只是一个 opcode + 操作数,它还有一个 48 字节的元数据结构:

偏移      内容
────────────────────────────
0x00      4字节操作数(编码了寄存器索引和立即数)
0x08      数据指针(某些指令用来存嵌入的地址)
0x28      下一条指令的 opcode 索引(dispatch 用)

所以你会在每个 handler 里看到这个寻址公式:

SMADDL X0, W8, W10, X9    ; X0 = PC_new * 48 + metadata_base

PC * 48 + base = 对应指令的元数据地址。48 就是那个到处出现的 #0x30

4 字节操作数编码

每条指令的核心操作数是 4 字节,但编码格式不统一,不同类型的指令拆法不一样:

31        16 15      8 7       0
┌──────────┬─────────┬─────────┐
│  HIWORD  │  BYTE1  │  BYTE0  │
└──────────┴─────────┴─────────┘

常见的拆法:

指令类型 BYTE0 BYTE1 HIWORD
STORE base 寄存器 value 寄存器 有符号偏移
ADD_IMM src 寄存器 dst 寄存器 有符号立即数
MOV_REG src 寄存器 (unused) dst 寄存器在 BYTE2
LOAD_INDIRECT (unused) dst 寄存器 有符号偏移

ARM64 里对应的解码指令:

  • AND X, X, #0xFF → 取 BYTE0
  • UBFX X, X, #8, #8 → 取 BYTE1
  • SBFX X, X, #0x10, #0x10 → 取 HIWORD 并符号扩展(偏移可以是负数)
  • LSR X, X, #0x10 → 取 HIWORD 不符号扩展

Handler 通用模板

看了几个 handler 之后,发现它们全是同一个模板:

1. 取操作数    → 从 X0 或 [X0,#offset] 拿 4 字节编码
2. 解码        → AND/UBFX/SBFX 拆出寄存器索引和立即数
3. PC++        → STR W_new, [X19](和运算无关,纯取指消费)
4. 读寄存器    → LDR X, [X23, Xidx, LSL#3]
5. 执行        → 一条核心操作(STR/ADD/LDR/...)
6. 算下一条地址 → SMADDL X0, PC, #48, base
7. 取 opcode   → LDR X, [X0, #0x28]
8. 查表        → LDR X, [X24, X, LSL#3]
9. 跳转        → BR X

区别只在第 5 步换了什么运算。一旦你把这个模板内化了,后面看新 handler 就是秒懂。

实例分析:vm_op_store(单条 STORE)

地址 0x2B05F4,最简单的 handler,只做一件事:把寄存器的值写到内存。

vm_op_store:
LDR W8, [X0]              ; 取 4 字节操作数
MOV W13, #0x30            ; 48
LDR W9, [X19]             ; PC
LDR X12, [X20]            ; metadata_base
AND X10, X8, #0xFF        ; X10 = BYTE0 = base 寄存器索引
UBFX X11, X8, #8, #8     ; X11 = BYTE1 = value 寄存器索引
ADD W9, W9, #1            ; PC + 1
SBFX X8, X8, #0x10, #0x10; X8 = 有符号偏移
LDR X10, [X23,X10,LSL#3] ; X10 = regs[base] 的值
NOP
SMADDL X0, W9, W13, X12  ; 下一条指令元数据地址
LDR X11, [X23,X11,LSL#3] ; X11 = regs[value] 的值
STR W9, [X19]             ; PC = PC + 1
STR X11, [X8,X10]         ; ★ *(regs[base] + offset) = regs[value]
LDR X8, [X0,#0x28]        ; 下一个 opcode 索引
LDR X8, [X24,X8,LSL#3]   ; handler 地址
BR X8                     ; 跳

伪代码:

*(regs[base] + (int16_t)offset) = regs[value];
PC++;

就这么简单。注意 SBFX 做了符号扩展——偏移可以是负数,比如访问结构体前面的字段。

实例分析:vm_op_load_indirect_imm(间接加载)

地址 0x2B137C,稍微不一样——数据源不是寄存器,而是指令元数据里嵌入的指针。

vm_op_load_indirect_imm:
LDR W8, [X19]             ; PC
MOV W10, #0x30            ; 48
LDR X9, [X20]             ; metadata_base
LDR W11, [X0]             ; 4 字节操作数
ADD W8, W8, #1            ; PC + 1
SMADDL X9, W8, W10, X9   ; 下一条指令元数据地址
LDR X10, [X0,#8]          ; ★ X10 = 元数据偏移 8 处的指针(数据源地址)
LSR X13, X11, #0x10       ; X13 = HIWORD(偏移量,待符号扩展)
UBFX X11, X11, #8, #8    ; X11 = BYTE1 = 目标寄存器索引
MOV X0, X9               ; 更新 X0
STR W8, [X19]             ; PC++
LDR X10, [X10]            ; ★ X10 = *指针 = 解引用拿到实际值
LDR X12, [X9,#0x28]       ; 下一个 opcode 索引
ADD X10, X10, W13,SXTH   ; X10 = 值 + sign_extend_16(偏移)
LDR X12, [X24,X12,LSL#3] ; handler 地址
STR X10, [X23,X11,LSL#3] ; ★ regs[dst] = 结果
BR X12                    ; 跳

伪代码:

regs[dst] = *insn.metadata_ptr + (int16_t)offset;
PC++;

这条指令的特殊之处:它从 [X0, #8] 取了一个指针,解引用后加偏移,结果存到目标寄存器。数据源是编译时就确定的地址,硬编码在字节码元数据里。

实例分析:复合指令 vm_op_store_store_mov

地址 0x2C06F8,一次执行 3 条微操作,PC += 4。

vm_op_store_store_mov:
LDP X13, X8, [X0,#8]      ; X13=insn_slots[1], X8=insn_slots[2]
; ... 解码第一条 STORE ...
STR X11, [X8,X10]         ; ★ 微操作1: *(regs[base] + offset) = regs[value]

; ... 解码第二条 STORE ...
STR X11, [X8,X10]         ; ★ 微操作2: *(regs[base] + offset) = regs[value]

; ... 解码第三条 MOV_REG ...
AND X11, X8, #0xFF        ; src 寄存器索引
UBFX X8, X8, #0x10, #8   ; dst 寄存器索引(注意:BYTE2 不是 BYTE1!)
LDR X11, [X23,X11,LSL#3] ; regs[src]
STR X11, [X23,X8,LSL#3]  ; ★ 微操作3: regs[dst] = regs[src]
BR X10

伪代码:

*(regs[base1] + offset1) = regs[val1];   // STORE
*(regs[base2] + offset2) = regs[val2];   // STORE
regs[dst] = regs[src];                    // MOV_REG
PC += 4;

复合指令就是把高频出现的操作组合打包成一个 handler,减少 dispatch 开销。这三步组合起来的典型场景:往结构体连续写两个字段,然后把指针复制一份。

注意 MOV_REG 的编码格式和 STORE 不一样——它用 BYTE0 和 BYTE2 作为两个寄存器索引,跳过了 BYTE1。VM 的指令编码不是统一格式,不同操作类型对 4 字节的拆法不一样。


关于 VMP 的一些感悟

分析到这里,说实话 VMP 也没那么玄乎。VM 本质上就是个解释器,它必须规规矩矩地走 fetch-decode-execute 循环。再怎么混淆,底层逻辑不可能乱来——寄存器得有固定的存取方式,指令编码得有确定的格式,handler 之间的跳转得有可预测的机制。

这个 VM 的设计其实很教科书:ctx 结构体布局固定、寄存器存取就一个公式 slot*8+0x6050、每个 handler 末尾统一从 metadata[0x28] 取下一个 opcode 索引然后 BR。一旦你把这几个约定摸出来,所有 handler 都是同一个模板套出来的。

VMP 的"难"从来不在单个组件的复杂度,而在于:

  1. 体力活——85 个 CF handler + 800 个 opcode handler,一个个看
  2. 间接性——静态看不到执行顺序,必须靠 trace
  3. 规模——五百万行 trace 里找有意义的模式

但结构本身确实没什么玄学。这大概率也不是 LLVM pass 自动生成的,LLVM pass 生成不出来这么细节的汇编——VM 引擎本身是正常 C++ 写的,用常规编译器编译。签名算法才是被编译成字节码跑在 VM 上的。说白了就是个自研语言虚拟机,和 JVM、Lua VM 是同一类东西,只不过目的是保护代码而不是跨平台。


当前进度

内容 状态
dispatch loop 结构 已理解,threaded dispatch
全局寄存器约定 已确认(X19/X20/X23/X24/X25/X26)
指令元数据结构 48 字节,操作数+数据指针+next_opcode
操作数编码 非统一格式,不同指令拆法不同
vm_op_store 已分析
vm_op_load_indirect_imm 已分析
vm_op_add_imm_store_store 已分析(复合)
vm_op_store_store_mov 已分析(复合)
剩余 ~800 个 handler 慢慢磨...

下一步就是从 trace 里提取实际的 handler 调用序列,看签名算法到底走了哪些 opcode、调了哪些 CF。不用把 800 个全分析完,只要把 trace 里实际执行到的那些搞清楚就行。


以上就是目前的进度,整体来说还在摸索阶段,很多地方的理解可能不够准确。后续如果发现之前的结论有问题会回来修正。如果有大佬看出哪里分析得不对,欢迎留言指出,感谢。


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 0
支持
分享
最新回复 (1)
雪    币: 76
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
加油...
1小时前
0
游客
登录 | 注册 方可回帖
返回