本帖最后由 蜗牛杰 于 2026-07-21 22:07 编辑
基于文本匹配的 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 内部的任何指令模式。只需要: - 识别
LDR [X24, ...] + BR 这个固定的 dispatch 模式 - BR 跳转的目标地址就是下一个 handler 入口
- 如果入口地址不在已知 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 流式解析,不将整个文件加载到内存
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"} ] }
addrend_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 为什么需要 IRHandler 序列只能告诉你"执行了哪些 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 call0x29bd94 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 → 编码
编码解析: → 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], +0xNN 或 bbit 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 内部): BLRX9 ← 第1个 blr,wrapper 函数 (wrapper 内部): BLRX8 ← 第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 | | |
|---|
| | add64, sub64, and64, EOR, nor64, ORR_IMM | | | lsl32_reg, lsr64_reg, asr_reg, lsr_shift2_dup | | | | | | LDRB, ldrh, strh, STRB, ldrsw, str32, strb | | | | | | b, beq, bne, blt, bbit, ret | | | | | | fneg, fcmpeq, fceilf, dceil, floorf, dcsel_nz | | | UMOD, REV16, nibble_hash, umod_dup | | | call, mov2_call, str_call_ldr, call_mov2_call_mov, ... | | | COMPOUND_STORE_INIT, ldr_ind_ldr_ldr2, ldr_str2, ... | | | eor_eor_ldr_ldr_ldr, lsr_add_sub, orr_lsl32_orr_ldrsw, ... | | | | | 总计 | 161 | |
五、工程优化:BL 折叠与嵌套 VMP 处理5.1 问题VMP handler 中经常通过 BL 调用 native 函数。trace 会逐条展开 native 函数内部的几百上千条指令。不处理的话: - handler 指令数暴增(被 native 函数"撑大")
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: ldrw8, [x0] ← handler 指令 69481: bl#0x2ced14 ← BL 入栈,记录 return_offset = 69485 69482: [native 100条指令...] ← 折叠跳过 69582: (offset=0x69485) ← 匹配栈顶 return_offset,出栈 69583: lsrx8, x28, #0x3c ← 继续 handler 指令 ... 69600: brx10 ← handler 结束
5.3 初版问题:嵌套 VMP 被漏掉初版在栈不为空时无条件跳过所有指令。但如果 native 函数内部触发了另一个 VMP handler,这个嵌套 handler 会被错误跳过: Handler A BL native_func ← 栈:[返回地址] (native 代码) 遇到 VMP handler B ← 栈不为空 → 被错误跳过! BRX8 ← 也被跳过 (更多 native 代码) 返回 ← 出栈 Handler A 继续
5.4 最终方案:选择性折叠在栈不为空时,不再无脑跳过,加两道判断: if (!bl_stack.empty()) { // 检查是否是已知 handler 入口(嵌套 VMP 开始) if (handler_map_.find(curr_offset) != handler_map_.end()) { // 嵌套 VMP 的 handler 入口,不跳过,正常处理 } // 检查是否是 BR 指令(可能是 handler 结束) elseif (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 的逻辑] BRX8 ← 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) - 返回检测
- 栈不为空时:handler 入口和 BR 指令放行,其余折叠
- inst_count
- 基于 offset 匹配,不依赖 RET 指令(trace 中可能看不到)
这个 BL 折叠逻辑本质上是"回型嵌套"思路在 VMP 场景的实现——动态栈驱动、自底向上、容错性强。
六、验证:IDA 集成161 个 handler 的 addr 和 end_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 边界的分析提供了可信基础。
七、项目结构与工作流项目结构文件具体就不给各位看了 技术选型: - 单文件解释器架构(所有 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. 重复直到还原完毕
八、为什么这个方案有效把整个过程拉回来看,它有效的原因是每层都做了正确的降维: | |
|---|
| | | 只识别 handler 边界,内部逻辑由 trace 记录 | | | | handler 入口 = dispatch BR 目标,精确匹配 | | |
人只负责一个动作:命名。去 IDA 看一眼不认识的东西,起个有意义的名字,剩下的交给工具。 后续方向: IR → 平台汇编:Handler 序列 → 中间表示 → 等等内容的反编译处理
注:若转载请注明大神论坛来源(本贴地址)与作者信息。
|