WeChat/微信 RQTX IRVMP 逆向还原:架构、指令与运行时
WeChat/微信 RQTX IRVMP 逆向还原:架构、指令与运行时
这篇记录一次 rqtx IRVMP 的逆向和还原过程。重点不在某个 hook 点,而在把这条链路拆成几个能验证的工程对象:入口、VM 初始化、rqtx.dat 布局、指令编码、runner 语义、CFG 去噪和离线 runtime。
后面每个对象都会按“它是什么、为什么重要、怎么被证明”的顺序解释。你可以把它看成一条证据链,而不是一份工作日志。
关键结论
- WeChat 8.7.0 的 RQTX 链路可以拆成 ObjC anchor、slot/body proof、VM init/layout proof、payload dump、runner localization、CFG skeleton stripping、offline runtime verification 七个阶段。
rqtx.dat不是单纯 opcode 列表,而是由 opcode map、payload image、relocation/export slots 和内部 VM blocks 组成的 packaged VM image。- RQTX IRVMP 的 opcode decode 同时依赖
raw_word、当前 IP 和 opcode map;相同高 6 位在不同 IP 上可能解出不同语义。 - 对 WeChat RQTX 做版本迁移时,应该迁移证据链和布局关系,而不是直接复用旧版本 runtime address。
- 离线 runtime 的目标是复现 VM image、context、handler semantics 和 observed traffic,而不是只拿到一次 hook 返回值。
阅读顺序可以简单理解成四段:先把调用链拉直,再解释 VM image 和 context 如何被初始化;接着看指令如何被解码、runner 如何被 CFG 隐藏;最后把这些证据收束成离线 runtime 和版本迁移方法。每一段都回答一个问题:下一层为什么能被信任。
关键词与术语
| 关键词 | 说明 |
|---|---|
| WeChat / 微信 | 本文分析目标所属客户端环境。 |
| RQTX / rqtx | WeChat 请求链路中的受保护计算路径。 |
| IRVMP | Instruction-level virtual machine protection,本文中指 rqtx 使用的 VM 保护结构。 |
| rqtx.dat | 承载 opcode map、payload image 和 relocation slots 的 VM 数据文件。 |
| CFG 去混淆 | 对 flattened native runner 做 block coverage、dispatcher 剥离和语义恢复。 |
| IDA Pro / Frida | 静态分析与动态插桩验证工具链。 |
相关内容会持续整理到 WeChat RQTX IRVMP 研究索引。
rqtx 的难点不在单个函数,而在多层保护叠加:
- 上层是 Objective-C selector,提供稳定业务入口。
- 中层是多个被 CFG 扁平化的 native stage,负责对象构造、runtime handle、entry 解析和 bridge。
- 底层是一个解释执行
rqtx.dat的 IRVMP runner。 - 数据层还有 opcode map、payload image、relocation slots 和 VM context。
- 动态分析层必须处理 ASLR、线程噪声、热路径卡顿和 watchdog re-entry。
这类目标最怕只看一段伪代码就下结论。我的处理方式是把每一层都做成可反驳的结论:静态地址能对上,动态路径能命中,VM 状态能解释,离线 runtime 能复现 observed 行为。做不到这一点的地方,就保留为 unknown。
总体链路
先看 live path。没有这条链,后面所有关于 rqtx.dat、VM context 和 runner 的分析都会悬空;有了它,才能把高层 selector、body wrapper、bridge 和 VM runner 放到同一张图里。
rqtx 的 live path 可以归纳成下面这条链:
WCCalRqtDataMgr - calRqtData:len:cmd:uin: -> slot 0x28 dispatcher / wrapper -> rqtx_md5_body_dispatch sub_112E270BC -> rqtx_calc_stage_dispatch sub_112D3B6A0 -> rqtx_vm_bridge sub_112D3AF68 -> rqtx_vm_runner_flat sub_112E16DB0 -> rqtx.dat + VM context + opcode map当前版本的关键静态地址如下:
下面这组地址用于串起静态入口、动态命中和运行时对象,重点是层级关系,而不是孤立 offset。
| 角色 | IDA 地址 | 作用 |
|---|---|---|
| ObjC anchor | 0x105b84710 | calRqtData:len:cmd:uin:,最高层稳定入口 |
| md5-like body | 0x112e270bc | 接收 32 字节 ASCII hex 输入,进入 rqtx 计算链 |
| calc stage | 0x112d3b6a0 | runtime handle 校验、entry-like 字段解析 |
| VM bridge | 0x112d3af68 | 装载 blob/context,设置 VM 初始寄存器 |
| VM runner | 0x112e16db0 | IRVMP 主解释器,带重度 CFG 扁平化 |
slot 解析也不是一跳到位。从 calRqtData 看到 0x28 后,还要继续解析 thunk chain:
calRqtData loads slot 0x28 -> sub_107C0CCA8 -> sub_1129EA5F4 -> sub_112B6CD80 -> thin wrapper -> sub_112E270BC这个步骤很重要。如果停在第一个 slot dispatcher,会把包装层误判成算法主体。
IRVMP 的设计思路
入口和 slot chain 解决的是“走到哪里”。下一步要解释的是“为什么走进去以后看不到普通 native 算法”。答案在 IRVMP 的分层设计里。
rqtx 的 IRVMP 可以理解成五层设计。
第一层是 packaged VM image。核心数据不是硬编码在 native 函数里,而是封装在 rqtx.dat 中。native runner 只负责解释执行,具体逻辑和数据通过 VM image 表达。
第二层是 objectized runtime。rqtx.dat 不会直接以文件形态执行,而是经过全局 init、provider/handle 管理、blob getter、context getter 之后,变成 runtime 对象。
第三层是 VM context。每次调用 bridge 都会准备一个上下文缓冲区,里面既有寄存器文件,也有 IP、next IP、VM state slot、内存窗口指针。
第四层是 IP-aware instruction decode。指令的 opcode 不只是 word >> 26,还会混入当前 IP,并通过 opcode map 翻译。寄存器字段也经过 XOR 混淆。
第五层是 native runner obfuscation。真正解释 VM 指令的 runner 又被 CFG 扁平化,内部有 main dispatcher、sub dispatcher、selector state 和 common tail。
这几层叠在一起,形成了一个典型保护目标:静态看不到完整算法,动态 trace 容易混入噪声,dump 出来的数据如果不重建 relocation 也不能直接运行。
rqtx.dat 布局
理解了分层以后,先看最底下的 VM image。rqtx.dat 是业务逻辑和 VM 数据的承载体;如果这个布局解释不通,后面的 init、decode 和 runtime 都没有稳定基础。
rqtx.dat 不是单纯的 opcode 列表。当前恢复模型中,它由 opcode map、payload image、relocation/export slots 和内部 VM blocks 组成。
| 区域 | 偏移/大小 | 作用 |
|---|---|---|
| opcode map | 0x000..0x1ff,大小 0x200 | opcode deobfuscation map |
| payload image | 0x200.. | 复制到 runtime main memory,live payload 以 XIMG 开始 |
| default entry | payloadBase + 0x4000 | 旧模型中的默认 VM 入口 |
| primary exported block | payloadBase + 0x16520 | runtime patch slot 0x1c000 指向 |
| secondary exported block | payloadBase + 0x16a2c | runtime patch slot 0x1c0b8 指向 |
| data blob | payloadBase + 0x290 / 0x2d0 / 0x3d0 | 高熵数据块 |
| marker block | payloadBase + 0x18000 | 包含 sign.c 标记 |
runtime patch slots 的当前映射如下:
| patch slot | runtime target | 建议命名 |
|---|---|---|
0x1c000 | payloadBase + 0x16520 | rqtx_patch_ptr_primary_code |
0x1c0b8 | payloadBase + 0x16a2c | rqtx_patch_ptr_secondary_code |
0x1c0c0 | payloadBase + 0x3d0 | rqtx_patch_ptr_const_blob_a |
0x1c0c8 | payloadBase + 0x2d0 | rqtx_patch_ptr_const_blob_b |
0x1c0d0 | payloadBase + 0x290 | rqtx_patch_ptr_const_blob_c |
0x1c0d8 | payloadBase + 0x18000 | rqtx_patch_ptr_debug_str |
这解释了一个容易误判的问题:live dump 和旧 payload 如果只在 patch-slot 附近出现少量差异,并不必然代表算法重写。那些位置是 runtime relocation/patch bytes,会随 payloadBase 改变。正确做法是先用 relocation model 解释差异,再判断 VM bytecode block 或 data block 是否发生结构变化。
VM 初始化
布局只是静态模型。VM 初始化要证明这份模型真的在运行时成立:blob 怎么 objectize,payload 怎么落到内存,patch slot 怎么被改写,每次调用又如何拿到自己的 context。
IRVMP 的 init 阶段不是“读一个 blob”这么简单。它至少完成四件事:
- 从
rqtx.dat拆出前0x200字节,构造 opcode map 对象。 - 把 payload image 复制到 page-aligned runtime memory。
- 根据 payload 内部表重写 relocation/export slots。
- 准备每次调用的 VM context 和初始 IP。
当前 IDA init pipeline 为:
| 阶段 | 地址/函数 | 作用 |
|---|---|---|
| global init wrapper | sub_112E27060 | call_once 包装,保证 runtime 只初始化一次 |
| global init body | sub_112E283A8 | provider/manager 初始化,建立 runtime handle |
| high-value virtual call | 0x112E28470 - 0x112E28488 | 解析或构造 rqtx.dat-backed object 的关键虚调用 |
| blob getter | sub_112E0E210 | 返回 handle + 8,说明 blob 已经 objectized |
| context getter | sub_112E265EC | 返回 VM context buffer |
| per-call context init | sub_112D3AF68 | bridge 内准备一次 VM 执行 |
| context write proof | 0x112D3B1FC - 0x112D3B21C | 证明 0x80418 VM state layout |
| param write range | 0x112D3B220 - 0x112D3B234 | 写入 bridge 参数寄存器 |
VM-init 阶段不能只写一句“dump 成功”。最少要回答:
global init body = sub_112E283A8blob-returning function = sub_112E0E210context-returning function = sub_112E265ECper-call context init = sub_112D3AF680x80418 layout proof = 0x112D3B1FC - 0x112D3B21C并且必须区分:
- 静态稳定点:IDA 函数和 call site。
- 动态运行点:
blobPtr、headerPtr、ctxPtr、slot80418。
动态指针会因为 ASLR/restart 改变,不能作为版本迁移的核心依据;真正可迁移的是 IDA point set 和布局关系。
VM Context
初始化证明了 VM image 能被装载,context 则解释一次调用如何进入 VM。这里开始,分析对象从“全局 runtime”切到“单次 RQTX 计算”。
离线 runtime skeleton 中,context buffer 大小约为 0x80440,关键字段如下:
| 字段 | 作用 |
|---|---|
ctx + 0x80418 | VM state slot,保存 VM 状态指针/内存基址相关信息 |
ctx + 0x80428 | IP storage / next IP 相关槽 |
reg(0x10084) | 当前 entry/IP storage |
reg(0x10085) | next entry/IP storage |
reg(0..3) | bridge 传入的前四个 VM 参数 |
bridge-like 初始化可以抽象成:
ctx[0x10084] = entryLike;ctx[0x10085] = entryLike;*vmStateSlot = defaultHaltLikeValue;
ctx[0] = reg0;ctx[1] = reg1;ctx[2] = reg2;ctx[3] = reg3;
rqtx_vm_runner_flat(ctx, blob);return ctx[0];旧版兼容路径中,默认入口是 main_memory + 0x4000,寄存器形态接近:
reg0 = md5_32reg1 = 0x20reg2 = out_valuereg3 = out_len当前 870 bridge-like 路径仍然把 entry_like、md5_32、reg1/reg2/reg3 作为独立建模对象,这是为了避免把旧版本参数约定强行套到新版 live path 上。
指令编码
有了 image 和 context,还不能直接执行 VM。runner 每取到一个 word,都要先通过 IP-aware decode 把它变成可解释的 opcode 和 operand。
runner 的 fetch-decode-execute 模型大致如下:
current_ip = ctx.ipraw_word = *(uint32_t *)current_ipctx.ip_storage = current_ipctx.ip = current_ip + 4decoded = decode(raw_word, current_ip, opcode_map)execute(decoded)原始 opcode 先来自高 6 位:
raw_opcode = raw_word >> 26但如果当前指令地址不是默认 halt-like buffer,还会混入 IP:
raw_opcode ^= (current_ip >> 8) & 0x3f之后再查 opcode map:
if opcode_map[2 * raw_opcode] != 0: opcode = opcode_map[2 * raw_opcode + 1]这说明 opcode 不是静态全局表,而是由 raw_word、current_ip 和 opcode_map 联合决定。这个设计会直接破坏简单的字节码反汇编器,因为相同高 6 位在不同 IP 上可能解出不同语义。
寄存器字段也不是直接读取。旧模型中的几类格式如下。
I-format
适用于 ADDI、ANDI、MOD、MULI、SW、XORI、SUBI、LW、SH、SRAI、LB、LD、SW32、LH、SB、ORI 等。
rs = ((word >> 12) & 0x7f) ^ ((word >> 19) & 0x7f)rt = ((word >> 19) & 0x7f) ^ reg_fieldimm = sign_extend(word & 0xfff, 12)R-format
适用于 XOR、SUB、SNE、SEQ、AND、OR、SLT、CMOV、GETVAL_JMP、MOV、ADD 等。
rd = ((word >> 5) & 0x7f) ^ ((word >> 12) & 0x7f)rs = ((word >> 12) & 0x7f) ^ ((word >> 19) & 0x7f)rt = ((word >> 19) & 0x7f) ^ reg_fieldimm = word & 0x1f19-bit immediate
适用于 LDI、BFINS、BEQ1、PC_REL、ADDIP 等。
rt = ((word >> 19) & 0x7f) ^ reg_fieldimm = sign_extend(word & 0x7ffff, 19)26-bit branch
JMP 使用 26-bit signed immediate:
imm = sign_extend(word & 0x3ffffff, 26)这里的 reg_field 在旧模型里来自指令地址:
reg_field = (current_ip >> 2) & 0x7f870 no-CFG live path 中还能看到 selector/semantic 家族的另一层状态,这说明 runner 内部不是简单 switch(opcode),而是先用扁平化状态机走到 selector,再进入具体 micro-op block。恢复时需要同时利用旧模型和 live-hit selector 证据。
指令语义
编码解决“字段怎么取”,语义解决“这条指令到底做什么”。这一步要谨慎:能命名的就命名,证据不足的要保留不确定性。
旧模型中的 Opcode870 覆盖了 0x00..0x37 范围内的 VM opcode。可以按语义分组理解:
| 类别 | 指令 |
|---|---|
| 控制流 | HALT、RET、JMP、BEQ1、GETVAL_JMP |
| 算术 | ADD、ADDI、SUB、SUBI、MULI、MOD |
| 位运算 | AND、ANDI、OR、ORI、XOR、XORI、SLLI、SRAI |
| 比较 | SGT、SNE、SEQ、SLT |
| 内存读 | LB、LH、LW、LD |
| 内存写 | SB、SH、SW32、SW |
| 地址计算 | PC_REL、ADDIP、GET_MEM_AND_RET |
| 数据移动 | MOV、CMOV、LDI、BFINS |
870 live-hit runner 里确认过一组 selector 到 semantic 的绑定。注意:这些 selector 是 native runner 内部状态/分派值,不应该直接等同于 VM raw opcode。
| live selector | 已确认语义 |
|---|---|
0x9F0BE75E | add(reg, reg) |
0xB15F9064 | sub(reg, reg) |
0x600B79DF | mul(reg, reg) |
0xCBF21908 | xor(reg, reg) |
0xDF9B888D | or(reg, reg) |
0x1F6EA64C | lsr(reg, reg) |
0x65824F54 | asr(reg, reg) |
0x677DCBEE | lsr(imm/local) |
0x3F574BE6 | or(reg, simm12/local) |
0x7687434F | mod(reg, simm12/local) |
0xEE940736 | udiv(reg, simm12/local) |
0xB7B02238 | load qword |
0x5CF5B499 | load signed byte |
0xBFC3E3A1 | load signed half |
0xF012CC39 | load signed word |
0x9A0006D4 | store qword |
0x17DA031D / 0x19395CFA | store word |
0x085D120F / 0x51907A54 / 0x52097D9F | store half |
0x2FE6DF83 | best-fit store byte |
0x7AD70088 | cmp_le |
0x615BB917 | cmp_ls |
0x8906B00C | cmp_cs |
0xB5647D29 | cmp_eq |
0xBBC96063 | cmp_ge |
0xE0208CC1 | cmp_hi |
0xE35D0837 | rel19 next_ip update |
0xF2DD56C0 | rel26 next_ip update |
0x0B861E7C | pc_rel/page-base materialization |
store byte 当前仍属于 best-fit 语义,而不是完全闭合的静态证明。证据来自 live body 0x112e18d78 .. 0x112e18d98 和前驱状态链,runtime skeleton 也明确把这种不确定性保留在注释里。这种标注方式比强行填满 opcode 表更可靠。
Runner 运行模型
单条指令的语义只是局部真相。runner 运行模型要把 current_ip、decode、writeback、next IP 和 halt reason 连起来,才能支撑后面的 CFG 剥离和离线执行。
runner 每一步都要维护几个状态:
| 状态 | 作用 |
|---|---|
current_ip | 当前 VM 指令地址 |
raw_insn | 当前 32-bit 指令 word |
opcode_map | opcode 翻译表 |
dst/src/aux 或 rd/rs/rt | 解码后的寄存器字段 |
imm5/imm12/imm19/imm26 | 不同格式立即数 |
simm12/simm19/simm26 | 符号扩展后的立即数 |
vm_state_slot | VM 状态槽,保存内存基址、IP 相关信息 |
next_ip_slot | 下一条指令或分支目标 |
离线 runtime 还补了 halt reason,用于定位还原不完整的位置:
RunningMaxStepsReturnedIpOutOfWindowDecodeRejectedMemOutOfRangeUnknownSemantic这对 flywheel 很重要。一次回归用例不匹配时,不能只看 expected/actual,还要看是 decode 被拒绝、IP 跳出窗口、内存越界,还是某个 semantic 仍未知。
CFG 扁平化
到这里,VM 的正常解释器模型已经清楚了;真正挡住还原的是 native runner 自身的 CFG 扁平化。下面这一步不是继续猜伪代码,而是先把稳定执行路径从噪声里分出来。
rqtx_vm_runner_flat 的静态范围约为:
0x112E16DB0 .. 0x112E1A138如果直接看 F5,几乎一定会被 dispatcher 状态污染。我采用的规则是:
- 先确认 target function 已经在 live path 上。
- 从 bridge 进入时打开同线程 trace 窗口。
- 记录 basic block starts,不做全指令热路径 trace。
- 将结果分成
bridgeLeave和watchdog两类。 - 只把稳定的
bridgeLeave路径作为 canonical semantic source。 - 剥离 main dispatcher/sub dispatcher,再从 real blocks 重建 no-CFG pseudocode。
runner 稳定性验证不应该只看“是否进入过函数”,而要看多轮冷启动下 canonical path 是否收敛。判断标准是:
| 指标 | 结果 |
|---|---|
| injection order | spawn -> attach -> load -> main() -> resume |
| trace scope | 只在已确认 bridge 线程内开启 |
| trace unit | basic block starts,而不是全指令流 |
| canonical source | bridge 正常返回路径 |
| noise source | watchdog / late re-entry 单独记录 |
| acceptance rule | 多轮冷启动下 bridgeLeave block set 收敛 |
intersection == union 是关键判断:同一条业务路径在多轮冷启动中得到一致的 block-start 集合,才可以作为当前 traffic class 的 canonical real path。
watchdog 结果则只作为 secondary evidence。只要它出现 late noise 或 re-entry,就不能直接合并进语义表。
No-CFG 还原
CFG 恢复的目标不是得到一张更漂亮的控制流图,而是把 flattened native 重新表达成“取指、解码、执行、写回”的解释器结构。
CFG 剥离后,runner 的 no-CFG 重建不是“美化反编译”,而是重新表达真实执行模型:
fetch raw instructiondecode opcode through IP-aware opcode mapdecode operand familymap selector to semanticexecute micro-opupdate writeback / memory / next_iploop分类时要区分四类 block:
| block 类型 | 处理 |
|---|---|
| main dispatcher | 作为 CFG skeleton,剥离 |
| sub dispatcher | 作为 CFG skeleton,剥离 |
| decode block | 保留,用于恢复 opcode 和 operand family |
| micro-op block | 保留,用于 selector -> semantic 映射 |
| common tail | 保留,用于 writeback、store completion、IP update |
因此,runner 还原的核心不是保存一份庞大的 trace,而是把“扁平化后的 native 控制流”转回“VM 解释器的正常逻辑”:哪些 block 只负责调度,哪些 block 负责解码,哪些 block 负责具体 micro-op。
深层 Runner 定位
前面为了讲清楚机制,已经把 runner 当成一个确定对象。实际工作里,runner 本身也要被证明出来;否则很容易把 bridge、wrapper 或 timing helper 误当成 VM 主循环。
runner 定位不能把 sub_112E16DB0 当成预设答案,而是要让两个独立启发式收敛。
timestamp-wrapper heuristic
如果一个 deeper child call 前后出现时间戳包装,例如:
bridge_enter tick_leave runner_enter runner_leave tick_leavebridge_leave那么中间 callee 是高置信 runner candidate。在该版本的抽象调用关系中,对应角色是:
| 角色 | 函数 |
|---|---|
| body | sub_112E270BC |
| calc core | sub_112D3B6A0 |
| bridge | sub_112D3AF68 |
| timing helper | sub_10C5481E8 |
| runner candidate | sub_112E16DB0 |
effective-call-list ranking
另一条线是先从已验证 body 向下做静态 CFG 简化,抽出有效子调用列表:
sub_112E270BCsub_112D3B6A0sub_112D3AF68sub_112E16DB0再在同线程 live call 中按三个维度排序:
- 总耗时。
- 单次最大耗时。
- 函数体大小。
最深、最大、耗时稳定靠前的候选才可以命名为 runner。当前两条启发式都收敛到 sub_112E16DB0。
Live Body
runner 是最深的一层,body 则是它的上游边界。把 body 讲清楚,是为了确认 VM 收到的到底是什么输入,以及 high-level 返回值和 VM 返回值是否在同一条链路上。
sub_112E270BC 不是最终 VM runner,而是一个 flattened body stage。当前 live path 已确认它接收:
x0 -> 32-character ASCII hex stringx1 -> length = 32x2 -> cmdreturn -> uint32body input shape 被证明之后,就不能再把它称为 raw packet stage。正确解释是:
calRqtData receives raw request bufferupstream chain transforms itsub_112E270BC receives md5-like 32-byte hex string + cmdbody return equals high-level calRqtData return验证时关注的是关系:输入长度、字符类别、命令号、返回值类型,以及 body return 是否等于高层 return。
body 自身也被 CFG 扁平化。当前 live-path 语义证明了它会构造一个短 key buffer,执行已观察到的 byte transform stage,然后把 local object、runtime handle、md5-like input 和 cmd 传给 calc stage。这里仍然强调“live-path”:未命中的错误分支和其他 traffic class 不能随便写成死代码。
还原流程
前面的章节按对象展开,读起来像一组局部证明。真正落地时,需要把这些证明编排成一个可重复流程:先证明入口,再证明数据,再证明 runner,最后证明离线 runtime。
这次 rqtx 最有价值的部分不是单个 hook,而是把逆向流程拆成几个稳定阶段。每个阶段只证明一件事,证明不了就停在当前层,不把猜测带到下一层。
入口适配
入口适配负责从新版本里重新建立证据链。它不是直接运行一个大脚本,而是分阶段推进:
| Stage | 目标 | 成功标准 |
|---|---|---|
| A | 环境和 Frida/spawn 路径 | SSH、frida-server、WeChat process 可用 |
| B | PID 和 attach/spawn 选择 | 选中当前 com.tencent.xin |
| C | 构建 probe | 高层 hook、body probe、input probe、ranking probe 可用 |
| D | ObjC anchor | calRqtData:len:cmd:uin: live hit |
| E | static/dynamic reconciliation | 用同一 slide 映射实现地址和 caller |
| F | slot 0x28 proof | slot chain 解析到 sub_112E270BC |
| G | body live proof | body return 等于高层 return |
| H | body input shape | x1=32,x0 是 ASCII hex,x2=cmd |
| I | VM init/layout proof | blobPtr/headerPtr/ctxPtr/0x80418 等布局成立 |
| I2 | layout self-check | rqtx.dat layout 与 IDA init path 一致 |
| J | two-phase payload dump | pointer-only capture 后二阶段 dump |
| K | timestamp heuristic | 时间包装器夹住 runner candidate |
| L | effective-call ranking | 同线程候选按耗时/大小/深度收敛 |
| M | final localization | 输出当前版本 runner 地址或结构化失败 |
这里的关键思想是 staged validation。每一阶段都只证明一件事,失败时也要输出“成功到哪、卡在哪、下一步看什么”。这比一次性塞进大脚本可靠得多。
CFG 恢复
CFG 恢复专门处理已定位的 flattened native function。这里有几个硬规则:
- 函数窗口必须先固定,不能 trace “接下来随便跑什么”。
- 优先
spawn -> attach -> load -> main() -> resume。 - 默认记录 block starts,不记录全指令流。
- 只跟同一个 active thread。
- 结果拆成
bridgeLeave和watchdog。 - 至少三次 cold spawn 稳定后才提升为 canonical semantic table。
- 对内部 basic block 地址不要用
Interceptor.attach()直接 patch,因为会破坏扁平化状态机。
输出不是一句“已恢复”,而是一组可检查的中间结果:
trace designblock universeCFG role tablereal-block setreal execution orderreal-block disassemblyno-CFG pseudocodestability conclusionRuntime 还原
runtime 还原更接近正常开发:逆向不是到“我懂了”为止,而是要把语义还原成可回归验证的离线模型。
有效状态不是代码能跑一次,而是满足下面几个条件:
| 条件 | 作用 |
|---|---|
| VM image 可装载 | opcode map、payload、relocation slots 能被正确初始化 |
| context 可重建 | entry、IP、寄存器和 VM state slot 能被模拟 |
| handler 可回归 | 已恢复指令语义能跑通 observed traffic |
| 回归可验证 | 保留输入形态和输出一致性检查 |
| 失败可定位 | decode、IP、memory、unknown semantic 等失败原因可区分 |
验证标准很明确:
VM init model is reproducibleobserved traffic can be replayed offlineunknown selectors remain explicitly markedversion migration has a fixed proof sequence整个流程里最重要的是证据图。每个 claim 都要能落到证据类型:
Claim: deeper runner is localized.
Static evidence: effective call list reaches bridge -> runner.
Dynamic evidence: timestamp wrapper surrounds the candidate; same-thread ranking and function size agree; bridgeLeave path is stable across cold spawns.
Runtime evidence: no-CFG pseudocode feeds an offline VM model; observed traffic verifies the restored semantics.失败也要结构化。例如 VM-init 能拿到 stable pointers,但大 payload read 卡住请求,就不要继续加 hook。应该把 pointer chain 和 blocker 分开记录,再换到二阶段 dump 或离线分析。
难点处理
流程看起来线性,实际最容易出错的地方都在边界:伪代码边界、trace 边界、版本边界和采样边界。下面这些问题如果不提前处理,后面的语义化会被噪声拖偏。
1. Hex-Rays 伪代码不可直接信任
flattened runner 的 F5 会把 dispatcher 状态、selector 常量和真实业务 micro-op 混在一起。处理方式是先用动态 block coverage 建立 real path,再回到 IDA 标色和反汇编。F5 只能作为候选解释,不能作为最终语义来源。
2. 热路径 instrumentation 会改变目标行为
rqtx 是请求关键路径。过重的 hook、频繁 send()、大块 dump、长时间 Stalker 都可能让请求消失。因此 Frida 用法要拆成:
pointer-only capture firstsecond-phase dump latersame-thread short-window traceblock-start only by default3. Watchdog 和 re-entry 噪声
watchdog 捕获的 PC 集合不稳定,不能和 canonical bridgeLeave 混合。恢复 semantic table 时只采纳 bridgeLeave 的稳定交集。
4. 版本地址漂移
runtime address 必须通过 slide 映射回 IDA。脚本里应维护 version profile,而不是散落 offset。未来版本迁移时,旧知识可以迁移,旧地址不能直接信。
5. relocation 差异容易误判
rqtx.dat live payload 和旧 payload 的少数字节差异,如果集中在 patch-slot 区域,要先用 relocation model 解释,不能马上宣称算法变了。
6. observed mode 不等于全覆盖
当前 observed traffic 能被验证,不等于所有 traffic class 和异常分支都已穷尽。未命中 selector、watchdog-only blocks、错误路径必须保留 unknown。
离线 Runtime
当 no-CFG 语义表积累到一定程度后,下一步不是继续堆 hook,而是把 VM 运行环境搬到离线模型里。这样每次修改 decode 或 handler,都能快速回归。
离线 runtime 的任务是复现 VM 运行环境,而不是只翻译几个 handler。它要模拟:
- opcode map header。
- page-aligned main memory。
- memory region tree。
- payload copy。
- relocation patch slots。
- context buffer。
- IP 和 next IP storage。
- handler semantics。
- halt reason 和 validation result。
runtime skeleton 中的 flywheel 包含两类执行入口:
| 入口 | 作用 |
|---|---|
| v08-compatible | 用旧版模型验证基础 opcode/handler 语义 |
| observed-bridge-like | 用 870 bridge 参数形态逼近 live path |
验证循环不是一次性写完,而是:
recover selector/handlerrun offline replaycompare expected/actualinspect halt reasonpatch decode or semanticrepeat until matched=true离线回归关注输入形态、命令类别、长度区间、返回值一致性和失败原因。这样 verifier 能证明语义闭合,也不会把采样内容和还原逻辑混在一起。
版本迁移思路
离线 runtime 的另一个价值,是给未来版本迁移留下路线。迁移时不要从“旧 runner 地址”开始,而要从“旧证明流程”开始。
下一次版本迁移时,不应该从 runner 开始,而应该沿着同一条证据链重新证明:
- ObjC anchor 是否仍是
WCCalRqtDataMgr - calRqtData:len:cmd:uin:。 - slot
0x28是否仍解析到同类 body。 - body 输入是否仍是 32 字节 ASCII hex +
cmd。 - VM init point set 是否仍能解释
blobPtr/headerPtr/ctxPtr/0x80418。 rqtx.datlayout 是否仍是 opcode map + payload image + relocation slots。- runner candidate 是否被 timestamp wrapper 和 ranking 同时支持。
- bridgeLeave trace 是否能三次 cold spawn 收敛。
- offline runtime 是否仍能验证 observed traffic。
这样迁移时就不会被单个 offset 变化打乱。变化会被定位到具体层:入口、slot chain、VM init、blob layout、runner CFG、指令语义或 bridge 参数。
复盘
回头看,第一篇真正想建立的是一种拆解方式:先把保护结构拆成可命名对象,再把每个对象放回证据链里验证。
rqtx 的 IRVMP 设计核心是“数据和解释器分离”:业务逻辑被放进 rqtx.dat 的 VM image,native runner 负责解释,context 承载调用状态,opcode map 和 IP-aware decode 增加静态分析成本,CFG 扁平化再隐藏 runner 的真实 micro-op。
逆向上对应的解法也是分层的:
ObjC anchor -> slot/body proof -> VM init/layout proof -> payload dump and relocation model -> runner localization -> CFG skeleton stripping -> no-CFG semantic table -> offline runtime -> observed traffic verification这条链路的价值不在“拿到一个返回值”,而在可审计:每个结论都能追到一个静态地址、一次 live trace、一类动态证据或一条离线验证记录。对专业逆向工程来说,这比单次成功 hook 更重要。
常见问题
RQTX 是什么?
RQTX 是 WeChat/微信客户端请求链路中的一段受保护计算路径。本文关注的是它在 WeChat 8.7.0 中的 IRVMP 结构、rqtx.dat 数据布局、VM 初始化、指令编码和离线 runtime 还原方法。
WeChat RQTX IRVMP 的核心保护点是什么?
核心保护点是数据和解释器分离:业务逻辑被封装到 rqtx.dat 的 VM image 中,native runner 负责解释执行;同时 opcode decode 混入当前 IP,runner 又经过 CFG 扁平化,导致静态反编译难以直接得到真实语义。
rqtx.dat 文件包含什么?
rqtx.dat 当前恢复模型中包含 opcode map、payload image、relocation/export slots、常量数据块和内部 VM blocks。分析时要先解释 runtime patch slot 和 relocation 差异,再判断算法或 VM bytecode 是否真的变化。
如何定位 WeChat RQTX 的 VM runner?
定位 VM runner 不能只靠单个地址。更可靠的路径是先证明 ObjC anchor 和 slot chain,再确认 body input shape、VM init/layout、timestamp wrapper、same-thread ranking 和 bridgeLeave trace,最后用离线 runtime 验证 observed traffic。
为什么不能直接相信 Hex-Rays F5 伪代码?
flattened runner 的伪代码会把 dispatcher 状态、selector 常量和真实 micro-op 混在一起。正确做法是先用动态 block coverage 还原真实路径,再回到 IDA 反汇编和离线 runtime 中验证 handler 语义。
这套方法如何迁移到新的 WeChat 版本?
迁移时应复用证明流程,而不是复用旧地址。优先重新验证 calRqtData:len:cmd:uin:、slot 0x28、32 字节 ASCII hex 输入、VM init point set、rqtx.dat layout、runner candidate 和 offline runtime 回归结果。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
保持匿名、聚焦技术讨论;昵称和站点都可留空。