简介面向前端开发者与网络安全初学者的可运行源码包专门演示小红书 X-s/X-T 参数的 JavaScript 逆向分析流程帮助读者建立从抓包定位、Hook JSON.stringify、调用栈跟踪到补环境与 RPC 调用的完整思路。压缩包共 3 个文件内含 inscode 工程配置、HTML 调试页面和 gitignore 忽略规则整体仅 6KB轻量简洁可直接运行或二次修改。已有 120 人学习下载适合作为加密参数逆向的入门参照。源码包结合插桩日志还原签名生成链路URL 与请求参数先做 MD5 计算拼接 x1-x4 后经 Base64 编码再由加密函数生成 X-s/X-T 所需的 payload同时记录环境检测值、cookie 中的 a1 值、时间戳等关键因子便于对照验证每一步。作者同时声明仅供学习交流适合希望掌握前端加密机制、爬虫参数分析或网络请求签名逆向的中高级开发者是一份能直接运行验证的参考脚手架。1. 别拿到源码就冲小红书 X-s/X-T 的逆向落脚点在哪不少做 JS 逆向分析小红书 X-s/X-T 的朋友第一句就是“有没有可运行源码”。其实源码只是第三关前面还有两关先把请求头和加密入口对起来再把签名器从浏览器搬到你自己的 Node 环境。这两关过不去给你再完整的压缩包也跑不出真实 X-s。简单说X-s 是小红书 Web 端接口的签名参数X-t 是伴随签名的时间戳二者一起被服务端校验存储。本文会用一套可运行源码的骨架讲清从定位函数到部署签名服务的完整路径适合想解决 406/sign 校验、把小红书采集脚本稳定下来的读者。2. 拆解 X-s/X-T先认清这两个参数再动手网上流传的“可运行源码”通常只给一个算法文件和一行调用示例却很少解释签名的生成上下文。实际排错时最关键的往往不是算法本身而是签名与请求头、Cookie、时间戳的绑定关系。所以第一步不是读代码而是先抓包确认你手上的源码要解决的到底是哪一个参数、哪一类校验。2.1 X-s 和 X-t 的结构与时间关系抓包后先看这两个头的表面形式。一般会得到类似这样的信息参数名表面形式作用X-t10 位或 13 位数字秒或毫秒级请求时间戳参与 X-s 计算服务端会拿它做防重放校验X-s一段 base64 风格字符串长度通常在 40~80 位对 path、query、body、时间戳等信息做签名后编码的结果很多人看到 X-s 长得像 base64就顺手解码发现里面有路径和时间戳以为可以直接拼出来。这个思路只对了一半算法往往不是直接对明文字符串做 base64而是在内部先拼接原始参数、加入秘钥或哈希后再对结果编码。你把解码后的内容原样拼回去签名出来十有八九是错的。X-s 与 X-t 还有一个时间窗口关系。同一个 X-s 在生成后的几十秒内有效超过时间窗口会被判过期。所以本地签名服务里X-t 必须在使用前一刻生成不能缓存同一个时间戳反复用。这一点到后面部署服务时最容易踩坑。2.2 在浏览器里快速定位签名生成入口拿到 X-s 和 X-t 后下一步是找到它们是在哪一段 JS 里被设置到请求头上的。常见的做法是打开浏览器开发者工具先用无痕窗口访问小红书 Web 端在 Network 面板找一个信息流接口复制请求头。如果是直接采集阶段接口可能直接返回 406 或签名校验失败这时候不需要去读响应体而是要看请求头里有没有 X-s/X-t以及它们对应的触发请求。有个很实用的习惯在开发者工具的“Source”面板里做全局搜索搜x-s和x-t这两个字符串。注意搜索范围不要选网络面板而是选源码文件。找到后点进 JS 文件在设置请求头的那一行打断点刷新页面等断点命中。断点处向上看调用栈就能看到签名函数的名字和它所在的文件。如果字符串是被拆开或者加密的搜索不出来那就用注入 Hook 的方式让页面在设置请求头时主动把调用栈吐出来。下面这段脚本可以贴在控制台里用(function () { // 保存原始 setRequestHeader const rawSet XMLHttpRequest.prototype.setRequestHeader; // 重写 setRequestHeader拦截 X-s/X-t XMLHttpRequest.prototype.setRequestHeader function (key, value) { if (/x-?s/i.test(key) || /x-?t/i.test(key)) { console.log([header hook], key, value); // 打印当前调用栈用于定位生成位置 console.log(new Error(header set at).stack); } return rawSet.apply(this, arguments); }; })();这段逻辑是拦截 XHR 请求的 setRequestHeader只要写入的 key 匹配x-s或x-t就打印 key、value 和调用栈。然后回到页面下拉刷新触发新的请求控制台会直接告诉你签名是在哪个函数里被设置的。要注意请求头可能通过 fetch 写入而不走 XHR所以只用这一招还不够需要配合下一小节一起做。2.3 拦截 fetch 请求定位入口现在页面里的接口越来越多走 fetch尤其 Web 端新版本基本都是 fetch 拉取数据。对 fetch 的拦截要单独写一段逻辑因为它的头和参数不直接暴露给 XHR hook。我一般会直接在页面初始化前注入这段(function () { const originFetch window.fetch; window.fetch function (input, init {}) { // 从 init.headers 或 input.headers 里找 X-s let headers new Headers(init.headers || (input input.headers) || {}); if (headers.has(x-s) || headers.has(X-s)) { console.log([fetch hook] url:, typeof input string ? input : input.url); console.log([fetch hook] x-s:, headers.get(x-s)); console.log([fetch hook] x-t:, headers.get(x-t)); console.log(new Error(fetch x-s at).stack); } return originFetch.call(this, input, init); }; })();这段脚本和 XHR hook 互补。fetch 的 headers 如果是用Headers对象传入的重写之后会看到完整的签名值。注意打印完用originFetch.call(this, input, init)原样调用不要被打印逻辑影响。如果两个 hook 都没等到结果说明签名可能是在封装的 ajax 库内部生成的或者请求对象的 key 做了混淆。这时候退一步回源码里搜setRequestHeader、headers.append这类 API再配合调用栈一层层找。用 hook 的价值在于省去人工翻压缩包的时间尤其面对几万行的压缩 JS 时它能直接缩小范围。3. 让签名器脱离浏览器本地可运行 JS 的最小工程当你在浏览器里已经定位到签名函数后下一步是把它搬到本地让它不依赖页面也能跑。这一步常见的源码包会分成两类一类是“补环境”方案另一类是“算法提取”方案。两者的差别在于页面里的签名函数是否只依赖标准 JS 逻辑。3.1 先判断补环境还是算法提取打开定位到的源码文件看签名函数附近的逻辑。如果它只是调用Date.now()、Math.random()、几个字符串拼接函数通常没有 DOM 依赖可以把函数体连同依赖的公共函数一起抠出来这是算法提取。但小红书 Web 端的签名器经常藏在自执行函数里外层可能判断window、document、navigator甚至在初始化时读取location.href和localStorage。把这些逻辑硬抠出来不现实常见做法就是补环境。补环境的意思是用 Node.js 模拟一个浏览器环境让压缩后的 JS 文件在 vm 沙箱里认为自己还在浏览器中运行然后继续执行最后通过暴露出来的全局对象拿到签名函数。这个方案优点是不需要完全看懂算法缺点是补环境时缺失的浏览器 API 会非常多。我一般优先走补环境路线因为它能保留原始签名器的不确定性和加密结构不会因为手动提取算法而漏掉某个参与计算的全局变量。等补环境稳定后再对照调用栈把多余依赖删掉最后得到一份干净的独立函数。3.2 用 Node.js 的 vm 模块把源码扔进独立沙箱为了跑补环境方案需要一个最小工程。假设你已经通过浏览器开发者工具把包含签名器的压缩 JS 保存到了本地文件名叫web_main.js那 Node 端的起始脚本可以写成这样const fs require(fs); const vm require(vm); // 读取页面里扣出来的 JS const code fs.readFileSync(./web_main.js, utf8); // 构造浏览器环境的最小模拟 const sandbox { console, Date, Math, JSON, setTimeout, clearTimeout, // window 指向 sandbox 自身很多脚本会检查 windowundefined window: {}, // 常见属性 location: { href: https://www.example.com/, search: }, navigator: { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), language: zh-CN }, document: { cookie: , referrer: , createElement() { return {}; }, getElementById() { return null; }, querySelector() { return null; }, addEventListener() {}, }, }; sandbox.window sandbox; // 创建上下文并运行 vm.createContext(sandbox); try { vm.runInContext(code, sandbox, { timeout: 5000 }); console.log(源码注入完成); } catch (err) { console.error(运行失败缺少环境, err.message); }这里对参数的解释很重要。sandbox里的每一项都是按“源码会读取什么”来补的不是随便写的。window: {}之后又把sandbox.window sandbox指回自身是为了处理脚本中常见的window this判断。document里的cookie、referrer会被某些初始化函数读取。navigator.userAgent如果缺了环境检测会直接抛错。跑完这段后脚本大概率还会报缺少别的对象。比如canvas、screen、performance这些都是补环境最耗时间的地方。解决方法是看报错信息里缺哪个 API就在sandbox里补上对应占位函数。3.3 拿到导出函数并完成一次签名源码在沙箱里跑通后剩下的问题是签名函数在哪里怎么把它调用起来。方法有两个。第一在浏览器里下断点进入签名函数所在作用域后在控制台执行window.__sign fn把函数引用挂到全局。第二在 Node 沙箱里主动搜索全局对象常见的导出可能叫sign、get_x_s、b64_sign等。为了稳定复用我一般在补完环境后直接写一段导出代码// 把沙箱里的签名函数取出来 const signFn sandbox.window.__sign || sandbox.window.sign || sandbox.sign; if (typeof signFn ! function) { throw new Error(未找到签名函数请先在断点中确认函数名); } // 模拟一次签名调用 const x_t Math.floor(Date.now() / 1000); const x_s signFn({ path: /api/sns/web/v1/homefeed, query: cursorabc123, body: , x_t: String(x_t), }); console.log(x-s:, x_s); console.log(x-t:, x_t);这里signFn的参数是根据断点上下文补的不一定就是这个对象。很多签名函数实际接收的是path、query、x_t三个散参。把调用参数封装成对象只是便于在本地调试时对照。第一次跑出来的结果拿它和浏览器控制台里打印出的真实 X-s 对比字段长度和开头字符如果差异太大比如长度差一半或者编码风格不对说明函数入口找错了。如果源码里存在多个签名函数比如一个用于首页、一个用于搜索那就需要分别保存函数引用而不是只导出一个。我在本地工程里会维护一个export.js文件专门记录函数名和对应的调用参数避免下次更新源码时重新找入口。4. 把签名器接进采集流程接口封装与并发参数签名器能在本地跑通只代表单机验证过了。要把它用在小红书采集脚本里还需要一个稳定的调用方式。常见做法是把它封装成无状态的 HTTP 服务让采集进程通过网络接口拿签名。4.1 用最简单的方式把签名器变成 HTTP 微服务Node 自带http模块可以起一个轻量服务不引入额外依赖。把上一章的导出函数包装在服务里请求方只需要传path、query、body三个字段服务端返回一串x-s/x-t。const http require(http); const sandbox require(./load_web_main.js); // 封装好的沙箱加载逻辑 http.createServer((req, res) { let body ; req.on(data, (chunk) (body chunk)); req.on(end, () { try { const { path, query, bodyText } JSON.parse(body); const x_t Math.floor(Date.now() / 1000); const signFn sandbox.window.__sign; const x_s signFn({ path, query, body: bodyText, x_t: String(x_t) }); res.writeHead(200, { content-type: application/json }); res.end(JSON.stringify({ x-s: x_s, x-t: x_t })); } catch (err) { res.writeHead(500, { content-type: application/json }); res.end(JSON.stringify({ error: err.message })); } }); }).listen(3000, () console.log(sign service at 3000));逻辑说明服务收到请求后先解析 JSON生成当前秒级时间戳x_t再调用沙箱里的签名函数最后把结果返回。注意函数里不要缓存x_t每个请求都重新取时间。load_web_main.js是前面 vm 沙箱封装出的模块这里复用它避免重复加载。参数说明path是请求路径必须和实际采集请求一致query是带 query string 的请求参数bodyText是 post bodyGET 请求传空字符串。签名时如果漏了某个字段线上请求会拿不到合法签名。4.2 带 Cookie 的采集请求图片提取和搜索下单词都躲不开签名服务和采集进程分开后采集进程自己负责管理 Cookie。签名只代表“这个路径是你签过的”不代表“你这个人可信”。小红书 Web 端的安全校验会同时看 Cookie、请求来源、UA 和签名四者是否一致。下面这段伪代码展示了一个典型的采集请求流程先获取签名再带 Cookie 请求接口最后解析图片地址const fetch require(node-fetch); async function getNotes(cookie, query) { // 第一步请求本地签名服务 const signRes await fetch(http://127.0.0.1:3000/sign, { method: POST, body: JSON.stringify({ path: /api/sns/web/v1/homefeed, query: new URLSearchParams(query).toString(), bodyText: , }), }).then((r) r.json()); // 第二步带签名和 Cookie 请求目标接口 const res await fetch(https://www.example.com/api/sns/web/v1/homefeed? new URLSearchParams(query), { headers: { cookie: cookie, x-s: signRes[x-s], x-t: signRes[x-t], referer: https://www.example.com/, }, }); // 第三步从返回的数据里取图片列表 const json await res.json(); const items json.data json.data.items || []; return items.map((item) ({ title: item.title, images: (item.imageList || []).map((img) { // imageList 里的 URL 有时是相对路径需要拼接域名 return img.url img.url.startsWith(http) ? img.url : https://www.example.com img.url; }), })); }这段代码演示了三件事。第一签名服务只负责生成签名不负责实际请求。第二请求时必须把原始 Cookie 原样传给目标接口签名里如果绑定了 Cookie 信息缺了就会返回 406。第三图片地址解析是采集的收尾环节很多接口返回的图片 URL 是带签名参数的临时链接直接取url拼接即可。实际做小红书爬虫时Cookie 的获取方式决定采集稳定性。登录后页面的 Cookie 会包含会话标识直接复制进代码虽然能跑但过期很快。更合理的做法是维护一个 Cookie 池由采集进程定期刷新签名服务保持无状态。4.3 并发和签名更新习惯签名服务本身是无状态的可以在多个采集进程间共享。并发高时要给http服务加一层限速或临时失败重试逻辑避免某次签名请求因为沙箱执行太慢导致超时。另一个容易被忽略的点是算法更新。小红书 Web 端的 JS 文件版本更新后旧的签名器可能在几个小时后失效。判断算法是否失效的方法很简单用同一个参数在浏览器里重新请求一次接口然后把浏览器里抓到的 X-s 和本地生成的 X-s 做比较。如果字段结构、长度模式全都对不上说明算法已经换了。这个检查习惯要养成而不是等到线上报错才去排查。5. 避坑指南签名器本地能跑一到采集还是翻车的 5 个常见问题5.1 现象本地签名能用一上服务就被判失效本地调试时生成的签名直接请求接口是成功的但部署到服务器后跑起来就报签名过期。原因通常有两个。第一本地和服务器的系统时间不一致生成 X-t 时刻与服务器请求时刻相差太大。第二签名服务生成 X-s 后采集进程又等了很久才发起请求超过时间窗口。解决方法是让 signature 生成和 HTTP 请求尽量靠近。最稳妥的做法是把签名服务部署在采集服务器的同一台机器上并在调用链路上记录生成时间与请求耗时。出现大量过期错误时先检查时间同步不要急着认为是算法错误。5.2 现象Node 里运行源码报window is not defined这是补环境最常见的报错。脚本在浏览器环境里天生有window而 Node 没有。解决方式是维护一个补环境字典。window、document、navigator、location、history这些是必补项。实际报错时系统会告诉你少了哪个对象的哪个方法按提示一个个补。不要想着一次补完所有 API我一般只补到签名函数能正常运行即可越少越容易维护。有时候还需要把window和self指向同一个对象否则一些自执行函数会拿不到全局对象。代码里可以显式加一行sandbox.self sandbox。5.3 现象源码文件找到了但函数名全被混淆成 a/b/c压缩后的签名代码变量名可能是一两个字母函数入口根本没法靠名字搜索。解决方法是不要搜索函数名而是搜索参数名和特征字符串。打开 VSCode 或开发者工具的源码搜索用正则找x-s、x-t的设置位置然后再往上追。一个很好用的正则/[]x[-_]?(s|t)[]/gi这个表达式能同时匹配x-s、x_t、xs、xt等常见写法。也能覆盖混淆后字符串被拆成两段的情况因为 JSON 属性名柯里化不常见。还有一种情况是参数名被转成 Unicode 或者十六进制这时搜索不到也正常回到浏览器断点调试是最快的。5.4 现象Windows 本地能跑Linux 服务器上跑不了签名器在本地 Windows 的 Node 里能正常生成签名部署到 Linux Docker 容器后函数返回undefined或直接超时。原因多半是代码里用了浏览器专属对象比如screen、window.screen.width或者依赖了小端字节序。这些在 Windows 本地开发环境不一定能触发在 Linux 上反而会走不同分支。解决方式是尽量让本地环境和服务器保持一致。用 Docker 镜像把 Node 版本、系统时区、Chrome 头文件都固定下来。如果签名器必须依赖浏览器环境那就直接在无头浏览器里执行签名而不是硬补环境。5.5 现象签名和时间戳都对状态码还是 406X-s/X-t 看起来没问题请求也带了但接口还是返回 406。这个问题的关键不在签名本身而在 Cookie。小红书 Web 端的签名经常和a1、web_session这类 Cookie 绑定单独把签名拿出来复制到没有 Cookie 的环境里服务端很容易识别出请求是伪造的。解决方法是把“拿到签名”和“使用签名”放在同一个请求会话里。先访问首页获取初始化 Cookie再带着这些 Cookie 去申请签名和请求接口。如果采集的接口较多可以每次请求前更新一次 Cookie而不是整个爬虫过程一直复用同一个。6. 拿到源码后的第一件事先固定时间戳做三组对比再上量不管是自己逆向出来的签名器还是从别人手里拿到的可运行源码第一件事不是直接接进采集脚本而是先做一个固定时间戳回归。具体做法是把时间戳固定成一个常量比如x_t 1700000000反复调用签名函数十次。如果输出每次都一样说明签名算法是确定性的如果输出有随机变化说明存在random、crypto或当前时间参与需要再检查依赖项。第二组对比是用浏览器里的真实请求做参照把同一路径、同一 Cookie、同一时间戳下生成的 X-s 和线上请求头的 X-s 比较看长度、字符集、尾部填充是否一致。第三组是重放实验用刚生成的签名立即请求观察返回码是 200 还是 406。这三组验证可以写成一个小的回归脚本验证项方法通过标准确定性回归固定时间戳连续调用 10 次结果一致或至少前缀一致线上对照浏览器抓包另存一份 X-s 进行比对长度接近字符集风格一致重放实验生成后 1 秒内发起请求返回 200而不是 406把这三项纳入每次更新后的例行检查能过滤掉大半“伪源码”。真正可运行的源码不怕固定时间戳测试怕的是那种只靠浏览器环境临时混过去、一脱离页面就失效的“半成品”。我现在每次拿到新签名器都会先做固定时间戳回归再放量。否则等到采集跑停机再回去查浪费的时间往往比重新写一个签名器还多。希望帮到你。本文还有配套的精品资源点击获取