WeChat/微信 RQTX 第二篇:从 VM Runtime Trace 到语义化算法还原

8061 字
40 分钟

WeChat/微信 RQTX 第二篇:从 VM Runtime Trace 到语义化算法还原

RQTX 位于 WeChat 请求链路的签名/校验路径里,输入来自请求相关数据,输出是一个短整数。这篇关注的是第二阶段:当 VM runtime 已经能被观察以后,怎么把一段很长的虚拟机执行流,慢慢还原成一套能读、能测、能维护的算法。

我更愿意把这个过程叫成“从 runtime 到语义”。一开始我们手里只有 VM 的输入、输出、寄存器、内存和一堆 block;最后希望看到的是几个普通工程师能理解的函数名:

message_schedule()
compress_sha_like()
tail_transform()
rolling133_to_0x44()

当代码从 r8/r9/ra/rb 这种寄存器复刻,变成 state/block/digest/finalizer 这种算法对象时,RQTX 才算真正从 VM 里走出来。

接下来先看算法骨架,再回到 trace 如何定位算法:它和标准 MD5/SHA-256/HMAC/CRC 到底哪里像、哪里不像,都会在同一条证据链里展开。

关键结论#

  • RQTX 的输入边界不是原始请求体,而是请求体规整后的 32 字节 lowercase MD5 hex。
  • VM trace 不能直接线性阅读,必须聚合成 memory boundary、dynamic block、context slot diff 和 block-local instruction window。
  • 主体算法不是 CRC,也不是标准 MD5、标准 SHA-256 或标准 HMAC,而是借用了这些算法骨架之后做了 profile-specific 修改。
  • 语义化还原的关键是先给 block 起行为名,再对照标准算法结构验证,最后用 VM oracle 和 primitive tests 收口。
  • VM runtime 不应该被直接扔掉;语义化实现成为默认路径以后,runtime 仍然适合保留为 trace、回归和版本迁移 oracle。

这篇按五层推进:先用页面向量给一个可感知的验证入口,再抽出标准库式算法骨架;接着解释 trace 怎样把 runtime 压缩成算法对象;最后逐段对比 MD5、SHA-2、HMAC、CRC 这些标准形状,说明 RQTX 借了什么、改了什么。

页面刷新生成的验证向量#

下面这组向量由边缘函数在页面加载时生成。每次刷新页面都会得到新的 32 字节 ASCII hex 输入和对应的 0x44 前缀结果。它适合拿来和本地 hook、runtime trace 或语义化实现做一次反向比对。

RQTX Challenge 生成中
input_kind
md5_hex_32
cmd
-
input
loading
rqtx
-
generated_at
-

用 Frida 做本地反向验证#

这组页面向量的用途是反向验证:把页面里的 input 当作一次固定的 32 字节 ASCII hex 输入,在本地授权环境里注入到 RQTX 边界,再观察真机返回的 rqtx 是否等于页面给出的 0x44xxxxxx。它验证的是“语义化实现和 runtime 是否同构”,不是让页面变成任意输入计算器。

验证时建议只改一个边界:sign_md5_hex(md5_hex_32) 的入参。不要在更上游改 body,也不要在更下游改最终返回值。上游会混入 body 规整、命令开关和 buffer 生命周期;下游直接改返回值则失去验证意义。

推荐记录四个字段:

challenge.input
runtime input after patch
runtime rqtx result
challenge.result

四个字段能对齐,才说明本地 hook 点、输入替换和语义化算法三者在同一条边界上。

越狱设备:frida-server 注入#

越狱环境适合快速做一次只读定位和短窗口替换。流程是:

start frida-server on device
-> list target process
-> spawn or attach target
-> install boundary hook
-> replace one md5_hex_32 buffer
-> compare returned rqtx

命令形态保持简单:

Terminal window
frida-ps -Uai
frida -U -f <bundle-id> -l rqtx-vector-check.js

脚本分两段。第一段只读确认命中边界,不改输入:

const TARGET = {
module: "<target-module>",
offset: 0x0, // 填写已确认的 RQTX 边界偏移
inputArg: 0, // 指向 md5_hex_32 的参数序号
output: "retval", // 返回值或输出参数,按本地边界调整
};
const base = Module.findBaseAddress(TARGET.module);
if (base === null || TARGET.offset === 0) {
throw new Error("resolve RQTX boundary first");
}
Interceptor.attach(base.add(TARGET.offset), {
onEnter(args) {
const ptr = args[TARGET.inputArg];
this.input = ptr.readUtf8String(32);
console.log("[rqtx] input", this.input);
},
onLeave(retval) {
console.log("[rqtx] result", "0x" + retval.toUInt32().toString(16).padStart(8, "0"));
},
});

确认只读日志里的 input 长度、字符集和调用时机都正确以后,再做一次单次替换:

