
小红书 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 |a1xxxx; webIdxxxx; ...| 登录态和会话标识 || 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)— 对拼接后的字符串取 MD5w MD5(e)— 对 URL 单独取 MD5C 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的实现发现它用了一个完全不同的字母表ZmserbBoHQtNPwOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5长度正好 64 个字符。这就不难了用这个表替换标准表写一个自定义 base64 编码函数。等号补齐的逻辑和标准 base64 一样。第三步确认 MD5模块里K.Pu的实现对照了一下标准 MD5 的初始值和常数完全一致。初始值0x67452301、0xefcdab89、0x98badcfe、0x10325476四个轮的常数也没动过。就是个标准 MD5没有加盐没有魔改直接用 Node 的crypto.createHash(md5)就行。第四步搞定 CRC32K.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能访问到但它的源码是 JSVMPJavaScript 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){constuurl(typeofbodyobject?JSON.stringify(body):body||);constmmd5(u);constwmd5(url);// mnsv2Token 从浏览器获取constCmnsv2Token;constP{x0:4.3.7,x1:xhs-pc-web,x2:PC,x3:C,x4:typeofbody};constjsonJSON.stringify(P);constutf8BytesencodeUtf8(json);constb64customBase64(utf8Bytes);returnXYS_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都是现成的难的是搞清楚它把哪些东西拼在一起、什么顺序。这个只能靠断点调试和日志来确认没有捷径。