小红书 X-s 签名逆向分析实录
小红书 X-s 签名逆向分析实录
前言
前阵子接了个需求要爬小红书首页 Feed 流,打开一看请求头里躺着三个东西:X-s、X-t、X-S-Common。X-t 明显是时间戳,另外两个一眼动态签名。这种活儿干过不少,基本套路就那几个:找到签名函数、抠出来、补环境、跑通。
但小红书这次确实绕了点弯子,记录一下完整的分析过程,算是给自己留个笔记。
第一阶段:摸清接口长什么样
先打开首页,抓了一轮请求。目标接口很快定位到:
POST https://edith.xiaohongshu.com/api/sns/web/v1/homefeed请求头里需要关注的有:
| 字段 | 值示例 | 初步判断 |
|------|--------|----------|
| X-t |1730196432123| Unix 毫秒时间戳 |
| X-s |XYS_xxxxxxxxxx| 签名,每次都不一样 |
| X-S-Common | 一串 base64 字符串 | 另一个签名 |
| Cookie |a1=xxxx; webId=xxxx; ...| 登录态和会话标识 |
| Origin |https://www.xiaohongshu.com| 固定 |
| Referer |https://www.xiaohongshu.com/explore| 固定 |
请求体是个 JSON,结构也不复杂:
{"num":20,"cursor_score":"","refresh_type":1,"note_index":0,"unread_begin_note_id":"","unread_end_note_id":"","unread_note_count":0,"search_page_request_source":"discover_feed"}小意思。先试着改参数直接发请求,返回 403。那签名是非解不可了。
第二阶段:定位签名函数
把页面所有 JS 拉下来,搜X-s、XYS_这些关键字。在vendor-dynamic.880089a3.js这个文件里找到了生成逻辑。
文件经过 webpack 打包和混淆,函数名都是单字母。不过 webpack 的模块结构很清楚,每个模块就是一个函数包在function(module, exports, __webpack_require__)里。
定位到模块41439,里面定义了一个K对象,包含了一堆工具函数:
K.Pu— 这个看起来是 MD5K.kn— 疑似 CRC32K.lz— UTF-8 编码K.xE— 自定义 base64K.tb— 另一个 CRC32 变体
然后在另一个模块59839里找到了seccore_signv2函数,这就是签名的核心逻辑。
用浏览器控制台试了一下:
// 找到一个实际请求样本,断点停在 seccore_signv2 被调用的地方// 可以看到传入的参数:// e = "/api/sns/web/v1/homefeed"// a = JSON.stringify(body) // 请求体字符串第三阶段:一步步拆签名算法
第一步:还原签名公式
seccore_signv2(e, a)的流程如下:
u = e + (typeof a === 'object' ? JSON.stringify(a) : a || '')
— 把 URL 和请求体拼成一个字符串
m = MD5(u)
— 对拼接后的字符串取 MD5
w = MD5(e)
— 对 URL 单独取 MD5
C = window.mnsv2(u, m, w)
— 调一个叫mnsv2的函数,传三个参数下去,返回值是关键
拼一个对象
P = { x0: "4.3.7", x1: "xhs-pc-web", x2: "PC", x3: C, x4: typeof a }对 P 做 JSON.stringify 再 UTF-8 编码,然后自定义 base64
前面加上
XYS_前缀
所以最终的 X-s 就是:
"XYS_" + 自定义base64(UTF8(JSON.stringify({ x0: "4.3.7", x1: "xhs-pc-web", x2: "PC", x3: mnsv2返回值, x4: "string" 或 "object" })))第二步:搞定自定义 base64
标准 base64 的字母表是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/。
翻了模块 41439 里K.xE的实现,发现它用了一个完全不同的字母表:
ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5长度正好 64 个字符。这就不难了,用这个表替换标准表,写一个自定义 base64 编码函数。等号补齐的逻辑和标准 base64 一样。
第三步:确认 MD5
模块里K.Pu的实现对照了一下标准 MD5 的初始值和常数,完全一致。初始值0x67452301、0xefcdab89、0x98badcfe、0x10325476,四个轮的常数也没动过。就是个标准 MD5,没有加盐没有魔改,直接用 Node 的crypto.createHash('md5')就行。
第四步:搞定 CRC32
K.tb用了多项式0xedb88320,标准的 CRC32。用来干嘛的?在 X-S-Common 里用到。看了下 X-S-Common 的生成逻辑,就是把一些请求元信息(浏览器信息、时间戳等)拼起来,CRC32 一下,再 base64 编码。
展开看 X-S-Common 的生成代码,大概是这个结构:
// 伪代码letef={"x0":"4.3.7","x1":"xhs-pc-web","x2":"PC","x3":someBrowserInfo,"x4":timestamp,"x5":crc32(某些拼接数据)};X-S-Common=自定义b64(UTF8(JSON.stringify(ef)));第四阶段:最大的坑 — mnsv2
前面几步都很顺利,MD5、base64、CRC32 都是标准算法,一两小时就全部还原并且 Node 里能跑了。
但生成出来的 X-s 始终不对,跟浏览器里的对不上。逐字节对比发现,问题出在那个mnsv2函数的返回值上。
这个mnsv2函数不在我搜到的 JS 文件里。它在页面运行期才动态加载,藏在内存里。用window.mnsv2能访问到,但它的源码是 JSVMP(JavaScript Virtual Machine Protected)——代码被编译成了字节码,由一个解释器逐条执行。
尝试把函数 toString(),得到的是字节码解码器的代码,不是真正的算法。这就比较麻烦了。
仔细看了一下mnsv2这个函数的调用签名:
window.mnsv2(u, MD5(u), MD5(url))三个参数:
u— URL + body 拼接的字符串MD5(u)— 对拼接串取 MD5MD5(url)— 对 URL 取 MD5
返回值格式:mns0301_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
一段看起来像 hash 的字符串,前缀mns0301_似乎是版本标识。
这个函数的实现是 JSVMP 保护过的,反汇编工程量太大。但好消息是:这个函数它就是个纯函数——同样的输入永远得到同样的输出。而且它在浏览器环境中是全局可访问的。
所以我的方案是:
在浏览器控制台执行
window.mnsv2(u, m, w)拿到 token把这个 token 当作一个配置参数传给 Node 脚本
剩下的 MD5、base64、CRC32 全部在 Node 本地算
这样做的结果就是:X-s 签名中只有 mnsv2Token 这一项需要从浏览器获取,其他所有计算都可以在 Node 里独立完成。
第五阶段:拼接验证
算法全部梳理清楚之后,写了一个完整的测试脚本。流程是这样的:
functionbuildXSign(url,body,mnsv2Token){constu=url+(typeofbody==='object'?JSON.stringify(body):body||'');constm=md5(u);constw=md5(url);// mnsv2Token 从浏览器获取constC=mnsv2Token;constP={x0:"4.3.7",x1:"xhs-pc-web",x2:"PC",x3:C,x4:typeofbody};constjson=JSON.stringify(P);constutf8Bytes=encodeUtf8(json);constb64=customBase64(utf8Bytes);return"XYS_"+b64;}拿一个真实请求的原始数据来验证——同样的 URL、同样的 body、同样的 mnsv2Token——本地算出来的 X-s 跟浏览器里抓到的一模一样,逐字节对比完全一致。然后拿这个签名去发请求,返回 200,拿到了 11 条 feed 内容。
总结
小红书的签名体系拆开来看其实不复杂:
| 算法 | 说明 | 还原难度 |
|------|------|----------|
| MD5 | 标准 MD5,没有魔改 | ★☆☆☆☆ |
| 自定义 base64 | 换了字母表,逻辑同标准 base64 | ★★☆☆☆ |
| CRC32 | 标准 CRC32 | ★☆☆☆☆ |
| mnsv2 | JSVMP 保护的纯函数,输入输出确定 | ★★★★★ |
前三个都可以在 Node 里独立跑。最后一个 mnsv2 是 JSVMP 保护的,没法完整还原,但它是一个纯函数且浏览器全局可调,所以直接从浏览器拿 token 来用,效率最高。
最终的产物就是homefeed.js,一个 Node.js 脚本。依赖就只有 Node 内置的crypto模块,外加一个从浏览器获取的 mnsv2Token。
几个教训
先验证再逆向— 抓到签名后第一件事不是读代码,而是确认签名长得什么样、长什么样、跟什么有关。我这次走了弯路,一开始以为是 AES 加密,看着长度像就去翻 S-box,浪费了大半天。
注意自定义 base64— 小红书这个站最坑的地方不是它用了什么复杂加密,而是它改了 base64 字母表。一眼看过去以为是标准 base64,结果解码出来是乱码。要是早点注意到 64 个字符的顺序不对,能省很多时间。
JSVMP 的函数别硬啃— 确认它是纯函数之后,直接拿来用就行了,不用非要把它反汇编出来。能跑通接口才是最终目标,不是非要还原每一行代码。
拼接规则最重要— 签名算法本身(MD5/base64)都是现成的,难的是搞清楚它把哪些东西拼在一起、什么顺序。这个只能靠断点调试和日志来确认,没有捷径。