const CHALLENGE_INPUT = "<paste-page-md5-hex-32>";
const EXPECTED_RQTX = "<paste-page-result-hex>";
let patchedOnce = false;
Interceptor.attach(base.add(TARGET.offset), {
onEnter(args) {
const ptr = args[TARGET.inputArg];
const before = ptr.readUtf8String(32);
if (!patchedOnce && /^[0-9a-f]{32}$/.test(before)) {
Memory.writeUtf8String(ptr, CHALLENGE_INPUT);
patchedOnce = true;
this.patched = true;
console.log("[rqtx] patched input", CHALLENGE_INPUT);
}
},
onLeave(retval) {
if (!this.patched) return;
const got = "0x" + retval.toUInt32().toString(16).padStart(8, "0");
console.log("[rqtx] got", got, "expected", EXPECTED_RQTX);
},
});

这里有三个检查点:

  • 只替换一次,避免影响后续请求。
  • 只替换 32 字节 lowercase hex 边界,避免误写非目标 buffer。
  • 只比较 runtime 输出,不在 hook 里改返回值。

非越狱设备:Frida Gadget 重打包#

非越狱环境可以用 Frida Gadget 做同样的验证,但前提是目标 App 属于你自己的测试包,或者你有明确授权。Gadget 路径更适合可重复实验,不适合临时扫偏移。

流程可以按下面拆:

prepare test IPA
-> embed FridaGadget dylib
-> add load command or framework reference
-> codesign test package
-> install to test device
-> attach gadget
-> run rqtx-vector-check.js

Gadget 模式下脚本不需要改变,只是连接方式从 -U -f 变成连接 Gadget 暴露的会话。建议仍然先跑只读版本,确认边界以后再跑单次替换版本。

两种方式的判断标准相同:

page challenge input
== runtime patched input
page challenge result
== runtime returned rqtx

如果不一致,优先排查:

  • hook 点是否在 md5_hex_32 边界,而不是 raw body 或 digest 中间态;
  • 参数是否是 ASCII hex 指针,而不是 NSDataNSString 或 VM context slot;
  • 写入后目标函数是否立即复制 input,还是稍后才读取;
  • 返回值是否经过 wrapper 再加工;
  • 命令号是否触发了禁用分支。

这个验证一旦稳定,页面向量就能成为一个轻量的真机回归入口:刷新页面拿一组新向量,本地替换一次,比较一个 0x44 结果。它比贴固定样本更适合发现边界漂移,也不会把文章写成依赖某一次日志的说明。

先把算法抽象成标准库式骨架#

页面向量解决的是“怎么验证一次”。接下来要解决的是“我们到底在验证哪套算法结构”。如果不先把算法骨架讲清楚,后面的 trace 证据会很难读。

还原到语义层以后,最值得先看的不是某个输出值,而是它已经被整理成了接近标准哈希库的形状。

标准哈希库通常长这样:

Hasher::new()
-> update(block)
-> update(block)
-> finalize()

RQTX 的语义化版本也可以按这个思路理解,只是它不是通用流式 hash,而是一个固定输入形状的 profile signer。下面用一份接近标准库接口的伪代码概括接口和数据流:

pub struct Rqtx870 {
profile: &'static RqtxProfile,
}
pub struct RqtxProfile {
sha_like: ShaLikeProfile,
inner_prefix: Block16,
outer_prefix: Block16,
tail: TailProfile,
finalizer: FinalizerProfile,
}
pub type Block16 = [u32; 16];
pub type Digest32 = [u8; 32];
pub type Digest16 = [u8; 16];
impl Rqtx870 {
pub fn sign_body(&self, body: &[u8], cmd: u32) -> u32 {
if should_skip_rqtx(cmd) {
return 0xffff_ffff;
}
let md5_hex = lowercase_md5_hex(body);
self.sign_md5_hex(&md5_hex)
}
pub fn sign_md5_hex(&self, md5_hex: &[u8; 32]) -> u32 {
let inner = self.digest_two_blocks(self.profile.inner_prefix, md5_hex);
let outer = self.digest_two_blocks(self.profile.outer_prefix, &inner);
let digest16 = tail_transform(self.profile.tail, outer);
rolling133_to_0x44(self.profile.finalizer, digest16)
}
fn digest_two_blocks(&self, prefix: Block16, payload: &[u8; 32]) -> Digest32 {
let mut core = RqtxShaLikeCore::new(self.profile.sha_like);
core.update_block(prefix);
core.update_block(payload_block_32(payload));
core.finalize_be32()
}
}

这段骨架把整套算法拆成四个标准库风格的层:

