简介:面向具备前端开发与逆向工程基础的学习者,这份可运行源码包完整呈现了小红书 X-s/X-T 参数生成逻辑的 JS 逆向分析过程。内容涵盖从抓包识别 Base64 编码 JSON、Hook JSON.stringify 断点定位,到跟栈追踪加密函数、补环境与 RPC 调用等关键步骤,并附有插桩日志,明确展示 URL 与参数经 MD5 拼接、Base64 编码后生成 payload 的完整链路。资源共 3 个文件,以 HTML 演示页面、inscode 配置及 gitignore 辅助文件为主,整体仅 6KB,轻量紧凑,便于直接运行与对照调试。包内源码可复用为同类 Web 应用加密参数研究的参考模板,适合希望深入理解前端签名机制、提升接口安全分析能力的开发者。目前已有 120 人学习下载,适合配合抓包工具与调试器边读边练。
1. 小红书 X-s/X-T 签名是道硬门槛:JS 逆向要先解决哪几件事
JS 逆向分析小红书 X-s/X-T,这句话在爬虫、自动化、数据采集圈子里几乎每天都会出现:小红书 Web 端大部分接口都带着X-s和X-t两个请求头,签名不对直接返回sign error,严重时还会触发滑块验证。X-s是请求签名,X-t是对应的时间戳,两者由浏览器里的加密 JS 生成。你想做笔记批量采集、图片提取、评论区数据整理,第一步就是把这个签名函数从页面 JS 里剥离出来,并且能在 Node.js 里重新跑通。网上讨论这个的人很多,但真正能落地的“可运行源码”很少,难点不在算法本身,而在浏览器环境缺失、Cookie 绑定、混淆和 WASM 这几层。这篇就按定位、补环境、跑通、踩坑、验证的顺序,给你一套可以照着复现的完整路径。
2. X-s/X-T 到底是什么:从抓包到定位签名函数
很多人一上来就问“X-s 的源码是什么”,这是一个容易走偏的问题。X-s不是一个固定函数,也不是一行明文算法,它是随版本迭代不断变化的一个签名结果。你真正要做的,不是在某个开源仓库里找“答案”,而是在目标站点的运行时里把生成这个签名值的函数“请”出来。先弄懂它在请求头里长什么样,再从调用堆栈里反向定位,才是正路。
2.1 先分清两个头的职责
X-t是一个毫秒级时间戳,类似1730000000000,它在请求头里的作用很简单:告诉服务端这个请求是什么时候发出来的。服务端会用当前时间和X-t做差,超过一定窗口就直接拒绝,所以它承担的是时效性校验。
X-s则是对请求路径、请求体、时间戳、Cookie 中的关键指纹,以及客户端环境信息做派生计算后的签名结果。它通常是一段几十到上百位的字符串,里面包含大小写字母和数字。服务端拿到请求后,会用同一套校验逻辑重新算一遍,看跟X-s是否一致。如果不一样,就判定请求被篡改或根本不是真实客户端发出来的。
这两个头通常一起出现,校验顺序是:先看X-t超没超时,再看X-s对不对。所以你只伪造X-s而忽略X-t,或者X-t和签名内部用到的时间戳不一致,都会被拒。
小红的 Web 端有时还会出现x-s-common-header,它携带的是设备和浏览器环境的编码信息,属于更完整的风控体系。很多实战帖里说的“小红书 shield”,本质是一整套端侧风控方案,X-s/X-T只是它在请求层暴露出来的签名形态。
| 请求头 | 典型内容 | 职责 |
|---|---|---|
| X-s | 签名结果字符串 | 防篡改、防重放 |
| X-t | 13 位毫秒时间戳 | 时效校验 |
| x-s-common-header | 环境参数编码 | 设备指纹上报 |
2.2 用 XHR 断点和调用堆栈把加密函数逼出来
定位签名函数最靠谱的手段,不是去搜各种“算法还原帖”,而是用浏览器 DevTools 的 XHR 断点。具体做法是这样:
- 打开页面,在 Network 面板勾选 Fetch/XHR,手动触发一个需要签名的请求,比如刷新首页笔记流或搜索内容。
- 找到一个带
X-s和X-t的请求,右键它,选择 Copy as fetch,把完整请求头保存下来备用。 - 切到 Sources 面板,在右侧找到 XHR/fetch breakpoints,点击加号,输入 URL 关键词,比如
/api/或这个接口自己的路径片段。 - 刷新页面,让请求重新发出。断点会停在真正发请求的那一行。
- 看右侧 Call Stack,从
fetch或send这一层往下翻。判断依据很简单:哪一层的函数里出现了x-s这个字符串,哪一层就是签名逻辑的入口。
这种做法的好处是,你不需要知道整个项目结构,也不用先去还原什么整体架构。你只要跟着调用栈走,最终一定会路过那个把 URL、时间戳、Cookie 塞进某个函数并返回X-s的位置。
如果你以前做过别的站点 JS 反爬实战,会发现这个过程本质上没有差别:所有签名都是在一个“发送之前”的拦截点被算出来的。只要找到那个点,剩下的就是环境补齐和调用方式问题。
2.3 堆栈里常见模块名与产物形态
现代 Web 端 JS 基本都是 webpack 打包的,签名算法通常藏在某个 chunk 文件里。定位之后,函数名很可能被压缩成单字母,比如t、e,甚至直接挂载到某个模块上而没有暴露到全局。
常见的产物形态有三种:
第一种是纯 JS 实现,整个签名过程都用 JavaScript 写成,调用了TextEncoder、crypto之类的 API。这种最好处理,直接把那段 JS 拿出来在 Node 里补环境运行即可。
第二种是 JS 调用 WASM。签名核心被编译成.wasm文件,JS 只负责把参数传进去、读结果。调用堆栈里会出现wasm-function或者WebAssembly.instantiate这样的字样。碰到这种情况,别急着把 WASM 还原成 JS,直接把它抓下来,在 Node 里用WebAssembly模块实例化后调用导出函数,效率要高得多。
第三种是 JS 里混了端上回传逻辑,比如一部分签名在客户端算好,再带进 Web 请求。这种情况在 Web 端相对少见,如果看到某个参数是异步回传、从 localStorage 里读出来的,就要留意它可能不是一个纯函数。
3. 在 Node.js 里补环境跑通 X-s:可运行的最小方案
定位到签名函数之后,任务就变成了“把浏览器里能跑的 JS 搬到 Node 里跑”。直接node xhs.js基本必报错,因为页面里的签名函数依赖浏览器全局对象:window、navigator、document、location,甚至CanvasRenderingContext2D。这些对象在 Node 里默认不存在,所以才有“补环境”这个步骤。
3.1 为什么必须补环境:window、navigator、document 一个都不能少
签名函数在浏览器里运行时,会隐式依赖这些全局变量。比如生成环境指纹时,它可能读navigator.userAgent;做 Cookie 拼接时,它可能读document.cookie;做环境检测时,它可能判断navigator.webdriver是否存在。这些读取动作,在浏览器里是天然成立的,但在 Node 里只要断一个,签名函数就会抛异常,或者算出一个能被服务端识别出来的“假签名”。
常见的做法是用 Node 内置的vm模块创建一个沙箱,把浏览器对象塞进沙箱里,再执行抓下来的 JS。vm.createContext可以让我们手动构造一个“类浏览器”的全局环境。
// 最小补环境骨架:先跑起来,再按报错逐项补齐 const fs = require('fs') const vm = require('vm') function buildWindow() { const window = {} window.window = window window.self = window window.top = window window.parent = window window.frames = window window.length = 0 window.navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', appVersion: '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', platform: 'Win32', language: 'zh-CN', languages: ['zh-CN', 'zh'], plugins: [], webdriver: undefined } window.document = { cookie: 'a1=你的设备Cookie; web_session=你的会话Cookie;', referrer: 'https://www.xiaohongshu.com/', title: '', createElement: () => ({ getContext: () => null, style: {}, setAttribute: () => {}, appendChild: () => {} }), getElementsByTagName: () => [], addEventListener: () => {} } window.location = { href: 'https://www.xiaohongshu.com/', protocol: 'https:', host: 'www.xiaohongshu.com', hostname: 'www.xiaohongshu.com', pathname: '/', search: '' } window.setTimeout = setTimeout window.clearTimeout = clearTimeout window.setInterval = setInterval window.clearInterval = clearInterval return window }这段代码里的window.document.cookie不是随便填的。X-s的值通常和a1或web_session这类 Cookie 绑定,签名时会读其中的指纹信息。如果你在浏览器里登录过,就要把登录后的 Cookie 复制进来,否则签名计算出来也大概率通过不了服务端校验。
3.2 拟真环境的补全清单与常见陷阱
补环境不是一次就能写完的。我的习惯是:先拿最小骨架跑,报错缺什么就补什么。下面这些是签名函数最常见的依赖项。
| 环境对象 | 必备成员 | 说明 |
|---|---|---|
| window | window/self/top/parent/frames/length | 必须有自引用 |
| navigator | userAgent/appVersion/platform/language/plugins/webdriver | webdriver 要设成 undefined |
| document | cookie/createElement/getElementsByTagName/addEventListener | 会被签名函数读取 |
| location | href/protocol/host/pathname/search | 有的算法会把 pathname 拼进签名 |
| storage | localStorage/sessionStorage | 需要时用临时对象模拟 |
| timers | setTimeout/setInterval | 有的初始化过程会挂定时器 |
| encoding | TextEncoder/TextDecoder | WASM 调用场景几乎必用 |
| crypto | Web Crypto API | Node 里可能需要用 polyfill 补齐 |
这里有个容易翻车的细节:navigator.webdriver必须显式赋值为undefined,而不是false。很多站点会检测这个字段是否存在,如果它存在且为false,反而不正常;不存在则更像真实浏览器。
还有一个常见误区是直接引入jsdom当补环境工具。jsdom确实提供了完整的 DOM 实现,但它太重了,而且有的签名函数能检测出某些原生对象的原型和jsdom实现不一致。对于只涉及签名、不涉及渲染的场景,手工构造一个沙箱更可控。
3.3 调用签名函数的 Node 最小骨架
环境对象构造好之后,就可以加载核心 JS 并调用签名函数了。注意:下面这段代码是一个可运行的调用骨架,不是某个固定版本的全量源码。核心签名函数来自你定位到的那个页面 JS,函数名也以你实际定位到的为准。
const window = buildWindow() const sandbox = { window, self: window, top: window, parent: window, navigator: window.navigator, document: window.document, location: window.location, console, TextEncoder, TextDecoder, Buffer, process, setTimeout, clearTimeout, setInterval, clearInterval } vm.createContext(sandbox) // 这里换成你从浏览器里抓下来的核心 JS 文件 const coreCode = fs.readFileSync('xhs_web.sign.js', 'utf-8') try { vm.runInContext(coreCode, sandbox, { filename: 'xhs_web.sign.js' }) const ts = Date.now() const requestArgs = { url: '/api/...', body: '', ts } // 假设定位到的导出函数是 window.signRequest,按实际调整 const xS = sandbox.window.signRequest ? sandbox.window.signRequest(requestArgs) : null console.log('X-t:', ts) console.log('X-s:', xS) } catch (err) { console.error('运行失败,重点看缺哪个 API:', err.message) }这段代码的核心思路是:先构造沙箱,再执行目标 JS,最后调用目标函数。url参数决定了签名内容,改成真实请求路径;body在有 POST 请求体时不能为空,否则签出来的结果对不上;ts建议直接用Date.now(),同时让外部拿到它作为X-t的值,保证两个头用的是同一个时间戳。
如果签名函数没有挂载到window上,而是藏在 webpack 模块内部,可以在断点处找到__webpack_require__,然后手动取模块:
// 在 DevTools 断点处执行一下,拿到模块加载函数 // 通常压缩后叫 r,也可能叫 __webpack_require__ const req = sandbox.window.__webpack_require__ || sandbox.window.r const signModule = req(173) // 173 是定位到的模块 ID,按实际替换 sandbox.window._sign = signModule4. 避坑:X-s/X-T 复现路上 5 个高频翻车点
签名这东西,看着就是“一个函数返回一个字符串”,实际跑起来几乎每一步都可能翻车。下面这 5 个坑是我在多次实战里真金白银踩出来的,每条都按“现象 → 原因 → 解决”给你说清。
4.1 现象:签名算出来了,接口仍然报 401
这是最挫败的一种情况:X-s生成了,X-t也带上了,请求路径和浏览器里一模一样,结果服务端还是返回未授权。
原因通常是三个:第一,X-s内部用到的时间戳和X-t头不是同一个值,你调用签名函数时传了一个时间戳,请求头发X-t时又Date.now()了一下;第二,Cookie 没对齐,签名函数读到的 Cookie 和请求实际带的 Cookie 不一致,服务端按照 Cookie 指纹重新计算时对不上;第三,请求体别写错,GET 请求别带多余 body,POST 请求的 body 必须原样参与签名。
解决方法是把签名参数和业务参数分开:签名函数内部产生的ts,一定要把它 return 出来,然后原封不动放进X-t。请求携带的 Cookie 也要从同一个环境变量里读取,不能签名时是一份、发送时是另一份。
4.2 现象:代码一跑就报 undefined is not a function
签名函数调不到,打开控制台一看,xxx is not a function,有时候还是undefined is not a function。
这背后有两种可能。一种是签名函数确实没挂到window上,它只是某个 webpack 模块内部的局部变量。你硬在沙箱外面调用,自然拿不到。解决方法是回到 DevTools,在调用堆栈里找到这个模块的 export。
另一种是变量名大小写问题。JS 是严格区分大小写的,搜索时如果开了“忽略大小写”,很容易把_sign和_Sign当成同一个东西,结果抄代码时抄错入口。这种错误排查起来特别费时间,因为报错信息跟真实原因离得远,看着像环境缺失,其实是入口找错了。
4.3 现象:浏览器里正常,Node 里跑结果不一样
同一个函数,在浏览器 Console 里执行打印出来一个值,放到 Node 沙箱里跑,结果变了,甚至每次跑都不一样。
原因通常是环境检测。签名函数里会判断navigator.webdriver是否存在、navigator.plugins长度是否为 0、window.chrome是否挂载、Canvas 指纹能否正常生成。这些判断结果会直接影响签名时使用的“指纹因子”,导致字符串不一致。
解决方法是不要只补“报错提到的那一个”对象,要把浏览器里真正存在的关键环境指纹全部搬过去。比如补window.chrome对象,虽然它可能只是一个下拉菜单相关的历史遗留,但在签名函数眼里,它是识别“当前是否真实 Chrome”的一个信号。同时要确保每次计算用到的指纹尽可能稳定,不能每次跑都给不一样的值。
4.4 现象:格式化混淆代码后死活读不出逻辑
很多人定位到签名代码后,第一件事就是把压缩 JS 格式化,然后一行行读。读一晚上可能都读不明白,因为现代混淆早就不是简单变量重命名了。
控制流平坦化会把正常的 if/else 拆成几十个状态循环;字符串加密会把关键参数名变成数组下标;还有一些代码在运行时用Function构造函数动态拼接。你看到的所谓“源码”,只是一张完全没有逻辑线索的迷宫图。
正确做法是别强行读。用运行时 hook 打印关键参数和返回值,比静态分析快得多。在签名函数入口打日志,把 url、ts、body 打出来,再把返回的X-s打出来,然后对比不同入参下的输出变化,反推参数之间的组合关系。这种动态追踪方式,配合 AST 层面的局部还原,才是对付混淆的正解。
4.5 现象:并发一高就触发滑块或限制
补环境通过、签名验证通过,开始批量采集时,跑了十几分钟突然弹滑块,或者接口开始返回 461。
原因不是签名算错了,而是你的请求指纹太单一:同一个环境、同一个 Cookie、同一个 IP,在极短时间内发起高频请求,风控侧不需要破解签名也能发现异常。
解决方案是给整套签名环境做隔离。每个采集任务用独立的 Cookie 和独立的环境变量,请求间隔加上随机延迟,不要每秒钟打十几个请求。签名正确只是第一步,请求频率和指纹一致性决定你能跑多久。真正稳定的采集方案,重点是流量控制,而不是无限提升签名速度。
5. 从浏览器搬到服务端的完整落地:封装签名接口与验证
签名函数在 Node 里跑通之后,下一步就是让它成为一个可以给任何脚本调用的服务。而不是每次都用node xhs.js手动跑一遍。常见做法是把签名模块封装成 HTTP 接口,请求方只需要传 url、body、ts 三个参数,返回拿到X-s和X-t。
5.1 把签名函数包成独立服务:输入输出怎么设计
签名服务不需要复杂路由,一个 POST/sign就够用。输入参数设计成固定结构,避免调用方理解成本:
const http = require('http') const { sign } = require('./signer') // 你自己封装的签名模块 http.createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/sign') { res.writeHead(404) res.end() return } let data = '' req.on('data', chunk => { data += chunk }) req.on('end', () => { try { const { url, body, ts } = JSON.parse(data) const result = sign({ url, body: body || '', ts: ts || Date.now() }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } catch (err) { res.writeHead(400) res.end(JSON.stringify({ error: err.message })) } }) }).listen(3900, () => { console.log('sign service running on 3900') })这里的sign函数是你在第三章里跑通的那段逻辑的封装。ts设计成可选参数,这样调用方可以自己控制时间戳,也可以在多次请求失败时重新生成。body必须支持空字符串,因为 GET 请求没有请求体,但签名函数同样会把空 body 纳入计算。
5.2 用一次真实请求验证签名正确性
服务封装好之后,不能直接丢到业务里。先做一次最小化验证:从浏览器里复制一个真实请求,去掉它原来的X-s和X-t,换成你自己签名服务生成的值,再发出去看状态码。
const response = await fetch('https://www.xiaohongshu.com/api/...', { method: 'GET', headers: { 'x-s': result.x_s, 'x-t': result.x_t, 'cookie': '完整 Cookie', 'accept': 'application/json' } }) console.log(response.status)如果返回 200,说明签名已经通过了服务端校验。如果返回 401 或 461,优先检查是不是 Cookie 和你签名时用的 Cookie 不一致。还有一个很实用的习惯:把浏览器里的原始请求保存下来,和你的请求逐字节对比,别只看头和签名,URL 里的 query 参数顺序变了也可能导致校验失败。
5.3 常见做法是限流与指纹隔离,别把一套环境跑到死
签名服务只是整个数据采集链路里的一环。常见做法是每个 Cookie 对应一个独立沙箱环境,签名服务内部维护环境池,调用方传一个session_id,服务根据这个 ID 返回对应环境的签名结果。
原因很简单:如果你所有请求都用同一个navigator.userAgent和同一个 Cookie,即使签名算法完全正确,高频请求下也会被识别成机器行为。指纹隔离的目的不是破解算法,而是让每个身份的请求频率看起来接近真实用户。
另外,X-s这类签名通常是版本相关的。页面更新后,算法可能调整,签名服务就可能失效。所以要把签名服务和具体业务请求解耦,页面改版时只需要更新环境里的核心 JS 文件,业务方不用改代码。
6. 再进一步:从 X-s 到小红书 shield 与端上算法,值得投入吗
走到这一步,你已经具备了一套可以运行的签名链路。再往下深挖,会碰到小红书 shield 和端上算法,那就不仅是 Web JS 的事情了。
先把 Web 端的成果固化好:把每次签名请求的 url、body、ts 和最终签名结果记录下来,形成一条回归样本。之后每次更新核心 JS,都要拿旧样本跑一遍,确认输出没变。这个动作看起来不起眼,却是长期维护签名服务最重要的保障。没有回归样本,页面一改版,你会分不清是环境补少了还是算法换了。
如果目标是做笔记采集、图片提取这类数据场景,Web 端X-s/X-T已经够用了。很多人在刚入门时总想深入 App 端,觉得 Web 端限制多、要过滑块。但从落地性价比看,Web 端的签名链路短、定位容易、调试工具齐全,反而更值得先做扎实。
如果你确实要往端上走,方向会变成 Frida Hook、脱壳、定位 so 文件里的 JNI 函数。那时候核心代码已经不在 JS 环境里了,补环境这套经验只能帮你完成“入口定位”,后面的工作更多是二进制层级的逆向。这个领域更新快、成本高,适合有专门团队或长期业务需求的团队,不建议个人从零一头扎进去。
我现在的习惯是:Web 端签名用脚本自动化维护,端上算法只有在 Web 端完全被限制时才考虑。回归样本加签名服务,再加上合理的限流,足以支撑大多数个人项目和中小团队的采集需求。这一套思路,不仅能解决小红书,也能迁移到其他带签名的站点,通用性才是你投入时间得到的真正资产。
希望帮到你。
本文还有配套的精品资源,点击获取