WeChat/微信 RQTX 第二篇:从 VM Runtime Trace 到语义化算法还原
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 或语义化实现做一次反向比对。
- 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.inputruntime input after patchruntime rqtx resultchallenge.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命令形态保持简单:
frida-ps -Uaifrida -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.jsGadget 模式下脚本不需要改变,只是连接方式从 -U -f 变成连接 Gadget 暴露的会话。建议仍然先跑只读版本,确认边界以后再跑单次替换版本。
两种方式的判断标准相同:
page challenge input == runtime patched input
page challenge result == runtime returned rqtx如果不一致,优先排查:
- hook 点是否在
md5_hex_32边界,而不是 raw body 或 digest 中间态; - 参数是否是 ASCII hex 指针,而不是
NSData、NSString或 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 的边界 |
RqtxShaLikeCore | hash 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 hexinput_len -> 0x20output_ptr -> 4-byte resultoutput_len -> 4entry_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 trace | VM opcode 是否解对 | 小窗口 decoded instruction |
| memory boundary trace | 何时读 input、何时写 output | input/output 边界事件 |
| dynamic block trace | 哪些 block 是主路径热点 | block execution count |
| block memory roles | block 读写 context 还是 main memory | per-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 byte | input_staging |
| 中段 | 不再直接读 input,只操作 context/main memory | hash_core |
| 末尾 | 写 4 字节 output,并恢复 output length | final_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 / schedulectx range B -> [u32; 8] statectx range C -> round indexctx range D -> Digest32 / Digest16一旦这些名字成立,文章和代码里就不应该再围绕 ctx+offset 讲故事,而应该围绕 block/state/digest 讲。
第五步:用 block-local instruction window 对照标准算法
最后才进入标准算法对比。
对 SHA-2-like 候选 block,检查这些 micro pattern:
rotate/xor group for arotate/xor group for eCh(e, f, g)Maj(a, b, c)h + sigma + ch + W[i] + K[i]sigma + majstate rotationfeed-forward对 MD5-like tail 候选 block,检查这些 micro pattern:
4 lanes4 rounds16 steps per roundmessage index tablerotation tableround function switchtarget/carry lane orderfinal feed-forward lane order对 finalizer 候选 block,检查这些 micro pattern:
read 16 digest bytesacc = acc * constant + byteperiodic byte maskmerge high/mid/low laneswrite visible output prefix这一步完成后,trace 才真正把 runtime 定位成算法。它不是“我猜这是 SHA”,而是:
dynamic count 证明 round 形态memory role 证明输入和状态窗口context diff 证明每轮读写关系instruction window 证明布尔函数和 rotateoracle 证明语义化实现输出一致这五类证据叠在一起,才足够支撑算法命名。
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 轮;
Ch和Maj这种 SHA-2 典型布尔结构;W[16..64]消息扩展;- 每轮更新
a..h。
但是所有能被标准实现直接识别的地方都被换掉了。
| 部位 | 标准 SHA-256 | RQTX 语义化后观察到的形状 |
|---|---|---|
| IV | 标准 IV | profile-specific IV |
| BigSigma | 标准 rotate 组合 | 自定义 rotate 组合 |
| small sigma | 标准 rotate/shift 组合 | 自定义 rotate/shift 组合 |
| K 表 | 一张标准 K 表 | 两张自定义 K 表,按轮次选择 |
| word packing | big-endian message word | little-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的压缩流。
但确认到这里还不够。还要继续验证几个容易错的点:
W[i]的装载字节序。W[16..64]的 small sigma 参数。BigSigma0/1的 rotate 参数。- K 表选择规则。
- 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。差异在于:
| 部位 | 标准 HMAC | RQTX 形状 |
|---|---|---|
| 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:
| 部位 | 标准 MD5 | RQTX tail transform |
|---|---|---|
| IV | 标准 MD5 IV | profile-specific IV |
| round function | 标准 F/G/H/I | 替换后的四类布尔函数 |
| constants | sine table | profile-specific constants |
| rotate | left rotate | right rotate |
| word packing | little-endian | 自定义字节重排 |
| output lane | a,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)}判断语义化是否完成,可以看四个标准:
- 函数名描述算法角色,而不是 VM 来源。
- 参数是
state/block/digest,不是ctx+offset。 - 每个 primitive 都能单独测试。
- 最终结果和 VM oracle 对齐。
达到这一步以后,VM runtime 才从“唯一实现”变成“回归裁判”。
标准算法对比:像,是线索;不像,才是重点
把每一段单独看完以后,再放到标准算法旁边对照,整体定位会更清楚。相似性提供方向,差异点才决定实现边界。
把 RQTX 放到标准算法旁边看,它的设计意图会清楚很多。
| 结构 | RQTX 借用的部分 | RQTX 修改的部分 | 逆向意义 |
|---|---|---|---|
| MD5 | 输入摘要、tail 的 4-lane/64-step 形状 | tail IV、常量、rotate、word packing、lane 输出 | 不能按标准 MD5 跑,只能借结构识别 |
| SHA-256 | 8-lane state、64-round compression、Ch/Maj、schedule | IV、Sigma、K 表、word packing、feed-forward | 主体是 custom SHA-2-like |
| HMAC | inner/outer 双层 digest | 无标准 ipad/opad,使用 profile block | 是 HMAC-like wrapper,不是 HMAC |
| CRC | 32-bit 输出形状 | 无 polynomial,无 bit feedback | finalizer 是 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 coreHMAC-like wrapperMD5-like tail transformrolling-133 output mixerVM oracle regression这就是从 VM runtime 到语义化算法还原的完整闭环。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
保持匿名、聚焦技术讨论;昵称和站点都可留空。