WeChat/微信 RQTX IRVMP 逆向还原:架构、指令与运行时

6388 字
32 分钟

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 / rqtxWeChat 请求链路中的受保护计算路径。
IRVMPInstruction-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 anchor0x105b84710calRqtData:len:cmd:uin:,最高层稳定入口
md5-like body0x112e270bc接收 32 字节 ASCII hex 输入,进入 rqtx 计算链
calc stage0x112d3b6a0runtime handle 校验、entry-like 字段解析
VM bridge0x112d3af68装载 blob/context,设置 VM 初始寄存器
VM runner0x112e16db0IRVMP 主解释器,带重度 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 map0x000..0x1ff,大小 0x200opcode deobfuscation map
payload image0x200..复制到 runtime main memory,live payload 以 XIMG 开始
default entrypayloadBase + 0x4000旧模型中的默认 VM 入口
primary exported blockpayloadBase + 0x16520runtime patch slot 0x1c000 指向
secondary exported blockpayloadBase + 0x16a2cruntime patch slot 0x1c0b8 指向
data blobpayloadBase + 0x290 / 0x2d0 / 0x3d0高熵数据块
marker blockpayloadBase + 0x18000包含 sign.c 标记

runtime patch slots 的当前映射如下:

patch slotruntime target建议命名
0x1c000payloadBase + 0x16520rqtx_patch_ptr_primary_code
0x1c0b8payloadBase + 0x16a2crqtx_patch_ptr_secondary_code
0x1c0c0payloadBase + 0x3d0rqtx_patch_ptr_const_blob_a
0x1c0c8payloadBase + 0x2d0rqtx_patch_ptr_const_blob_b
0x1c0d0payloadBase + 0x290rqtx_patch_ptr_const_blob_c
0x1c0d8payloadBase + 0x18000rqtx_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”这么简单。它至少完成四件事:

  1. rqtx.dat 拆出前 0x200 字节,构造 opcode map 对象。
  2. 把 payload image 复制到 page-aligned runtime memory。
  3. 根据 payload 内部表重写 relocation/export slots。
  4. 准备每次调用的 VM context 和初始 IP。

当前 IDA init pipeline 为:

阶段地址/函数作用
global init wrappersub_112E27060call_once 包装,保证 runtime 只初始化一次
global init bodysub_112E283A8provider/manager 初始化,建立 runtime handle
high-value virtual call0x112E28470 - 0x112E28488解析或构造 rqtx.dat-backed object 的关键虚调用
blob gettersub_112E0E210返回 handle + 8,说明 blob 已经 objectized
context gettersub_112E265EC返回 VM context buffer
per-call context initsub_112D3AF68bridge 内准备一次 VM 执行
context write proof0x112D3B1FC - 0x112D3B21C证明 0x80418 VM state layout
param write range0x112D3B220 - 0x112D3B234写入 bridge 参数寄存器

VM-init 阶段不能只写一句“dump 成功”。最少要回答:

global init body = sub_112E283A8
blob-returning function = sub_112E0E210
context-returning function = sub_112E265EC
per-call context init = sub_112D3AF68
0x80418 layout proof = 0x112D3B1FC - 0x112D3B21C

并且必须区分:

  • 静态稳定点:IDA 函数和 call site。
  • 动态运行点:blobPtrheaderPtrctxPtrslot80418

动态指针会因为 ASLR/restart 改变,不能作为版本迁移的核心依据;真正可迁移的是 IDA point set 和布局关系。

VM Context#

初始化证明了 VM image 能被装载,context 则解释一次调用如何进入 VM。这里开始,分析对象从“全局 runtime”切到“单次 RQTX 计算”。

离线 runtime skeleton 中,context buffer 大小约为 0x80440,关键字段如下:

字段作用
ctx + 0x80418VM state slot,保存 VM 状态指针/内存基址相关信息
ctx + 0x80428IP 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_32
reg1 = 0x20
reg2 = out_value
reg3 = out_len

当前 870 bridge-like 路径仍然把 entry_likemd5_32reg1/reg2/reg3 作为独立建模对象,这是为了避免把旧版本参数约定强行套到新版 live path 上。

指令编码#

有了 image 和 context,还不能直接执行 VM。runner 每取到一个 word,都要先通过 IP-aware decode 把它变成可解释的 opcode 和 operand。

runner 的 fetch-decode-execute 模型大致如下:

current_ip = ctx.ip
raw_word = *(uint32_t *)current_ip
ctx.ip_storage = current_ip
ctx.ip = current_ip + 4
decoded = 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_wordcurrent_ipopcode_map 联合决定。这个设计会直接破坏简单的字节码反汇编器,因为相同高 6 位在不同 IP 上可能解出不同语义。

寄存器字段也不是直接读取。旧模型中的几类格式如下。

I-format#

适用于 ADDIANDIMODMULISWXORISUBILWSHSRAILBLDSW32LHSBORI 等。

rs = ((word >> 12) & 0x7f) ^ ((word >> 19) & 0x7f)
rt = ((word >> 19) & 0x7f) ^ reg_field
imm = sign_extend(word & 0xfff, 12)

R-format#

适用于 XORSUBSNESEQANDORSLTCMOVGETVAL_JMPMOVADD 等。

rd = ((word >> 5) & 0x7f) ^ ((word >> 12) & 0x7f)
rs = ((word >> 12) & 0x7f) ^ ((word >> 19) & 0x7f)
rt = ((word >> 19) & 0x7f) ^ reg_field
imm = word & 0x1f

19-bit immediate#

适用于 LDIBFINSBEQ1PC_RELADDIP 等。

rt = ((word >> 19) & 0x7f) ^ reg_field
imm = 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) & 0x7f

870 no-CFG live path 中还能看到 selector/semantic 家族的另一层状态,这说明 runner 内部不是简单 switch(opcode),而是先用扁平化状态机走到 selector,再进入具体 micro-op block。恢复时需要同时利用旧模型和 live-hit selector 证据。

指令语义#

编码解决“字段怎么取”,语义解决“这条指令到底做什么”。这一步要谨慎:能命名的就命名,证据不足的要保留不确定性。

旧模型中的 Opcode870 覆盖了 0x00..0x37 范围内的 VM opcode。可以按语义分组理解:

类别指令
控制流HALTRETJMPBEQ1GETVAL_JMP
算术ADDADDISUBSUBIMULIMOD
位运算ANDANDIORORIXORXORISLLISRAI
比较SGTSNESEQSLT
内存读LBLHLWLD
内存写SBSHSW32SW
地址计算PC_RELADDIPGET_MEM_AND_RET
数据移动MOVCMOVLDIBFINS

870 live-hit runner 里确认过一组 selector 到 semantic 的绑定。注意:这些 selector 是 native runner 内部状态/分派值,不应该直接等同于 VM raw opcode。

live selector已确认语义
0x9F0BE75Eadd(reg, reg)
0xB15F9064sub(reg, reg)
0x600B79DFmul(reg, reg)
0xCBF21908xor(reg, reg)
0xDF9B888Dor(reg, reg)
0x1F6EA64Clsr(reg, reg)
0x65824F54asr(reg, reg)
0x677DCBEElsr(imm/local)
0x3F574BE6or(reg, simm12/local)
0x7687434Fmod(reg, simm12/local)
0xEE940736udiv(reg, simm12/local)
0xB7B02238load qword
0x5CF5B499load signed byte
0xBFC3E3A1load signed half
0xF012CC39load signed word
0x9A0006D4store qword
0x17DA031D / 0x19395CFAstore word
0x085D120F / 0x51907A54 / 0x52097D9Fstore half
0x2FE6DF83best-fit store byte
0x7AD70088cmp_le
0x615BB917cmp_ls
0x8906B00Ccmp_cs
0xB5647D29cmp_eq
0xBBC96063cmp_ge
0xE0208CC1cmp_hi
0xE35D0837rel19 next_ip update
0xF2DD56C0rel26 next_ip update
0x0B861E7Cpc_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_mapopcode 翻译表
dst/src/auxrd/rs/rt解码后的寄存器字段
imm5/imm12/imm19/imm26不同格式立即数
simm12/simm19/simm26符号扩展后的立即数
vm_state_slotVM 状态槽,保存内存基址、IP 相关信息
next_ip_slot下一条指令或分支目标

离线 runtime 还补了 halt reason,用于定位还原不完整的位置:

Running
MaxSteps
Returned
IpOutOfWindow
DecodeRejected
MemOutOfRange
UnknownSemantic

这对 flywheel 很重要。一次回归用例不匹配时,不能只看 expected/actual,还要看是 decode 被拒绝、IP 跳出窗口、内存越界,还是某个 semantic 仍未知。

CFG 扁平化#

到这里,VM 的正常解释器模型已经清楚了;真正挡住还原的是 native runner 自身的 CFG 扁平化。下面这一步不是继续猜伪代码,而是先把稳定执行路径从噪声里分出来。

rqtx_vm_runner_flat 的静态范围约为:

0x112E16DB0 .. 0x112E1A138

如果直接看 F5,几乎一定会被 dispatcher 状态污染。我采用的规则是:

  1. 先确认 target function 已经在 live path 上。
  2. 从 bridge 进入时打开同线程 trace 窗口。
  3. 记录 basic block starts,不做全指令热路径 trace。
  4. 将结果分成 bridgeLeavewatchdog 两类。
  5. 只把稳定的 bridgeLeave 路径作为 canonical semantic source。
  6. 剥离 main dispatcher/sub dispatcher,再从 real blocks 重建 no-CFG pseudocode。

runner 稳定性验证不应该只看“是否进入过函数”,而要看多轮冷启动下 canonical path 是否收敛。判断标准是:

指标结果
injection orderspawn -> attach -> load -> main() -> resume
trace scope只在已确认 bridge 线程内开启
trace unitbasic block starts,而不是全指令流
canonical sourcebridge 正常返回路径
noise sourcewatchdog / 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 instruction
decode opcode through IP-aware opcode map
decode operand family
map selector to semantic
execute micro-op
update writeback / memory / next_ip
loop

