目标站点:4f3K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4N6%4N6#2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2U0L8$3@1`. · 目标参数:请求头 M-S(签名)与 M-T(时间戳)· 内部算法:ChaCha20 流加密 · 最终方案:Node.js 补环境 + WASM 黑盒 · 时间:2026-10
摘要:这一单我踩了三块硬骨头——先是被 wasm 里一个叫 ascon 的字符串骗得以为算法是 Ascon,结果反汇编挖出来是 ChaCha20;再是拿一串 124 字符的真实签名当基准,对着自己 100 字符的输出怀疑"是不是少算了 18 字节",最后发现签名长度本来就随 URL 长度变;最后在"逆 wasm 内部"这条死路上耗了很久,才掉头走"补环境黑盒"这条路,几行 mock 就把签名在 Node 里原样复现出来了。全程没碰 wasm 内部一行明文逻辑,但把它的底裤——算法家族、密钥、环境依赖——全扒了一遍。
目标是要抓某约稿平台「约稿大厅」的数据,接口长这样:
浏览器里点开正常返回 JSON,但直接 curl / requests / fetch 过去就是 403。抓包一看,请求头里多了两个字段:
M-T 一眼就是秒级时间戳,M-S 一看就是签名。签名不对,服务端直接拒绝。
所以目标非常明确,就一句话:还原生成 M-S 这个参数的算法,在 Node.js 里零浏览器、零登录态地复现。拿到这个签名,后面的爬取、自动化、数据采集才谈得上。
先交代手上的家伙,因为整个逆向的入口就是它——RuyiTrace(如意 trace)。它是个改了内核的 Firefox(内核版本 155.0.1),核心能力是:在页面运行时,把 JS 引擎触发的每一次 DOM / BOM / WebAPI 调用都记录成结构化日志。
为什么要用它?因为对混淆 JS 做逆向,传统手段(下断点、搜字符串、读混淆代码)效率太低——你根本不知道那堆乱码里哪一段在干活。而 trace 的哲学是反过来的:不管代码长什么样,我只看它运行时到底调用了什么。签名最终要发出去,就必然要 setRequestHeader;签名要用加密,就必然要碰 crypto;签名要读环境,就必然要碰 document / navigator。这些调用是藏不住的。
启动它靠一组环境变量 + 命令行,全自动,不需要手动点:
跑完,日志目录里会生成一批 NDJSON 文件,核心是 domtrace/trace_process_<pid>.jsonl。某约稿平台首页这一次,主渲染进程的日志有 24 万多行,旁边还有 cookie、descriptor、eval、event、wasm 几个子目录,wasm 目录里直接 dump 出了页面加载的 fe_sign_*.wasm——这个文件后面会反复用到。
每一条 trace 记录长这样,是一个 JSON 对象:
type 分了三种:call 是方法调用(带 return 返回值)、get 是属性读取(带 value)、set 是属性写入。这个结构是全文后面所有定位工作的基础。
一句话说清这套工具的价值:它把"读混淆代码猜行为"变成了"直接查运行时日志"。逆向从"考古"变成了"查案卷"。
第一步永远是拿到真实样本——没有真值,后面一切"我复现得对不对"都无从判断。
有了 trace 日志,直接从结果倒推。签名要发出去,必然经过 setRequestHeader,所以在 24 万行日志里 grep 这个关键字,命中了一小段:
三个结论当场落地:
再往前翻几行,找到了组装好的完整 headers 对象:
到这里,第一回合收工:真实样本到手,签名产生的落点(axios 拦截器)锁定了。
有了样本,下一步找"这个字符串到底是谁吐出来的"。这比"在哪设置"更进一步——设置只是把现成的值塞进 header,我们要找的是值本身在哪诞生。
方法是在日志里搜这个签名值第一次出现的位置。结果是一段 TextDecoder.decode 的调用——签名是从一块 wasm 内存里解码出来的:
args 里那串数字就是内存里的原始字节,return 是解码出来的签名字符串。关键在调用栈——再往上追,看到一个 wasm-bindgen 的标志性函数名,栈底落在一个 .wasm 文件上:
结论清晰了:签名算法被封装在 Rust 编译的 WebAssembly 模块 fe_sign_bg.DLpTGLRB.wasm 里,JS 只是 wasm-bindgen 自动生成的胶水层。文件名 fe_sign 直译就是"前端签名"(fe = frontend,sign = 签名),直接坐实了。
同时顺带确认了时间戳的生成方式,日志里 Date.now → Math.floor 的调用链一清二楚:
M-T = Math.floor(Date.now() / 1000),秒级时间戳,和第一回合的 1778571128 对上了。
第二回合收工:算法在 wasm 里,不在 JS 里。 这决定了后面的路线选择。
到这里出现岔路,两条路摆在面前:
选哪条,取决于一个关键问题:wasm 到底从环境里吃了多少东西? 吃得多,补环境就麻烦;吃得少,补环境就是最优解。于是继续翻 trace,把 wasm 初始化时碰过的每一个环境 API 都翻出来。
它把 favicon 的 URL 当签名材料。 日志 7712 行附近,wasm 初始化时主动去查 DOM:
它主动去取页面 <link rel="icon"> 的 href。为什么签名要碰 favicon?因为这是环境指纹——真实浏览器里这个值一定存在且正确,无头环境里就没有。把它混进签名,等于把"你在不在真浏览器里"这件事焊进了签名里。
它做反自动化检测——navigator.webdriver 必须是只读的。 日志 7727 行附近:
这个 return: false 是精髓。wasm 尝试 Reflect.set(navigator, "webdriver", true):
它有随机成分。 日志 8741 行:
getRandomValues 被调用、每次 32 字节随机数,说明签名里掺了 nonce/随机盐。同一 URL 每次签出来的 M-S 都不一样——这直接否掉了"它是确定性 HMAC"的猜测,也解释了为什么第一回合拿到的样本没法定长。
把这几条拼起来,wasm 的环境依赖清单就出来了,一共没几个:
结论出来了:依赖就这么几个,路 B(黑盒补环境)完全走得通,而且成本远低于路 A。 但当时的我没立刻想通,还在路 A 上耗了一阵——这是后面第五回合的学费。
补环境之前,还差最后一块拼图——签名函数的真实输入到底是什么。
先读胶水层 http.BzJ4_4Bj.js。它是 wasm-bindgen 生成的,虽然混淆过,但结构是标准的。反混淆后,SignTool 类长这样:
wasm 导出两个关键函数:signtool_new() 建实例、signtool_sign(ret, ptr, url_ptr, url_len, ts) 签名。JS 层就是传参 + 读内存,没有任何算法逻辑。
再读拦截器 http.Uxzu40tg.js,看签名是怎么被喂进去的:
这里埋着一个最容易翻车的坑:e.url 是 axios 的 config.url,只含路径、不含 query 参数(params 是 axios 单独拼的,发送时才拼上)。所以签名输入是:
这个细节,连同第三回合的环境依赖清单,就是补环境所需的全部信息了。万事俱备,但我先拐去走了一段弯路。
在最终选定黑盒之前,我在路 A(逆 wasm 内部)上耗了不少功夫。这条死路最后没走完就退了,但它吐出来的东西反而是全文最有意思的部分——它让我看清了签名算法的真面目。
第一步是把 wasm 反汇编成可读的 WAT。wasm 文件 64979 字节,反汇编出来的 WAT 有 901KB。这个量级就已经在暗示:里面是个不小的算法实现,逐行逆下去的性价比很低。但我还是硬着头皮扫了字符串和常量。
用 strings 扫 wasm 的 data segment,看到一个明晃晃的字符串:
ascon 四个字母让我直接下结论:"好,这是 Ascon AEAD 加密。"(Ascon 是 2023 年 NIST 轻量密码竞赛的胜出算法,AEAD 模式,常出现在这种前端签名场景里。)我还顺着这个思路去找 Ascon 的 64 位初始化常量 0x00400c0000000100,结果——一无所获。
这就是第一个误判的起点,先记在这里,后面"中途自证"里专门复盘。
Ascon 的常量找不到,我却发现了另一组更眼熟的数字。ChaCha20 流加密的初始状态,用固定常量 "expand 32-byte k" 开 4 个字,编译成 wasm 后是四个 i32.const。我在 WAT 里搜这四个整数值,全部精确命中:
这四个字串起来就是 "expand 32-byte k",ChaCha20 的标志性初始化常量,铁证如山。搜索方式也很直白,就是把字符串转成小端整数再 grep:
(对比一下:字符串形式 expa 在 wasm 二进制里 0 次,因为它被编译成了整数常量,不是字符串;而 ascon 是 1 次,因为它是 data segment 里的明文。这个反差本身就是个很好的判别技巧。)
所以真相是:内部算法是 ChaCha20 流加密,而 key 字符串里那个 ascon 是作者放的烟雾弹(也可能只是 key 命名 js-<域名>-ascon 的历史遗留,不代表算法)。key 本体是 js-.example-ascon,18 字节——注意,这不是标准 ChaCha20 的 32 字节 key,说明它是变体或有 key 派生,这点我到最后也没完全还原,下面边界里会诚实交代。
这条死路的结论:逆 wasm 内部能把算法家族定性成 ChaCha20,但要从 901KB 的 WAT 里把 key 派生、nonce 布局、counter 初始化、base64 封装整条链还原成纯 JS,成本极高,且完全没必要——因为黑盒补环境已经能稳定产出正确签名了。
想通这一点,我掉头就走。这里的判断标准很朴素:目标是"拿到能用的签名",不是"写一篇密码学论文"。定性出 ChaCha20 是好奇心,复现出签名才是交付。
这一节单独拎出来,因为这两个坑最容易被后来者再踩一遍。
教训:别拿字符串当算法证据。 ascon 出现在字符串里,只能说明有个字符串叫 ascon,不能说明算法是 Ascon。真正能定性的,是那些编译进代码段、不以字符串形态存在的魔数(比如 ChaCha20 的四个 i32 常量)。反过来也成立——一个算法真正的特征,藏在它编译后的机器码里,而不是作者想让你看到的字符串里。
这个坑更隐蔽,也更典型。
一开始我手上有 7 条真实抓包样本(sigs.txt),长度清一色是 124 字符。而我自己的实现吐出来的是 100 字符。124 − 100 = 24 字符 ≈ 18 字节,我第一反应是"我是不是漏了一段固定字段",于是反复查环境、查拼接,自我怀疑了很久。
直到我做了一次消融实验。先老老实实把样本的长度分布打印出来:
再拿自己的实现对一个确定长度的 URL 签一次:
问题出在哪?M-S 的长度本来就是 URL 长度的函数,不是常数。 明文 = 固定 53 字节开销 + URL 字节数,再 base64 一下:
对照一下就全通了:
preview 接口是 22 字节 → 100 字符;sigs.txt 那 7 条全是 40 字节的 URL → 124 字符。根本不是同一个接口的样本,我却拿它们当基准对比,当然对不上。 这 18 字节的"差异"从头到尾是我自己造的假警报。
教训:做任何"我的输出对不对"的判断之前,先确认两边的输入是否真的同构。 长度对不上,先查 URL 长度,再查固定开销,别一上来就怀疑自己的实现漏了东西。
回到正路。黑盒方案的核心就一句话:不还原 wasm 内部,只把 wasm 依赖的浏览器环境 mock 出来,让它照常跑。
sign_tool.js 的整体结构是三层:
先搭环境。wasm-bindgen 的胶水层需要一个"对象堆",把 JS 对象映射成 wasm 能看懂的 i32 句柄,标准做法是:
然后 mock 三个环境对象:
补环境能跑通,是靠四个坑一个个踩过去的。每个坑都对应 wasm 的一处环境检测,踩错一个就是 panic。
坑 1:navigator.webdriver 必须真只读。
原理回到第三回合那条 trace:wasm 用 Reflect.set(navigator, "webdriver", true) 探测,普通对象会写入成功(返回 true),被判定为假环境;只有原生只读 getter 才会返回 false。这个坑踩中的现象是直接 panic,报错里根本没有"webdriver"字样,只能靠 trace 日志反推。
坑 2:别污染 global。
为什么?设了 global.window 之后,wasm 里 typeof self、instanceof Window 这类检测会走错分支——它以为自己在浏览器里,拿到的却是一个结构和预期不一致的对象,后续调用链断裂。补环境的黄金法则是:mock 只存在于 imports 这个边界上,永远不要去污染真实的 globalThis。
坑 3:必须藏掉 Node 的 process。
wasm-bindgen 生成的 crypto 初始化有两条路径:检测到 process.versions.node 就走 require("crypto").randomFillSync(Node 路径),检测不到才走 globalThis.crypto.getRandomValues(浏览器路径)。Node 路径里 randomFillSync 往 wasm 内存视图写数据时会崩。所以这些 import 全部返回 0,逼它走浏览器路径:
坑 4:签名输入只给 path,别带 query。
这个在第四回合埋过,这里再强调一次:sign("/api/v1/stalls/preview", ts),而不是带 ?topic_count=4 的完整 URL。带 query 签出来的 M-S 是错的,服务端不认。
补环境调通后,封装就水到渠成了: