
简介资源包围绕小红书x-s参数的逆向分析展开适合具备一定编程与网络基础的安全研究人员、爬虫开发者及逆向工程爱好者。内容聚焦于补环境源码的还原、客户端通信协议与签名机制的解析帮助理解x-s参数在身份验证和安全通信中的实际作用。压缩包共1511个文件体积约3.34MB以JavaScript与TypeScript源码为主体辅以source map映射、JSON配置、Markdown说明及多种工程配置文件覆盖数据包加密、签名生成、调用链追踪等关键分析环节。已有404人学习下载。通过学习可掌握从抓包、协议解析到代码反编译与动态调试的完整逆向思路同时获得补环境方案、参数生成逻辑、常见加密算法识别等具体参考资料中的模块化目录结构也便于按主题深入排查既能用于理解小红书应用的安全机制也可为开发兼容工具或提升自身漏洞分析能力提供技术支撑。1. x-s参数到底是什么先认清它是风控签名不是普通加密写小红书采集脚本的人大概率都撞过这堵墙明明带了正常Cookie请求头也模拟得像模像样结果接口直接甩回一个406 Not Acceptable或者返回signature error。问题往往就出在一个叫x-s的请求头上。这个参数不是普通加密不是简单的Token而是小红书风控体系里的请求签名服务端通过它判断「这个请求是不是来自真实客户端、有没有被篡改、是不是同一个设备发出来的」。想在小红书做图片提取、批量采集笔记、下载视频第一步永远是把x-s的生成逻辑搞清楚。这篇文章要解决的问题很具体怎么定位它的生成位置、怎么补环境让签名函数跑起来、哪些参数决定成败、以及那些让人翻车的边界条件在哪。适合正在和时间赛跑的爬虫工程师、做数据采集工具开发的人也适合想理解前端签名机制的前端同学。2. 定位x-s的生成位置从抓包到调用栈的完整流程2.1 先抓包确认哪些接口必须带x-s不要一上来就逆向先把请求面摸清楚。用 Charles 或 Mitmproxy 配好 HTTPS 证书过滤api.xiaohongshu.com和edith.xiaohongshu.com这两个域名。正常刷一遍信息流、点开一篇笔记、搜索一个关键词然后把抓到的请求按「请求头」排序观察哪些接口带了x-s、带的值长什么样。实际操作中你会发现笔记详情页接口、搜索接口、关注列表接口基本都强制要求x-s而某些静态资源接口不需要。这个差异很重要它决定了你要逆向的目标范围——没必要把整套风控逻辑都啃下来先盯住你要用的那几个接口就够了。抓包时顺手记下x-s的格式通常是 64 位十六进制字符串有时带一个_前缀。如果是纯 64 位 hex大概率是 MD5/HMAC 家族如果带时间戳前缀那里面就藏了防重放逻辑。把下面这段抓包结果存成 JSON后面校验签名正确性时会反复用到curl -s https://edith.xiaohongshu.com/api/sns/web/v1/search/notes \ -H x-s: 0000000000000000000000000000000000000000000000000000000000000000 \ -H x-t: 1699999999 \ -H cookie: web_sessionxxxxxx \ -o search_response.json这段命令的意义是故意传一个假签名观察服务端返回什么。如果返回signature error说明签名校验是独立的如果返回invalid session说明账号维度先被校验了。我一般会用这种方式先做「失败实验」反向确认校验顺序能省掉不少逆向时间。参数说明x-t是时间戳和x-s通常是成对出现的web_session是账号核心凭证签名里大概率会绑定它。2.2 从请求头特征判断签名是JS生成还是Native生成这是决定后续路线的一步。Web 端的签名逻辑跑在 JavaScript 里Android/iOS 端的签名逻辑跑在 so 文件里两条路线的逆向方式完全不同。判断方法很粗暴在 Chrome 里打开小红书网页版登录后刷新页面搜索x-s字符串看它出现在哪些 JS 文件里。如果你能在 Sources 面板里搜到x-s的赋值语句那恭喜Web 端签名是纯 JS 实现可以通过补环境的方式在 Node 里复现。如果搜不到或者只看到window._webmsxyw这样一个调用入口那也是 JS——只是它把核心逻辑藏在混淆过的闭包里需要抠出来跑。真正麻烦的是 App 端抓包看到x-s但网页版根本没这个逻辑那就得用 Frida hook so 层或者从 APK 里提取 lib 库分析。Web 端签名有个典型特征如果你改了请求体里的任意一个字段x-s就会变。这说明签名函数接收了请求内容作为输入不是简单的时间戳加密。验证方法在 Console 里手动改一下请求参数重新触发请求对比两次x-s值。不同说明签名绑定请求体相同说明只绑定 URL 路径和请求头。这一步能帮你缩小黑匣子的范围。2.3 用调用栈和XHR断点定位签名函数入口这是整个逆向过程里最需要耐心的一步。打开 Chrome DevTools切到 Sources 面板在右侧的 XHR/fetch 断点里勾上「Any XHR or fetch」。然后刷新页面任意触发一个搜索请求断点会停在 XMLHttpRequest.send 的位置。此时不要急着继续看右侧的 Call Stack 面板一层一层往下点找那个在 send 之前被调用的函数——它一般是个匿名函数或者名字被混淆成p、t、b这种单字母。点进去在函数体里搜索x-s的赋值语句通常会看到类似这样的代码var e (0, s.default)(t, n, i); r.headers[x-s] e; r.headers[x-t] (0, s.getTime)().toString();这段代码的逻辑是调用s.default函数生成签名把返回值写入请求头x-s同时把当前时间戳写入x-t。注意s.default接收三个参数中间那个n大概率是请求体或请求参数第三个i可能是 URL 路径或设备信息。把这三个参数的实际值打印出来你就知道签名函数的输入是什么了。我一般在 Console 里执行s.default.toString()打印整个函数源码如果太长就先格式化再搜索关键词。这个函数通常由一个主函数加三四个辅助函数组成主函数里能看到 MD5、SHA1 或 HMAC 的调用。确认了入口函数后把整个依赖链剥出来下一步就是补环境。3. 补环境跑通签名在Node里复现Web端x-s生成3.1 把签名函数从浏览器迁移到Node的最小步骤拿到签名函数源码后不要直接扔进 Node 跑大概率会报错——因为签名函数依赖window、navigator、document这些浏览器环境对象。常见做法是构造一个最小 mock 环境把函数挂进去然后用 Node 的vm模块执行。先把签名函数源码保存为sign.js注意不要混入任何浏览器专有的 DOM 操作有就删掉或者替换成 mock。然后写一个加载器const fs require(fs); const vm require(vm); const sandbox { window: {}, navigator: { userAgent: Mozilla/5.0 ..., platform: Win32 }, document: { cookie: web_sessionxxxxxx }, location: { href: https://www.xiaohongshu.com }, console: console, Buffer: Buffer, setTimeout: setTimeout, clearTimeout: clearTimeout, }; sandbox.window sandbox; vm.createContext(sandbox); const code fs.readFileSync(./sign.js, utf-8); vm.runInContext(code, sandbox, { filename: sign.js }); // 假设签名函数被挂到 window.__sign const sign sandbox.window.__sign; const result sign(GET, /api/sns/web/v1/search/notes, { keyword: 美食 }); console.log(x-s:, result);逻辑说明vm.createContext创建了一个独立的 V8 上下文sandbox里提前模拟了浏览器全局对象。签名函数在sign.js里被定义并挂到window上之后在沙箱上下文里调用。这里的关键是sandbox.window sandbox这句——浏览器里window指向自身如果不模拟这个循环引用很多签名函数会在初始化阶段直接报错。参数说明userAgent必须和你抓包时的一致签名函数可能把 UA 拼进签名内容document.cookie里的web_session也必须真实有效签名会绑定它。如果签名函数用了navigator.userAgentData这类新 API还得补上对应的 mock。3.2 环境缺什么就补什么一份常用的环境mock清单跑第一遍时大概率会遇到报错。最冤枉的报错是navigator is not defined或window is not defined——说明 mock 不够。我的经验是直接按下面这份清单补能省掉 90% 的来回试错const sandbox { window: {}, navigator: { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, language: zh-CN, languages: [zh-CN, zh], platform: Win32, hardwareConcurrency: 16, deviceMemory: 8, maxTouchPoints: 0, }, document: { cookie: , referrer: https://www.xiaohongshu.com/, title: 小红书, readyState: complete, }, location: { href: https://www.xiaohongshu.com/, host: www.xiaohongshu.com, protocol: https:, }, };这份清单里hardwareConcurrency和deviceMemory往往是最容易被忽略的——签名函数会用它们生成环境指纹。如果签名结果里包含类似navigator.hardwareConcurrency的哈希而你的 mock 值和浏览器不一致生成的签名会被风控判定为异常设备。补环境有个原则不是越多越好而是「用到什么补什么」。签名函数如果是纯计算逻辑mock 十来个属性就够了如果它还做了 DOM 嗅探那就要逐个断点排查。我习惯在vm.runInContext前加一个 Proxy 拦截所有未定义的属性访问打印出来就知道缺什么const sandbox new Proxy({ ... }, { get(target, prop) { if (!(prop in target)) { console.warn(Missing sandbox property:, prop.toString()); } return target[prop]; }, });这个 Proxy 不是必须的但调试时特别好用。它能直接告诉你签名函数访问了哪些你没 mock 的属性省得你靠猜。3.3 跑通后的自检方法签名固定性、时间戳联动、请求体验证签名函数跑通后不要直接上生产先做三轮自检。第一轮验证签名是否随时间戳变化。调用两次签名函数手动传入不同的x-t值比如相差 5 秒对比x-s输出。如果两次结果完全一样说明签名没有绑定时间戳——这在目前的风控体系里几乎不可能所以如果你遇到这种情况大概率是时序逻辑被剥掉了需要回头检查源码里是否用了Date.now()或new Date().getTime()。第二轮验证签名是否绑定请求体。修改请求参数里的关键词或页码重新生成签名。如果签名值没变说明它只绑定了 URL 路径这种签名的安全性很弱但也意味着实现更简单。如果签名值变了说明请求体参与了签名计算——这是目前的主流设计。第三轮用 curl 实际发一次请求把生成的x-s和x-t放进请求头看服务端是否返回正常数据。这一步通过后签名函数才算真正可用。注意 Cookie 也要同步带上签名和设备凭证是联动的。4. 影响x-s结果的三个必调参数时间戳、动态盐、deviceID4.1 时间戳参数不能裸用要防时钟偏差x-t是和x-s配套的时间戳格式是 Unix 秒级字符串。服务端会校验x-t与接收时间的差距一般允许的偏差窗口在 60 秒到 120 秒之间。超过这个窗口哪怕签名内容完全正确也会被拒绝——这是防重放攻击的基础设计。实际落地时有个容易忽略的细节如果用 Node 的Math.floor(Date.now() / 1000)直接用取到的服务器时间和本机时间是同步的问题不大。但如果你把请求走了代理或负载均衡目标服务器的时间和你本机时间可能差几十秒这时候需要在代码里做一次校准。常见做法是启动时请求一次小红的服务端时间接口计算差值然后每次生成签名时用校准后的时间let serverTimeOffset 0; function getServerTime() { const local Math.floor(Date.now() / 1000); return local serverTimeOffset; } // 启动时校准一次结果存到 serverTimeOffset // 校准接口返回 { serverTime: 1699999999 }这段代码的用意是把时间偏移量做成全局状态签名计算和时间戳生成都用getServerTime()这个函数而不是直接调用Date.now()。我一般会在每次请求后检查响应头的Date字段和本机时间对比如果偏差超过 10 秒就重新校准。这个习惯能帮你避开好几种只在凌晨出现的玄学签名失败。4.2 动态盐版本更新后签名失效的根源签名函数里通常有一个混淆过的固定字符串叫做盐salt。它会被拼进待签名的内容里参与 MD5 或 HMAC 计算。这个盐不是写在源码显眼位置的而是藏在各种字符串拼接和数组操作里。比如这段典型的混淆代码var t a b c; var r [x, y, z].join(); var salt t r 0x1f;这就是为什么不能只复制签名函数而不管运行环境。如果签名的结果不对先检查是不是盐值提取错了。我的排查方法是在签名函数执行前把所有字符串拼接结果打印出来观察哪些片段看起来像随机字符串——盐的典型特征是长度固定、包含大小写字母和数字、没有语义。版本更新时最容易翻车的点就是盐变了。小红书前端发布新版本后签名函数可能被重新混淆盐值可能被换掉。你手里的签名函数可能昨天还好用今天突然全部signature error。所以建议把盐值提取成独立配置做成一个config.js方便版本更新时快速替换。不要把它硬编码在签名函数源码里否则每次更新都要重新压缩混淆、对比差异。4.3 deviceID与cookie的绑定关系签名不是独立存在的很多人逆向出签名函数后直接把x-s当成万能钥匙这是最大的误解。x-s的生成过程中大概率包含设备指纹或账号身份信息。最常见的绑定对象是Cookie里的web_session和a1字段以及自定义请求头里的x-b3-traceid、x-mini-client等。实操时你会遇到这个现象用 Node 生成签名后如果请求头里的 Cookie 和你生成签名时传入的不一致服务端会返回invalid signature或者permission denied。这是因为签名内容里绑定了web_session的值服务端校验时会从请求包里取 Cookie 再算一次签名两边对不上就拒绝。所以我在代码里永远把签名函数和请求会话封装成一个整体而不是分开调用。类似这种结构class XHSClient { constructor(cookie, deviceInfo) { this.cookie cookie; this.deviceInfo deviceInfo; this.sign this._initSignFunction(deviceInfo); } _initSignFunction(deviceInfo) { const sandbox createSandbox(deviceInfo); vm.runInContext(signJsCode, sandbox); return (path, data) sandbox.window.__sign(path, data, deviceInfo.deviceId); } async request(path, data) { const xS this.sign(path, data); return fetch(path, { headers: { x-s: xS, x-t: String(Math.floor(Date.now() / 1000)), cookie: this.cookie, }, body: JSON.stringify(data), }); } }这里最核心的点是deviceInfo.deviceId在签名函数生成的时刻就被注入了后续所有请求的 Cookie 都必须和这个deviceId绑定不能混用。换账号时重新 new 一个XHSClient实例而不是复用同一个签名生成器。签名和会话是强耦合的这就是为什么网上拿到的现成签名函数往往跑几天就失效——不是函数错了是绑定的设备信息和服务端侧的风控条件变了。4.4 请求头的顺序被忽略的最后一个变量严格来说这不是签名函数的参数但我在实际测试中确实遇到过同一套签名函数同样的设备指纹和时间戳只有在请求头按特定顺序排列时才能通过校验。这通常是因为服务端在计算签名时会取请求头的原始序列而不是逻辑意义上的字段集合。这个情况并不适合所有接口但当你发现签名正确、时间戳正确、Cookie 也正确但服务端仍然拒绝时值得试一下调整请求头顺序。尤其是x-s和x-t这两个字段把它们放在 Cookie 之前或之后结果可能完全不同。用curl做对比测试时-H参数的书写顺序就等于实际发送顺序可以直接验证。5. 逆向落地中的五个常见坑环境检测、版本更新与指纹泄漏5.1 现象签名函数在浏览器里正常迁移到 Node 后生成的签名只有一半能通过原因浏览器环境里的navigator.hardwareConcurrency和platform等属性和 Node mock 不一致签名函数在生成时会把这些值拼入哈希计算过程。不同值导致签名内容不同服务端风控能识别出这不是一个真实浏览器环境。解决把hardwareConcurrency、deviceMemory、platform、userAgent这几个值固定成和你抓包时浏览器完全一致的常量。不要用 Node 的os.cpus().length去动态生成那样每次跑出的值可能会变签名就不稳了。我现在的习惯是直接把抓包时的navigator对象 JSON 序列化出来存成环境配置文件签名函数初始化时直接加载这份配置不再手工拼。5.2 现象版本一更新所有签名全部signature error之前存的脚本全废原因前端 JS 更新后签名函数被重新混淆盐值和算法结构可能都变了。你之前从旧包中抠出的函数已经没有参考价值。这是逆向工作里最消耗精力的部分没有一劳永逸的解法只能靠「版本监控 快速重扒」来缩短窗口期。解决给签名函数加一层版本感知。具体做法是每次启动任务时先访问首页抓取当前静态资源 JS 文件的版本号通常文件名里带 hash比如index.3f2a9b8c.js和你本地保存的版本做对比。版本不一致时不要硬跑直接重新抓包、重新定位签名函数、更新盐值和函数源码。把这个流程做成自动化能把你从半夜翻车的状态里解救出来。硬跑只会浪费时间这个坑我踩过现在长记性了。5.3 现象用代理后签名正确但请求被拒直连却正常原因代理服务器修改了 TLS 指纹或 HTTP 头顺序服务端即使签名通过也会因为「请求环境异常」而拒绝。另一种可能代理所在机房 IP 已被风控标记。解决先用直连验证签名本身是否正确。直连能过、代理不过就换代理测试代理也过不了签名再考虑是不是签名没有绑定当前 IP。注意签名内容一般不会绑定 IP所以如果换 IP 就能通过说明问题出在 IP 信誉度上不是签名逻辑。5.4 现象同一个签名值反复使用前几次成功后面全部 403原因服务端做了防重放校验。同一个x-s在短时间内被多次使用即使还没到时间戳窗口上限也会因为「重复签名」被拦截。签名必须是一次一签哪怕请求内容完全相同。解决确保每次请求都重新生成一次签名。不要在脚本里缓存签名结果。如果你是想做连续翻页采集那就每一页、每一条请求都重新调用一次签名函数不要复用。这里有个取舍签名函数本身可能比较重补环境后每次初始化几百毫秒但重用一个签名值省下的时间远比不上被风控封号的代价。我一般会用长驻 Node 进程维护一个签名函数实例每次只调用签名计算函数本身而不是重新初始化环境。5.5 现象Web 端签名正确但用 App 抓包得到的接口时签名不通过原因App 端接口走的是另一套签名逻辑可能是 so 层签名也可能用了不同的盐和参数组合。Web 端的签名函数在 App 端接口上不适用。反之亦然。解决先明确你的采集场景是 Web 端请求还是 App 端请求。文章前面说的方案都是 Web 端如果要做 App 端采集需要抽 so 文件用 Unidbg 模拟执行或者用 Frida hook 运行环境实时拿签名。那是一条完全不同的技术路线不建议和 Web 端方案混用。6. 验证x-s逆向成果的三种方法批量回放、频率控制与结果自检签名函数跑通后先不要全量跑数据用三种方法做个快速验证。第一种批量回放。准备 20 个真实搜索词每个词发起一次请求统计成功率和失败响应码。成功率低于 90% 说明签名还存在环境层面的不稳定性优先排查navigator和document的 mock 是否完整。第二种频率控制。连续 30 秒内发起 50 次请求观察是否出现限流。如果签名正确但被限流问题在 IP 或账号权重签名本身没毛病。第三种换账号测试。同一个签名生成器换一个 Cookie 重新生成签名确认新账号能正常请求验证签名是否和账号强绑定。顺手写一个简单的统计脚本把验证过程沉淀下来const results []; for (const keyword of keywords) { try { const res await client.request(/api/sns/web/v1/search/notes, { keyword }); results.push({ keyword, status: res.status, ok: res.ok }); } catch (e) { results.push({ keyword, status: 0, error: e.message }); } } const successRate results.filter(r r.ok).length / results.length; console.log(成功率:, (successRate * 100).toFixed(1) %); console.table(results.filter(r !r.ok));这段脚本只做一件事把每一条失败的请求打印出来对照状态码区分原因——406是签名问题403是权限或风控461可能是频率限制。这样定位问题会有方向感。状态码的含义在不同接口上可能略有差异但大方向是稳的。我自己现在的习惯是x-s逆向完成后先拿一个低权重的小号跑 24 小时确认签名稳定性和风控边界再考虑上正式数据。这个方向值不值得做取决于你要的数据量级——如果只是每天几百条笔记采集Web 端签名足够如果是百万级数据同步就得考虑更稳定的签名链路和设备指纹管理。希望帮到你。本文还有配套的精品资源点击获取