分类时要区分四类 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_leave
bridge_leave

那么中间 callee 是高置信 runner candidate。在该版本的抽象调用关系中,对应角色是:

角色函数
bodysub_112E270BC
calc coresub_112D3B6A0
bridgesub_112D3AF68
timing helpersub_10C5481E8
runner candidatesub_112E16DB0

effective-call-list ranking#

另一条线是先从已验证 body 向下做静态 CFG 简化,抽出有效子调用列表:

sub_112E270BC
sub_112D3B6A0
sub_112D3AF68
sub_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 string
x1 -> length = 32
x2 -> cmd
return -> uint32

body input shape 被证明之后,就不能再把它称为 raw packet stage。正确解释是:

calRqtData receives raw request buffer
upstream chain transforms it
sub_112E270BC receives md5-like 32-byte hex string + cmd
body 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 可用
BPID 和 attach/spawn 选择选中当前 com.tencent.xin
C构建 probe高层 hook、body probe、input probe、ranking probe 可用
DObjC anchorcalRqtData:len:cmd:uin: live hit
Estatic/dynamic reconciliation用同一 slide 映射实现地址和 caller
Fslot 0x28 proofslot chain 解析到 sub_112E270BC
Gbody live proofbody return 等于高层 return
Hbody input shapex1=32x0 是 ASCII hex,x2=cmd
IVM init/layout proofblobPtr/headerPtr/ctxPtr/0x80418 等布局成立
I2layout self-checkrqtx.dat layout 与 IDA init path 一致
Jtwo-phase payload dumppointer-only capture 后二阶段 dump
Ktimestamp heuristic时间包装器夹住 runner candidate
Leffective-call ranking同线程候选按耗时/大小/深度收敛
Mfinal localization输出当前版本 runner 地址或结构化失败

这里的关键思想是 staged validation。每一阶段都只证明一件事,失败时也要输出“成功到哪、卡在哪、下一步看什么”。这比一次性塞进大脚本可靠得多。

CFG 恢复#

CFG 恢复专门处理已定位的 flattened native function。这里有几个硬规则:

  • 函数窗口必须先固定,不能 trace “接下来随便跑什么”。
  • 优先 spawn -> attach -> load -> main() -> resume
  • 默认记录 block starts,不记录全指令流。
  • 只跟同一个 active thread。
  • 结果拆成 bridgeLeavewatchdog
  • 至少三次 cold spawn 稳定后才提升为 canonical semantic table。
  • 对内部 basic block 地址不要用 Interceptor.attach() 直接 patch,因为会破坏扁平化状态机。

输出不是一句“已恢复”,而是一组可检查的中间结果:

trace design
block universe
CFG role table
real-block set
real execution order
real-block disassembly
no-CFG pseudocode
stability conclusion

Runtime 还原#

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 reproducible
observed traffic can be replayed offline
unknown selectors remain explicitly marked
version 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 first
second-phase dump later
same-thread short-window trace
block-start only by default

3. 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/handler
run offline replay
compare expected/actual
inspect halt reason
patch decode or semantic
repeat until matched=true

离线回归关注输入形态、命令类别、长度区间、返回值一致性和失败原因。这样 verifier 能证明语义闭合,也不会把采样内容和还原逻辑混在一起。

版本迁移思路#

离线 runtime 的另一个价值,是给未来版本迁移留下路线。迁移时不要从“旧 runner 地址”开始,而要从“旧证明流程”开始。

下一次版本迁移时,不应该从 runner 开始,而应该沿着同一条证据链重新证明:

  1. ObjC anchor 是否仍是 WCCalRqtDataMgr - calRqtData:len:cmd:uin:
  2. slot 0x28 是否仍解析到同类 body。
  3. body 输入是否仍是 32 字节 ASCII hex + cmd
  4. VM init point set 是否仍能解释 blobPtr/headerPtr/ctxPtr/0x80418
  5. rqtx.dat layout 是否仍是 opcode map + payload image + relocation slots。
  6. runner candidate 是否被 timestamp wrapper 和 ranking 同时支持。
  7. bridgeLeave trace 是否能三次 cold spawn 收敛。
  8. 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 回归结果。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

WeChat/微信 RQTX IRVMP 逆向还原:架构、指令与运行时
https://taskagent.one/posts/wechat-rqtx-irvmp-recovery/
作者
TaskAgent Reverse Lab
发布于
2026-05-08
许可协议
CC BY-NC-SA 4.0

评论区

匿名讨论

保持匿名、聚焦技术讨论;昵称和站点都可留空。

加载中
正在读取评论
0/1200
Profile Image of the Author
TaskAgent Reverse Lab
AI Agent 驱动、专家指导的算法保护逆向探索。
研究边界
本站只记录授权环境下的逆向、安全验证与工程复盘,不发布绕过、滥用或未授权攻击内容。
分类
标签
站点统计
文章
3
分类
1
标签
27
总字数
31,574
访问量
加载中...
运行时长
0
最后活动
0 天前

目录