类似标准库里的概念RQTX 中的角色
Rqtx870::sign_body()高层 signer API处理命令开关和 body 到 MD5 hex 的边界
RqtxShaLikeCorehash core处理两个固定 block 的 SHA-2-like 压缩
tail_transform()compression transform对 outer digest 做 MD5-like 64-step 变换
rolling133_to_0x44()finalizer/truncator把 16 字节结果折叠成 32-bit RQTX

再往下看,SHA-like core 也可以写成很标准的 block compressor:

struct RqtxShaLikeCore {
state: [u32; 8],
profile: ShaLikeProfile,
}
impl RqtxShaLikeCore {
fn new(profile: ShaLikeProfile) -> Self {
Self {
state: profile.iv,
profile,
}
}
fn update_block(&mut self, block: Block16) {
let schedule = message_schedule(self.profile, block);
let work = compress_rounds(self.profile, self.state, schedule);
self.state = reverse_feed_forward(self.state, work);
}
fn finalize_be32(self) -> Digest32 {
words_to_be32(self.state)
}
}

tail transform 也不再是寄存器碎片,而是一个表驱动 round engine:

fn tail_transform(profile: TailProfile, outer: Digest32) -> Digest16 {
let block = make_tail_block(outer, profile.fixed_tail);
let words = tail_words(block);
let mut state = profile.iv;
for round in profile.rounds {
tail_round(&mut state, &words, round);
}
tail_feed_forward(profile, state)
}

后面的分析就按这个骨架抽丝剥茧:trace 先定位 sign_md5_hex() 的输入边界,再定位 digest_two_blocks() 的两个 SHA-like 阶段,再定位 tail_transform(),最后定位 rolling133_to_0x44()

从哪里开始:先承认 VM 是唯一裁判#

做 VMP 还原时,最容易犯的错是跳过 runtime,直接猜公式。

RQTX 很像一个“请求体签名”:给 body 和 cmd,最后得到一个 32-bit 整数。这个形状很容易让人想到 CRC、HMAC 或者 MD5(body) 的某种截断。但在证据不足时,这些都只是候选解释,不是结论。

更稳的起点是先把 VM wrapper 的边界复刻出来:

input_ptr -> 32-byte lowercase MD5 hex
input_len -> 0x20
output_ptr -> 4-byte result
output_len -> 4
entry_ip -> VM program entry

一旦这个边界能稳定运行,我们就得到一个本地 oracle:

vm_oracle(<md5_hex_32>) -> <rqtx_u32>

这个 oracle 的意义很大。它不是最终实现,也不是要长期把业务逻辑绑在虚拟机里,而是给后续每一次 lifting 提供判断标准:

  • 这个 opcode 语义有没有跑偏;
  • 这个 block 命名是不是过早;
  • 这个 SHA-like round 是否少了一个 rotate;
  • 这个 tail transform 的 lane 顺序是否反了;
  • 最后的 32-bit 输出是否仍然对齐。

没有 oracle,算法还原很容易变成“看起来像”。有了 oracle,每个猜测都能被打回或者保留下来。

Trace 不是越多越好,而是越能聚合越好#

Oracle 只能回答“对不对”,不能告诉我们“为什么对”。要从 VM 走向语义,还需要 trace 把运行时行为压缩成可以命名的对象。

第一次把 VM 跑起来时,线性 trace 往往非常长。直接读每条指令没有意义,也不现实。比较有效的做法是把 trace 分成几层视图。

视图解决的问题输出形态
instruction traceVM opcode 是否解对小窗口 decoded instruction
memory boundary trace何时读 input、何时写 outputinput/output 边界事件
dynamic block trace哪些 block 是主路径热点block execution count
block memory rolesblock 读写 context 还是 main memoryper-block load/store 统计
context slot summary哪些 context slot 是状态slot 读写次数和范围
context range diff一个 block 到底改了什么before/after 差异
block-local instruction单个 block 的语义可 lift 的伪代码

这套分层背后的思路很朴素:先看边界,再看热点;先看数据流,再看算术;先给行为名,再给算法名。

比如一个 block 读完 32 个 input byte,并把它们写到连续 context 区间里,它就可以先叫:

input_staging

而不是急着说它属于某个哈希算法。

另一个 block 如果执行 64 次,每次读一个 schedule word、一个 round constant,更新 8 个 32-bit 状态字,再回到 dispatcher,它可以先叫:

compression_round_candidate

等 rotate、布尔函数、K 表选择和 feed-forward 都验证后,才把它升级成:

custom_sha_like_compress_round

这个命名顺序很重要。名字越晚确定,错误越少。

Trace 如何把 runtime 定位成算法#

有了分层 trace 视图以后,下一步就是把观察方法固定下来。下面这五步对应从“边界确认”到“标准算法对照”的完整路径。

这里需要讲细一点。trace 的目标不是“把所有 VM 指令打印出来”,而是把 VM runtime 里的行为逐步压缩成算法对象。

我通常按五步走。

第一步:先锁 wrapper 边界#

先不要看热点 block,先确认 VM 是否真的在处理我们关心的 RQTX wrapper。

边界证据包括:

  • wrapper 读取的输入长度是固定的 32 字节;
  • wrapper 读取的输入内容符合 lowercase MD5 hex 的字符形态;
  • wrapper 检查或传播了 4 字节输出长度;
  • wrapper 最终只写一个 32-bit 结果;
  • 同一个输入在 VM oracle 和后续 lift 中返回一致。

这一步对应标准库式骨架里的:

Rqtx870::sign_md5_hex(md5_hex)

也就是说,trace 先证明 core 的入口不是 raw body,而是 32-byte hex string。这个边界一旦错,后面所有 SHA-like 和 MD5-like 对照都会错。

第二步:用 boundary memory trace 找 input staging 和 output write#

接着只看和 wrapper 边界相关的内存访问:

load input_md5[0..32]
store output[0..4]
store output_len

这类 trace 会把执行流切成三个区间:

区间trace 现象暂定行为名
早期连续读取 32 个 input byteinput_staging
中段不再直接读 input,只操作 context/main memoryhash_core
末尾写 4 字节 output,并恢复 output lengthfinal_output

这一步能排掉很多错误方向。比如如果中段不再读原始 input,而是一直操作 context 里的内部状态,那么后续就应该围绕 message_schedule()compress_rounds() 找证据,而不是继续追 input pointer。

第三步:用 dynamic block trace 找循环骨架#

线性 trace 很长,但哈希算法的动态形态很规整:

  • 64 次附近的重复 block,优先怀疑 compression round;
  • 16 到 64 的扩展过程,优先怀疑 message schedule;
  • 4 轮 x 16 step 的形态,优先怀疑 MD5-like transform;
  • 高频小 block 反复分发,优先怀疑 dispatcher 或 loop head;
  • 只执行一次且接近末尾的短 block,优先怀疑 finalizer。

这一步会把代码骨架从黑盒里拉出来:

input_staging
-> seed_message_block
-> expand_schedule
-> compress_rounds x 64
-> feed_forward
-> tail_transform
-> rolling_finalizer

注意这里还是候选名。compress_rounds x 64 只能说明“像压缩轮”,不能立刻说明“就是 SHA-2-like”。

第四步:用 context slot diff 把 block 变成 state/block/digest#

只有执行次数还不够,必须看每个 block 改了什么状态。

对一个候选 block 做 before/after diff,观察它是否:

  • 从一个连续窗口读取 16 或 64 个 word;
  • 修改 8 个连续或半连续的 32-bit state;
  • 每次执行推进一个 round index;
  • 写回 dispatcher handoff slot;
  • 保持输入 schedule 窗口只读,工作状态窗口可变。

如果一个 block 的 diff 显示:

read schedule[i]
read round_constant[i]
read state[a..h]
write state[a..h]

那它就能从 compression_round_candidate 升级成 compress_rounds() 的候选实现。

这个阶段对应标准库式骨架里的参数重命名:

ctx range A -> Block16 / schedule
ctx range B -> [u32; 8] state
ctx range C -> round index
ctx range D -> Digest32 / Digest16

一旦这些名字成立,文章和代码里就不应该再围绕 ctx+offset 讲故事,而应该围绕 block/state/digest 讲。

第五步:用 block-local instruction window 对照标准算法#

最后才进入标准算法对比。

对 SHA-2-like 候选 block,检查这些 micro pattern:

rotate/xor group for a
rotate/xor group for e
Ch(e, f, g)
Maj(a, b, c)
h + sigma + ch + W[i] + K[i]
sigma + maj
state rotation
feed-forward

对 MD5-like tail 候选 block,检查这些 micro pattern:

4 lanes
4 rounds
16 steps per round
message index table
rotation table
round function switch
target/carry lane order
final feed-forward lane order

对 finalizer 候选 block,检查这些 micro pattern:

read 16 digest bytes
acc = acc * constant + byte
periodic byte mask
merge high/mid/low lanes
write visible output prefix

这一步完成后,trace 才真正把 runtime 定位成算法。它不是“我猜这是 SHA”,而是:

dynamic count 证明 round 形态
memory role 证明输入和状态窗口
context diff 证明每轮读写关系
instruction window 证明布尔函数和 rotate
oracle 证明语义化实现输出一致

这五类证据叠在一起,才足够支撑算法命名。

Runtime 里看到的第一条主线#

五步证据聚合以后,runtime 不再是一串分散 block,而会呈现出一条可读的数据流。后面的五段语义分析,都是从这条主线拆出来的。

把 trace 聚合以后,RQTX 的 runtime 形状会从一团执行流变成几段比较清楚的阶段:

wrapper guard
-> input staging
-> schedule seed
-> schedule expansion
-> compression round loop
-> feed-forward
-> second digest stage
-> MD5-like tail transform
-> final rolling mixer
-> output write

其中最关键的观察是:input 只在早期被读完一次。后面的热点 round 不再直接读取 wrapper input,而是在 VM context 里操作已经 staging 过的内部状态。

这意味着还原时不能一直盯着原始 input 指针。正确的目标应该变成:

  • input 被拷贝到了哪个 context range;
  • staging 后是否发生字节顺序调整;
  • 哪个窗口变成了 message schedule;
  • 哪些 slot 承担 8 个工作状态字;
  • 哪些 block 是 64 轮压缩;
  • finalizer 从哪里读取 16 字节 digest。

这也是 runtime trace 到算法还原的核心变化:我们不再问“这条 VM 指令做什么”,而是问“这段 VM 状态对应标准算法里的哪个对象”。

第一段语义:MD5 hex 是入口规整,不是主体算法#

先看入口,是为了避免一开始就把算法类型判断错。RQTX 里确实出现了 MD5,但它承担的是输入规整角色,不是最终签名主体。

RQTX 的主体并不直接处理请求体。请求体先在外层被规整成 32 字节 lowercase MD5 hex:

request_body
-> md5 bytes
-> lowercase hex string
-> 32 ASCII bytes

这个设计对 VM 很友好。VM 不需要处理任意长度 body,也不需要在 runtime 里做复杂 buffer 管理;它只面对一个固定长度输入。

但这一步也很容易误导逆向分析。看到 MD5,就以为结果是 MD5 截断、MD5 加盐、MD5 折叠。实际不是。MD5 在这里更像输入归一化器,它把任意长度请求体变成固定的 32 字节消息块。

语义化以后,可以把边界写成这样:

fn rqtx_from_body(body: &[u8], cmd: u32) -> u32 {
if should_skip_rqtx(cmd) {
return 0xffff_ffff;
}
let md5_hex = lowercase_md5_hex(body);
rqtx_from_md5_hex(&md5_hex)
}

关键边界在这里:RQTX core 的输入是 ASCII hex,不是 raw MD5 bytes。

第二段语义:它像 SHA-2,但不是 SHA-256#

输入被规整成固定 32 字节以后,主体才开始做真正的扩散。这里第一个浮出来的结构,是 8 lane、64 round 的 SHA-2-like 压缩。

进入主体以后,最先浮现出来的是 SHA-2 风格的 8 lane 压缩结构。

标准 SHA-256 的核心是:

T1 = h + BigSigma1(e) + Ch(e,f,g) + W[i] + K[i]
T2 = BigSigma0(a) + Maj(a,b,c)

RQTX 的 compression round 保留了这套骨架:

  • 8 个 32-bit working state;
  • 64 轮;
  • ChMaj 这种 SHA-2 典型布尔结构;
  • W[16..64] 消息扩展;
  • 每轮更新 a..h

但是所有能被标准实现直接识别的地方都被换掉了。

部位标准 SHA-256RQTX 语义化后观察到的形状
IV标准 IVprofile-specific IV
BigSigma标准 rotate 组合自定义 rotate 组合
small sigma标准 rotate/shift 组合自定义 rotate/shift 组合
K 表一张标准 K 表两张自定义 K 表,按轮次选择
word packingbig-endian message wordlittle-endian 读取后再做字节旋转
feed-forward同 lane 相加reverse lane feed-forward

所以它最准确的名字不是 SHA-256,而是:

custom SHA-2-like compression

这类命名看似保守,但很重要。它强调这里识别的是结构,不是把它强行归类为标准算法。

为什么能确认它是 SHA-2-like#

上一节给了结论,这一节补证据。它不是靠常量长得像,也不是靠函数名猜出来,而是靠动态次数、状态窗口和指令窗口共同收敛。

确认算法骨架,靠的不是“看起来像”,而是 trace 证据。

一个 hot block 如果满足这些条件,就非常值得拿去对照 SHA-2:

  • 动态执行次数是 64 轮附近;
  • 每轮读取一个 schedule word;
  • 每轮读取一个 round constant;
  • 反复更新 8 个 32-bit 状态;
  • 出现 Ch(e,f,g)Maj(a,b,c) 等价表达式;
  • 出现两组 rotate-xor 结构;
  • round 后状态移动符合 a,b,c,d,e,f,g,h 的压缩流。

但确认到这里还不够。还要继续验证几个容易错的点:

  1. W[i] 的装载字节序。
  2. W[16..64] 的 small sigma 参数。
  3. BigSigma0/1 的 rotate 参数。
  4. K 表选择规则。
  5. 64 轮结束后的 feed-forward lane 顺序。

尤其是 feed-forward。标准 SHA-256 是同 lane 累加,而 RQTX 是反向 lane 注入。这种改动不会破坏整体扩散,但会让所有标准 SHA-256 对照都失败。

语义化代码里,我会把它拆成四个函数,而不是保留 VM context 偏移:

fn message_schedule(block: [u32; 16]) -> [u32; 64];
fn compress_sha_like(state: [u32; 8], block: [u32; 16]) -> [u32; 8];
fn choose_round_constant(round: usize, selector: u32) -> u32;
fn reverse_feed_forward(iv: [u32; 8], work: [u32; 8]) -> [u32; 8];

这几个函数名就是还原成果。它们把 VM context 里的 slot 偏移收束成稳定的算法对象,让后续维护可以围绕语义而不是偏移展开。

第三段语义:它像 HMAC,但不是 HMAC#

SHA-like core 不是只跑一次。它被包成了两层 digest,这一层很容易让人联想到 HMAC,但证据显示它只是借用了双层结构。

主体不是只压缩一次。整体更像两层包装:

inner = H(T1 || md5_hex_block)
outer = H(T2 || inner_digest_block)

看到这个结构,很自然会联想到 HMAC:

H(key ^ ipad || message)
H(key ^ opad || inner_digest)

但 RQTX 并不是标准 HMAC。差异在于:

部位标准 HMACRQTX 形状
key 处理key 与 ipad/opad XOR固定 profile block
hash core标准 SHA-256/MD5 等custom SHA-2-like core
message原始消息或摘要32-byte MD5 hex / inner digest
输出标准 digest继续进入 tail transform

它借用了 HMAC 的双层结构,但并没有使用标准 HMAC 的 key 派生和标准 hash core。

这段结构的工程意义是:第一层把固定 profile 材料和请求摘要混在一起,第二层再把 inner digest 重新压一次。这样外部只看到一个短整数,很难从输入直接建立线性关系。

语义化以后,可以写成:

fn digest_two_blocks(prefix: ProfileBlock, payload: [u8; 32]) -> [u8; 32] {
let mut core = RqtxShaLikeCore::new();
core.update_block(prefix);
core.update_block(payload_to_message_block(payload));
core.finalize_be32()
}

这里的 ProfileBlock 表示 profile 绑定的固定块;它和 hash core 一起决定两轮压缩的形状。

第四段语义:它像 MD5,但也不是 MD5#

两层 digest 之后,算法没有直接截断,而是进入一个 MD5-like tail transform。这里是第二个容易误判的地方:形状像 MD5,但细节全部要重新验证。

outer digest 之后,算法没有直接截断。它又构造了一个 64 字节 tail block:

outer_digest || fixed_tail

然后跑一段 MD5-like 的 64 step transform。

它像 MD5 的地方很明显:

  • 4 个 32-bit lane;
  • 4 轮;
  • 每轮 16 step;
  • 有 message order 表;
  • 有 rotation 表;
  • 每一步都更新一个 lane 并加回 carry。

但它不是标准 MD5:

部位标准 MD5RQTX tail transform
IV标准 MD5 IVprofile-specific IV
round function标准 F/G/H/I替换后的四类布尔函数
constantssine tableprofile-specific constants
rotateleft rotateright rotate
word packinglittle-endian自定义字节重排
output lanea,b,c,d改写后的 lane order

第一版硬翻译时,这段最容易变成一大坨寄存器变量。每个 step 看起来都像手写的临时代码,读完也不知道算法是什么。

正确的语义化方式是把它还原成表驱动:

enum TailRound {
Xor,
ChooseB,
ChooseD,
Md5I,
}
fn tail_transform(block: [u8; 64]) -> [u8; 16] {
let words = tail_words(block);
let mut state = tail_iv();
tail_round(&mut state, &words, ROUND1, TailRound::Xor);
tail_round(&mut state, &words, ROUND2, TailRound::ChooseB);
tail_round(&mut state, &words, ROUND3, TailRound::ChooseD);
tail_round(&mut state, &words, ROUND4, TailRound::Md5I);
tail_feed_forward(state)
}

这样写的价值不只是好看。它能让测试更细:

  • tail_words() 单独测字节序;
  • tail_step() 单独测 carry 和 rotate;
  • 每轮 message order 单独审计;
  • final lane mapping 单独回归。

VMP 还原里,能拆出这种 primitive test,意味着我们已经不再被 VM 控制流牵着走。

第五段语义:最后一步不是 CRC,而是 rolling mixer#

最后一步只输出 32-bit,所以最容易被历史命名带偏成 CRC。这里必须回到 dataflow:它读的是 16 字节 digest,做的是滚动混合和 bit mask。

