吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 236|回复: 5
上一主题 下一主题
收起左侧

[原创] 基于文本匹配的VMP还原思路

[复制链接]
跳转到指定楼层
楼主
rsds0duck 发表于 2026-7-2 20:54 回帖奖励

基于文本匹配的 VMP 还原

一、问题定义

VMP 逆向的传统路径很重:理解虚拟机架构 → 分析每条 handler 的语义 → 手动重建控制流 → 写复杂的模式匹配规则。

但现在做逆向,trace 几乎已经是标配。unidbg 模拟执行也好、真机跑也罢,最终都会拿到一份几十万甚至上千万条指令的执行记录。

VMP handler 的结构极为规范——每条 handler 都有固定的入口模式、固定的指令序列、固定的出口跳转。而 trace 本身就是把动态执行"拍平"成静态文本的过程。

那为什么不直接解析 trace 来还原 VMP 执行逻辑?

trace 已经记录了所有执行结果。你只需要知道"这段指令是哪个 handler",而不需要完全搞懂 handler 内部的每一条运算。本质上,这是把 "逆向虚拟机保护" 降维成 "填空游戏"——工具问"这是啥 handler?",你答"STORE_REG",工具说"好的继续"。


二、核心洞察:Dispatch 模式是固定的

最初的想法是用正则匹配 handler 开头几条指令的特征序列。但实际实现中发现了一个更简单的方案。

VMP 的 dispatch 模式是固定的:

LDR X8, [X24, X8, LSL#3]  ; 从 dispatch table 加载下一个 handler 地址
BR X8                      ; 间接跳转到 handler
; ──────────── 边界 ────────────
[handler入口...]           ; 这里就是 handler 的起始地址

这意味着不需要匹配 handler 内部的任何指令模式。只需要:

  1. 识别 LDR [X24, ...] + BR 这个固定的 dispatch 模式
  2. BR 跳转的目标地址就是下一个 handler 入口
  3. 如果入口地址不在已知 handler 列表 → 停止,等人工介入

这个发现直接把问题简化了一个量级:handler 识别从"模糊的模式匹配"变成了"精确的 dispatch 目标追踪"。

Handler 示例——以抖音七神指令为例,看一个 handler 长什么样:

0x2BAF58  LDP             X9, X8, [X0]           ; X9=op2_ptr, X8=op1_ptr
          LDR             W10, [X19]              ; W10 = PC
          LDR             W8, [X8]                ; W8 = *op1_ptr
          ADD             W12, W10, #1            ; W12 = PC + 1
          ADD             W10, W10, #3            ; W10 = PC + 3 (消耗3条)
          AND             X11, X8, #0xFF          ; X11 = op1 & 0xFF = src_slot
          UBFX            X8, X8, #0x10, #8       ; X8 = dst_slot
          STR             W12, [X19]              ; PC = PC + 1 (临时)
          LDR             X11, [X23,X11,LSL#3]    ; X11 = int_regs[src_slot]
          STR             X11, [X23,X8,LSL#3]     ; int_regs[dst_slot] = int_regs[src_slot]
          MOV             W11, #0x30              ; W11 = 48 (指令元数据大小)
          LDR             W8, [X9]                ; W8 = *op2_ptr
          STR             W10, [X19]              ; PC = PC + 3 (最终)
          LDR             X9, [X20]               ; X9 = instruction_stream_base
          AND             X12, X8, #0xFF          ; X12 = base_slot
          SMADDL          X0, W10, W11, X9        ; X0 = base + (PC+3)*48
          LDR             X9, [X23,X12,LSL#3]     ; X9 = int_regs[base_slot]
          SBFX            X12, X8, #0x10, #0x10   ; X12 = sign_extend_16(op2 >> 16)
          UBFX            X8, X8, #8, #8          ; X8 = dst_slot2
          LDR             X11, [X0,#0x28]         ; X11 = next_insn.opcode
          LDR             X9, [X12,X9]            ; *(int_regs[base_slot] + offset)
          LDR             X11, [X24,X11,LSL#3]    ; X11 = handler_table[next_opcode]
          STR             X9, [X23,X8,LSL#3]      ; 写回目标槽位
0x2BAFB4  BR              X11                     ; dispatch 到下一个 handler

整体结构高度一致:开头 LDP X9, X8, [X0] 取操作数,中间做运算,结尾 BR X11 dispatch。这为自动化识别提供了确定性基础。


三、系统设计:像 VM 解释器一样执行

3.1 解释器模式

设计理念:不一次性扫描整个 trace,而是像 VMP 解释器一样逐条推进。

开始
  ↓
当前 offset 是已知 handler 入口?
  → 是 → 记录 handler 名 → 跳过 handler 体 → 继续
  ↓ 否
当前指令是 dispatch 模式 (LDR [X24] + BR)?
  → 是 → BR 目标是已知 handler?
            → 是 → 继续
            → 否 → 停止,提示用户分析新 handler
  ↓ 否
普通代码 → 跳过 → 继续

遇到未知 handler 就停下。人工去 IDA 分析后添加到配置,工具从断点继续。反复这个过程直到覆盖完毕。

实际效果——工具会在看到不认识的东西时精准停下:

$ ./duckrevm trace_logs/code.log

========================================
发现未识别的VMP Handler!
位置: index=69489, offset=0x2c06f8
指令: ldp x13, x8, [x0, #8]
========================================
请在IDA中分析该地址,添加到 handlers_config.json 后重新运行
========================================

去 IDA 看一眼 0x2c06f8,发现是 STORE_STORE_MOV。加到配置,重新运行,工具从 index 69489 继续。像解锁技能树一样,跑得一次比一次远。

3.2 Trace 格式与地址匹配

Trace 格式:

index : 0xaddr [0xoffset] "mnemonic operands" (registers)

关键细节:匹配时用 offset 地址而非绝对地址。每次运行基址可能不同,但 offset 是稳定的。

3.3 内存优化

面对 1106 万条指令的 trace 文件(972MB),初版每条指令存 88 字节完整文本,内存直奔 4GB。优化:

  • 只保留 mnemonic 和 operands,砍掉 raw_line 字段
  • 改用 std::getline 流式解析,不将整个文件加载到内存
  • 最终降到约 2GB

3.4 配置文件设计

{
  "ctx": {
    "pc_reg": "x19",
    "ctx_base": "x23"
  },
  "handlers": [
    {"addr": "0x2c0cc0", "end_addr": "0x2c0d50", "name": "COMPOUND_STORE_INIT"},
    {"addr": "0x2b05f4", "end_addr": "0x2b0634", "name": "STORE_REG"},
    {"addr": "0x2b137c", "end_addr": "0x2b13bc", "name": "LOAD_INDIRECT"},
    {"addr": "0x2b2c8c", "end_addr": "0x2b2cb0", "name": "ret"}
  ]
}
  • addr:handler 入口的 offset 地址
  • end_addr:handler 最后一条指令地址(通常是 BR/RET),用于精确边界匹配。部分 handler 内部包含条件分支(beq、csel),会干扰边界判断,end_addr 避免了误判

从 IDA dispatch table 完整导出了 161 个 handler,覆盖所有 vm_handler_*vm_op_*vm_compound_*

3.5 输出格式演进

经历了三轮迭代:

第 1 版:完整 JSON——每条 handler 输出一个对象,包含起止 index、完整指令序列、调用地址。太冗余,读完难。

第 2 版:精简带分隔符——STORE_REG: 69393 + --- 分隔。好一些但还有噪音。

最终版:代码注释风格

COMPOUND_STORE_INIT // 69357
STORE_REG           // 69393
STORE_REG           // 69409
LOAD_INDIRECT       // 69523

读起来就像代码注释,一眼看出 handler 执行序列。最直观的方案往往是最简单的。


四、IR 解释器:从 Handler 序列到中间表示

4.1 为什么需要 IR

Handler 序列只能告诉你"执行了哪些 handler",但看不懂"这些 handler 具体在操作什么"。比如:

STORE_REG      // 69393
MOV_REG        // 69409
add_imm        // 69452
call           // 69480
load_mem       // 69523
beq            // 69556

这串序列的含义是:存一个值 → 搬一个值 → 加常数 → 调函数 → 读内存 → 条件跳转。但具体操作哪些寄存器?偏移多少?跳转到哪?

IR 的目标是把每条 handler 的编码操作数解码出来:

store_mem(VM_REG[1] + 0x1b8, VM_REG[27])
VM_REG[24] = VM_REG[10]
VM_REG[5] = 0x73665d9b18 + 0x55
call 0x29bd94
VM_REG[231] = load_mem(VM_REG[55] + 0x0)
beq VR[8], +0x1c

4.2 操作数编码:从 trace 中提取语义

VMP handler 的操作数几乎都通过 ldr [X0]ldr [X0, #offset] 读取。X0 指向字节码流中的编码数据,trace 记录了每次加载的值。

简单 handler(单一操作):

; handler add_imm 的 trace 片段
ldr w9, [x0]       ; w9 = 0x0080e03c → 编码

编码解析:

字节 含义
BYTE0 = 0x3c 源寄存器 = 60
BYTE1 = 0xe0 目标寄存器 = 224
HIWORD = 0x0080 有符号立即数 = +128

→ IR:VM_REG[224] = VM_REG[60] + 0x80

复合 handler(槽位式,多操作,如 COMPOUND_STORE_INIT):

操作数放在 [X0, #0x10][X0, #0x08][X0, #0x00] 三个不同偏移:

// COMPOUND_STORE_INIT: ADD_IMM + STORE x2
VM_REG[1] = VM_REG[0] + 0x10          // 从 [X0,#0x10] 解码
store_mem(VM_REG[0] + 0x8, VM_REG[5])  // 从 [X0,#0x08] 解码
store_mem(VM_REG[0] + 0x0, VM_REG[3])  // 从 [X0,#0x00] 解码

顺序操作数 handler(如 eor_eor_ldr_ldr_ldr):

操作数按执行顺序从 [X0] 逐个读取,X0 自动递增:

EOR#1:  VM_REG[12] = VM_REG[3] ^ VM_REG[7]      // 第1个 ldr [X0]
EOR#2:  VM_REG[15] = VM_REG[1] ^ VM_REG[9]      // 第2个 ldr [X0]
LOAD#1: VM_REG[8]  = load_mem(VM_REG[4] + 0x20)  // 第3个 ldr [X0]
LOAD#2: VM_REG[9]  = load_mem(VM_REG[4] + 0x28)  // 第4个 ldr [X0]
LOAD#3: VM_REG[10] = load_mem(VM_REG[4] + 0x30)  // 第5个 ldr [X0]

4.3 编码模式分类

通过分析全部 161 个 handler,总结出以下编码模式:

寄存器间操作(BYTE0=src1, BYTE1=src2, BYTE2=dst)

add64, sub64, and64, orr, EOR, nor64, sltu, UMOD
VM_REG[dst] = VM_REG[src1] OP VM_REG[src2]

立即数操作(BYTE0=src, BYTE1=dst, HIWORD=imm)

add_imm, slti, sltu_imm, ORR_IMM
VM_REG[dst] = VM_REG[src] + (int16_t)imm

移位操作(BYTE1=src, BYTE2=dst, BYTE3=shift)

lsl32, lsr32_imm, lsl64_imm, ROR32, ror64
VM_REG[dst] = VM_REG[src] OP shift

内存加载(BYTE0=base, BYTE1=dst, HIWORD=s16_offset)

load_mem, ldrsw, LDRB, ldrh
VM_REG[dst] = *(type*)(VM_REG[base] + offset)

内存存储(BYTE0=base, BYTE1=val, HIWORD=s16_offset)

STORE_REG, str32, strb, STRB, strh
*(type*)(VM_REG[base] + offset) = VM_REG[val]

分支跳转(BYTE0=cond_reg, HIWORD 或其他复杂编码)

b, beq, bne, blt, bbit
beq VR[cond], +0xNNbbit VR[cond], +0xNN

浮点操作(BYTE2=src, byte3_hi=dst,独立浮点寄存器文件)

fneg, fcmpeq, fceilf, dceil, dcmple, floorf, dcsel_nz
FLOAT_REG[dst] = -FLOAT_REG[src]

4.4 CALL 类 handler 的特殊处理

CALL 类 handler 包含 BL vm_call_dispatch,用于调用 VM 内部的子函数。实际调用目标通过追踪双层 BLR 解析:

Handler call 内部:
  BL vm_call_dispatch       ← 进入分发器
  (vm_call_dispatch 内部):
    BLR X9                  ← 第1个 blr,wrapper 函数
    (wrapper 内部):
      BLR X8                ← 第2个 blr,实际目标函数!
        0x29bd94: stp ...   ← 目标函数第一条指令

→ IR: call 0x29bd94

4.5 解释器架构

所有 IR 逻辑集中在单文件 src/handler_interpreter.cpp(约 3800 行):

// 操作数提取
get_operand(h, insts, encoded)            // 简单 handler
get_operand_at(h, insts, x0_offset, val)  // 槽位式
get_nth_x0_load(h, insts, n, val)         // 第 N 个顺序操作数
find_blr_target_after_bl(...)             // BLR 分发目标追踪

// 编码解码
decode_store(op, base, val, offset)       // STORE 模式
decode_load(op, base, dst, offset)        // LOAD 模式
decode_mov(op, src, dst)                  // MOV_REG 模式
decode_add_imm(op, base, dst, imm)        // ADD_IMM 模式

// 每个 handler 一个 interpret_xxx() 函数
// dispatch: if (name == "STORE_REG") return interpret_store_reg(...);

4.6 覆盖率:161/161

类别 数量 示例
简单算术/逻辑 12 add64, sub64, and64, EOR, nor64, ORR_IMM
移位 8 lsl32_reg, lsr64_reg, asr_reg, lsr_shift2_dup
循环移位 3 ROR32, ror64, ror64_imm
加载/存储 12 LDRB, ldrh, strh, STRB, ldrsw, str32, strb
比较 3 slti, sltu, sltu_imm
分支 6 b, beq, bne, blt, bbit, ret
条件选择 2 csel_nzero, csel_zero
浮点 7 fneg, fcmpeq, fceilf, dceil, floorf, dcsel_nz
特殊运算 4 UMOD, REV16, nibble_hash, umod_dup
CALL 类 22 call, mov2_call, str_call_ldr, call_mov2_call_mov, ...
复合(槽位式) 16 COMPOUND_STORE_INIT, ldr_ind_ldr_ldr2, ldr_str2, ...
复合(顺序式) 55 eor_eor_ldr_ldr_ldr, lsr_add_sub, orr_lsl32_orr_ldrsw, ...
委托/存根 2 call_ind, call_add_imm
总计 161

五、工程优化:BL 折叠与嵌套 VMP 处理

5.1 问题

VMP handler 中经常通过 BL 调用 native 函数。trace 会逐条展开 native 函数内部的几百上千条指令。不处理的话:

  • handler 指令数暴增(被 native 函数"撑大")
  • 超过 500 条限制时触发警告或截断
  • 无法提取 handler 的"真实逻辑"

5.2 初版方案:基于 offset 的栈折叠

// BL 入栈
if (mnem == "bl" || mnem == "BL") {
    uint64_t return_offset = curr_offset + 4;
    bl_stack.push_back(return_offset);
}

// 返回检测(offset 匹配栈顶 → 出栈)
if (!bl_stack.empty() && curr_offset == bl_stack.back()) {
    bl_stack.pop_back();
}

// 栈不为空 = 在 native 函数内部,跳过
if (!bl_stack.empty()) {
    continue;  // 折叠
}

效果——以一段 trace 为例:

69480: ldr w8, [x0]           ← handler 指令
69481: bl #0x2ced14           ← BL 入栈,记录 return_offset = 69485
69482: [native 100条指令...]   ← 折叠跳过
69582: (offset=0x69485)       ← 匹配栈顶 return_offset,出栈
69583: lsr x8, x28, #0x3c     ← 继续 handler 指令
...
69600: br x10                 ← handler 结束

5.3 初版问题:嵌套 VMP 被漏掉

初版在栈不为空时无条件跳过所有指令。但如果 native 函数内部触发了另一个 VMP handler,这个嵌套 handler 会被错误跳过:

Handler A
  BL native_func           ← 栈:[返回地址]
    (native 代码)
    遇到 VMP handler B     ← 栈不为空 → 被错误跳过!
      BR X8                ← 也被跳过
    (更多 native 代码)
  返回                     ← 出栈
Handler A 继续

5.4 最终方案:选择性折叠

在栈不为空时,不再无脑跳过,加两道判断:

if (!bl_stack.empty()) {
    // 检查是否是已知 handler 入口(嵌套 VMP 开始)
    if (handler_map_.find(curr_offset) != handler_map_.end()) {
        // 嵌套 VMP 的 handler 入口,不跳过,正常处理
    }
    // 检查是否是 BR 指令(可能是 handler 结束)
    else if (mnem == "br" || mnem == "BR") {
        // 可能是 handler 结束或 VMP dispatch,不跳过
    }
    else {
        // 真正的 native 代码,折叠跳过
        continue;
    }
}

5.5 支持的四类嵌套场景

场景 1:VMP → native 函数(最常见)

Handler A
  [前置逻辑]
  BL native_func         ← 入栈
    [native 代码 100 条] ← 全部折叠
  返回                   ← 出栈
  [后续逻辑]
  BR X8                  ← handler 结束

场景 2:native 内部调用 VMP

Handler A
  BL native_func         ← 入栈
    [native 前置代码]    ← 折叠
    Handler B 入口       ← handler_map 命中,不折叠
      [B 的逻辑]
      BR X8              ← BR 命中,不折叠
    [native 后续代码]    ← 继续折叠
  返回                   ← 出栈
  Handler A 继续

场景 3:VMP 嵌套 VMP(栈为空,天然支持)

Handler A
  [A 的逻辑]
  Handler B 入口         ← 栈为空,正常识别
    [B 的逻辑]
    BR X8
  Handler A 继续         ← 栈为空,正常识别

场景 4:多层嵌套

Handler A
  BL native1             ← 栈:[ret1]
    BL native2           ← 栈:[ret1, ret2]
      Handler B 入口     ← 命中,不折叠
        [B 的逻辑]
        BR X8
      [native2 代码]    ← 折叠
    返回                 ← 栈:[ret1]
    [native1 代码]       ← 折叠
  返回                   ← 栈:[]
  Handler A 继续

5.6 实现要点

  • 栈存储:BL 的返回地址(current_offset + 4
  • 返回检测:当前 offset 匹配栈顶 → 出栈
  • 栈不为空时:handler 入口和 BR 指令放行,其余折叠
  • inst_count 只统计非折叠部分,500 条限制更准确
  • 支持任意深度调用嵌套
  • 基于 offset 匹配,不依赖 RET 指令(trace 中可能看不到)

这个 BL 折叠逻辑本质上是"回型嵌套"思路在 VMP 场景的实现——动态栈驱动、自底向上、容错性强。


六、验证:IDA 集成

161 个 handler 的 addrend_addr 是否正确?不能靠猜。

通过 IDA MCP 插件编写 Python 脚本,对每个 handler 做三层检查:

for addr_str, end_str, name in handlers:
    func = ida_funcs.get_func(addr)          # L1: addr 是函数起始?
    last_insn = idc.prev_head(func.end_ea)   # L2: end_addr 是最后一条指令?
    mnem = idc.print_insn_mnem(end)          # L3: 最后一条是 BR/RET?

结果:

  • 161/161 addr 匹配 IDA 函数起始
  • 161/161 end_addr 指向最后一条指令
  • 161/161 最后一条指令是有效终止指令
  • 无重叠、无断层

全量验证通过。这为后续所有基于 handler 边界的分析提供了可信基础。


七、项目结构与工作流

项目结构

文件具体就不给各位看了

技术选型:

  • C++11,零第三方依赖(手写 JSON 解析)
  • 流式处理(std::getline,内存友好)
  • 单文件解释器架构(所有 handler IR 逻辑在 handler_interpreter.cpp

三种运行模式

# 基础模式:只输出 handler 序列
./build/duckrevm trace_logs/code.log -o build

完整工作流

1. 运行工具 → 遇到未知 handler 停下
2. 去 IDA 分析该 offset 地址的函数
3. 确定 handler 名称和编码格式
4. 添加到 handlers_config.json
5. 在 handler_interpreter.cpp 中添加 IR 解码逻辑
6. 重新编译运行 → 继续到下一个未知 handler
7. 重复直到还原完毕

八、为什么这个方案有效

把整个过程拉回来看,它有效的原因是每层都做了正确的降维:

传统路径 本方案
理解虚拟机架构 trace 已拍平为静态文本
分析每条 handler 语义 只识别 handler 边界,内部逻辑由 trace 记录
手动重建控制流 dispatch 模式固定,自动追踪
写复杂模式匹配 handler 入口 = dispatch BR 目标,精确匹配
一次性全量分析 解释器模式,遇到未知停下,迭代推进

人只负责一个动作:命名。去 IDA 看一眼不认识的东西,起个有意义的名字,剩下的交给工具。

后续方向:

IR → 平台汇编:Handler 序列 → 中间表示 → 等等内容的反编译处理

handlers_ir.txt

54.84 KB, 下载次数: 5, 下载积分: 吾爱币 -1 CB

handlers.txt

21.87 KB, 下载次数: 5, 下载积分: 吾爱币 -1 CB

免费评分

参与人数 2吾爱币 +4 热心值 +2 收起 理由
Sound + 3 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
yxnwh + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
zl20110000 发表于 2026-7-2 21:57
牛牛牛,这个思路不错
3#
ljzZ 发表于 2026-7-2 22:28
4#
Sound 发表于 2026-7-2 22:32
本帖最后由 Sound 于 2026-7-2 23:47 编辑

“VMP 的 dispatch 模式是固定的”      

有些是固定的,有些匹配规则不一样。

思路不错,有些handler 里集成多个虚拟机操作原语外加内存操作数常量隐藏 就不适配咯~~~~
5#
小木木XX 发表于 2026-7-2 23:33

支持一下,学习了
6#
shuai408 发表于 2026-7-3 02:14
小白不懂问一下。。这是安卓so的vmp。。还是pc的exe的vmp?
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-3 07:01

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表