RQTX 最终只输出 32-bit,历史命名里很容易出现 crc 这类词。但从 dataflow 看,最后一步不是 CRC。

CRC 通常会有 polynomial、bit feedback 或查表状态转移。RQTX finalizer 更像三条滚动 hash lane:

acc = acc * 133 + byte

三条 lane 从 16 字节 digest 的不同偏移开始滚动,中间穿插截断,最后再 mask 到 32-bit 输出里:

digest16
-> low lane
-> mid lane
-> high lane
-> mask and merge
-> 0x44xxxxxx

这里能解释一个现象:输出高字节稳定落在 0x44 族。它不是 digest 自然高字节,而是 finalizer 明确写入的可见前缀。

更准确的名字是:

rolling-133 output mixer

不要再叫 CRC。名字不只是描述习惯,也会影响后续排查方向。如果把它当 CRC,后面就会一直去找不存在的 polynomial。

从寄存器复刻到语义代码#

五段语义都确认以后,代码也应该从 VM 形态切到算法形态。否则实现虽然能跑,维护者仍然要在寄存器和 offset 里读故事。

VMP lifting 通常会经历两个版本。

第一版是寄存器复刻:

let mut r8 = ...;
let mut r9 = ...;
let mut ra = ...;
let mut rb = ...;

这版的目标是跑通。它保留了 VM 执行时的变量形态,能帮助我们快速验证“翻译有没有漏指令”。

但这还不是算法还原。它只是把 VM 寄存器换成 Rust 变量。

第二版才是语义化:

fn rqtx_from_md5_hex(md5_hex: &[u8; 32]) -> u32 {
let inner = custom_sha_like(T1, md5_hex_block(md5_hex));
let outer = custom_sha_like(T2, inner_digest_block(inner));
let digest16 = tail_transform(tail_block(outer));
rolling133_to_0x44(digest16)
}

判断语义化是否完成,可以看四个标准:

  1. 函数名描述算法角色,而不是 VM 来源。
  2. 参数是 state/block/digest,不是 ctx+offset
  3. 每个 primitive 都能单独测试。
  4. 最终结果和 VM oracle 对齐。

达到这一步以后,VM runtime 才从“唯一实现”变成“回归裁判”。

标准算法对比:像,是线索;不像,才是重点#

把每一段单独看完以后,再放到标准算法旁边对照,整体定位会更清楚。相似性提供方向,差异点才决定实现边界。

把 RQTX 放到标准算法旁边看,它的设计意图会清楚很多。

结构RQTX 借用的部分RQTX 修改的部分逆向意义
MD5输入摘要、tail 的 4-lane/64-step 形状tail IV、常量、rotate、word packing、lane 输出不能按标准 MD5 跑,只能借结构识别
SHA-2568-lane state、64-round compression、Ch/Maj、scheduleIV、Sigma、K 表、word packing、feed-forward主体是 custom SHA-2-like
HMACinner/outer 双层 digest无标准 ipad/opad,使用 profile block是 HMAC-like wrapper,不是 HMAC
CRC32-bit 输出形状无 polynomial,无 bit feedbackfinalizer 是 rolling mixer

所以这套算法不是“新密码学原语”,更像协议工程里的 anti-abuse 签名字段:

  • 输入固定,方便嵌入 VM;
  • 骨架成熟,扩散够用;
  • 常量和 lane mapping 全换,避开标准指纹;
  • 最后压成短整数,适配现有 packet 字段;
  • 外面再套 VMP,提高还原成本。

理解这个定位以后,文章就不会跑偏。我们不是在证明它比 SHA-256 更安全,而是在还原它如何借用 SHA/MD5/HMAC/rolling hash 的工程结构。

验证闭环#

读到这里,算法结构已经能讲通;但能讲通不等于能替换 runtime。最后必须用分层测试把每个 primitive 和最终输出都收住。

语义化实现能不能替换 VM runtime,不能靠人工阅读决定。需要一组分层验证。

验证层目的
VM wrapper oracle确认最终输出仍然和 runtime 一致
generated MD5-shaped inputs覆盖固定长度输入空间的多样性
body-level wrapper确认 body -> lowercase_md5_hex -> rqtx 边界正确
command gate确认不需要 RQTX 的命令返回固定禁用值
primitive tests确认 SHA-like、tail transform、rolling mixer 单独正确
legacy unrolled compare确认语义化重构没有改坏已验证行为

验证时只需要把等式和覆盖层级讲清楚:

semantic_lift(input) == vm_oracle(input)

每一层都要能重复执行、能定位失败来源。这样语义化实现才能从“看起来像”变成“可以替换 VM runtime”。

AI 在这个过程里的位置#

有了 oracle 和 primitive tests,AI 才适合进场。它可以帮忙归纳和重构,但不能替代证据链本身。

这类还原很适合用 AI,但 AI 不能当裁判。

AI 最有价值的地方,是帮我们把大量 trace 归纳成候选结构。例如把一个 hot block 的特征整理给它:

- 64 次 round
- 8 个 32-bit state
- Ch/Maj-like boolean expression
- rotate-xor
- W[i] + K[i]

它很快会提示 SHA-2 compression candidate。这个提示很有用,但结论必须回到 runtime 验证:

  • rotate 参数是否一致;
  • K 选择是否一致;
  • feed-forward 是否反向;
  • word packing 是否重排;
  • 输出是否匹配 oracle。

AI 也很适合把寄存器展开代码改成表驱动结构。比如 tail transform 的 64 step,手工整理很枯燥,但 AI 可以帮你抽出 round enum、message order table、rotation table 和 generic step。

不过生成后必须测试。像 lane order、signed byte、最后一轮 feed-forward 这种细节,AI 很容易写成标准 MD5 的直觉版本。只有 oracle 和 primitive tests 能把这些幻觉打掉。

最实用的分工是:

AI: 归纳、命名、重构候选、提出排查路线
Oracle: 判断候选是否成立
工程测试: 防止语义化重构退化

这次最容易踩坑的地方#

这些坑基本都来自“太早命名”:太早叫 MD5、太早叫 SHA-256、太早叫 HMAC、太早叫 CRC。把它们集中列出来,是为了提醒后续版本迁移时先验不要压过证据。

复盘下来,几个坑值得单独写出来。

第一,MD5 hex 是 ASCII。主体不是吃 16-byte raw MD5,而是吃 32-byte lowercase hex。这个边界错了,后面全错。

第二,SHA-like word packing 不是标准 big-endian。它更像“按 little-endian 读入,再做字节旋转”。这一步会影响每一个 schedule word。

第三,K 表不是标准 SHA-256 K,也不是单表。当前语义是 profile-specific table selection。

第四,feed-forward 是反向 lane。这个细节很隐蔽,因为 round 内部看起来已经像 SHA-2 了,很容易最后手滑写成标准形式。

第五,tail transform 像 MD5,但不是 MD5。它的 IV、常量、round function、rotate 方向、word packing 和输出 lane 都被换过。

第六,最后不是 CRC。它是 rolling-133 三 lane mixer,0x44 是最后写入的可见前缀。

第七,VM runtime 和语义化实现的职责要分开。默认运行应该走语义化代码,VM runtime 留作 oracle 和未来版本迁移工具。

把两阶段合成一条完整链路#

第二阶段解决算法,但它离不开第一阶段的 runtime 证据。把两阶段合起来看,RQTX 还原其实是一条从入口证明到语义替换的长链路。

完整的 RQTX 还原叙述可以拆成两段:先解决“VM 在哪里、怎么跑、怎么证明它不是普通 native 函数”,再解决“VM 跑起来以后,怎么把它变成可读算法”。

合起来,RQTX 的还原链路就是:

native anchor
-> VM runtime boundary
-> local oracle
-> trace observability
-> block behavior naming
-> standard algorithm comparison
-> semantic lifting
-> primitive regression
-> production replacement

这条链路里,每一步都要保留证据边界:

  • trace 只能证明 observed path;
  • wrapper oracle 不能自动证明所有 manager 侧输入;
  • 像 SHA-2 不等于 SHA-256;
  • 像 HMAC 不等于 HMAC;
  • 输出 32-bit 不等于 CRC;
  • 能跑的寄存器翻译不等于语义化完成。

这也是我觉得逆向工程最有意思的地方:真正难的不是“猜到一个名字”,而是把猜测一步步变成可反驳、可验证、可维护的工程对象。

最后的经验#

如果只用一句话总结这次 RQTX 第二阶段,我会说:

先让 VM 变成 oracle,再让 trace 变成证据,最后让证据变成普通算法代码。

VMP 会把控制流揉碎,但它藏不住数据流的形状。输入会被 staging,状态会被反复更新,round 会留下执行次数,digest 会被 finalizer 写出。只要把这些痕迹聚合起来,算法就会从 runtime 噪声里慢慢浮出来。

等到最后,成片的 VM 寄存器会退到幕后,留下来的应该是那些真正稳定的算法对象:

custom SHA-2-like core
HMAC-like wrapper
MD5-like tail transform
rolling-133 output mixer
VM oracle regression

这就是从 VM runtime 到语义化算法还原的完整闭环。

文章分享

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

WeChat/微信 RQTX 第二篇:从 VM Runtime Trace 到语义化算法还原
https://taskagent.one/posts/wechat-rqtx-vm-runtime-to-semantic-hash/
作者
TaskAgent Reverse Lab
发布于
2026-05-09
许可协议
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 天前

目录