小红书x-s签名逆向:Node补环境方案与JS逆向实战 简介本资源为某红书 x-s 加密算法的纯 JS 补环境版本面向从事前端逆向、爬虫开发与安全研究的工程师尤其适合需要稳定生成 x-s 参数、又不想依赖浏览器环境的开发者。包内共 2 个文件包含 1 个 js 与 1 个 pyjs 文件负责补环境并生成加密参数py 文件通过 execjs 调用该 js并附带完整可用的接口调用 Demo压缩包整体约 59KB轻量易集成。目前已有 1892 人学习下载说明该方案在社区中具备一定认可度。读者可直接参考示例完成参数生成与接口联调理解补环境思路与调用链路并在此基础上按需改造适配自身业务场景遇到问题还可联系作者沟通适合作为逆向学习与工程落地的参考实现。1. 从一次接口请求失败说起这套补环境方案到底在解决什么有个做数据采集的朋友遇到过一件挺玄学的事同样的接口浏览器里手动点一下能拿到数据换成脚本发请求就返回一个空对象连报错都不给。抓包看了半天请求头里多了一个x-s字段值是一长串看起来像 Base64 的字符串每次刷新页面还会变。这个字段就是某红书前端在请求发出前动态算出来的签名服务端拿它做校验对不上就直接把请求丢掉。这份资源要解决的就是把这个签名算法在 Node 环境里完整复现出来并且把浏览器里那些算法依赖的运行环境一并补齐让脚本发出去的请求和真实浏览器发出去的请求在服务端看来没有区别。它适合两类人一类是正在做数据采集、需要稳定拿到公开接口返回的开发者另一类是想学习前端 JS 逆向、补环境这套方法论的人。资源本身是 JavaScript 和 Python 双语言实现核心逻辑在 JS 侧Python 侧负责调度和调用。当前版本标注为 8.28 更新说明算法本身经历过一次调整旧版本可能已经失效这一点后面会专门讲怎么判断。2. 技术选型与整体架构为什么是补环境而不是纯扣算法2.1 补环境方案和纯扣代码的本质区别很多人第一反应是把签名函数从混淆代码里抠出来单独跑。这条路在早期确实走得通但现在的混淆会把算法拆散到多个模块依赖window、document、navigator、location这些浏览器对象还会在运行过程中动态读取一些环境属性参与计算。你抠出来的函数一旦脱离浏览器某个window.xxx读不到结果就是错的而且错得很安静不报错只是签名对不上。补环境走的是另一条路不把算法从环境里剥离而是用 Node 造一个假的浏览器环境把算法连同它依赖的那些对象一起搬过来跑。算法以为自己还在浏览器里实际上跑在 Node 里。这样做的好处是算法更新时只要它依赖的环境接口没大变补环境层基本不用动维护成本低很多。常见做法是用jsdom起一个 DOM 环境再手动往global上挂window、document、navigator这些对象。但jsdom比较重启动慢很多采集场景下其实只需要补几个关键属性手写一个轻量环境反而更快。2.2 目录结构和模块职责这份资源的目录大致是这样组织的project/ ├── js/ │ ├── env.js # 补环境核心构造 window/document/navigator │ ├── sign.js # 签名入口对外暴露 getSign(params) │ └── vendor.js # 从目标站点提取的混淆算法代码 ├── python/ │ ├── client.py # 请求封装调用 JS 拿签名 │ └── runner.py # 用 PyExecJS 或子进程方式执行 JS ├── config.json # 接口地址、请求头模板、超时配置 └── test.js # 本地验证签名是否可用的脚本env.js是整个方案的地基它决定了算法能不能跑起来。sign.js是门面外部只需要调它。vendor.js是算法本体通常是从目标站点下载后原样放进来不做修改这样算法更新时直接替换这个文件即可。Python 侧不参与算法只负责把参数传进去、把签名拿出来、拼到请求头上。2.3 签名调用的数据流一次完整的签名调用链路是这样的Python 侧准备好请求参数序列化成字符串通过runner.py传给 Node 侧的sign.jssign.js在env.js构造好的环境里执行vendor.js里的算法算出x-s再原路返回给 PythonPython 把它塞进请求头发出。// sign.js 核心逻辑示意 const { createEnv } require(./env); const vendor require(./vendor); // 环境只初始化一次复用避免每次调用都重建 const env createEnv(); function getSign(params) { // 把参数按算法要求的顺序拼接具体顺序在 vendor 里定义 const raw Object.keys(params) .sort() .map(k ${k}${params[k]}) .join(); // 在补好的环境里执行签名 return env.run(() vendor.sign(raw)); } module.exports { getSign };这里有几个参数要注意params的键排序规则必须和算法内部一致通常是字典序但有些版本会按特定字段优先raw的拼接分隔符也要对齐差一个字符签名就废。env.run是一个包装作用是把当前执行上下文切换到补好的环境里保证vendor.sign内部读到的window是我们造的那个。提示环境初始化放在模块顶层只执行一次是因为重建环境开销不小高频调用时每次重建会明显拖慢速度。3. 补环境核心实现window、document、navigator 怎么造3.1 最小可用环境需要补哪些对象不是所有浏览器对象都要补补多了反而容易引入不一致。判断标准很简单算法代码里读到了什么就补什么。实际操作时先在 Node 里直接跑vendor.js看它报的第一个ReferenceError或TypeError是什么缺什么补什么一轮一轮来。常见的必补对象有这几个对象关键属性作用windownavigator、location、document算法入口通常挂在 window 上navigatoruserAgent、platform、webdriver参与指纹计算documentcookie、createElement部分算法会读 cookie 或创建节点locationhref、protocol、host参与签名参数拼接navigator.userAgent是最容易翻车的地方。算法可能对 UA 做特征提取比如判断是不是移动端、浏览器内核版本这些特征会进入签名计算。如果你补的 UA 和真实请求头里的 UA 不一致服务端一比对就露馅。血泪经验是补环境里的 UA 必须和实际发请求时用的 UA 完全一致一个字都不能差。3.2 用 Proxy 拦截未定义属性手写环境最头疼的是不知道算法会读哪个属性漏一个就出错。一个实用技巧是用Proxy包一层把读不到的属性记录下来方便排查。// env.js 中用 Proxy 记录属性访问 function createEnv() { const accessed new Set(); const navigator new Proxy({ userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., platform: Win32, webdriver: false, }, { get(target, prop) { accessed.add(navigator.${String(prop)}); // 未定义的属性返回 undefined而不是抛错 return target[prop]; } }); const window new Proxy({ navigator, location: { href: https://example.com/, protocol: https: }, document: { cookie: }, }, { get(target, prop) { accessed.add(window.${String(prop)}); return target[prop]; } }); return { run(fn) { // 把补好的对象挂到全局执行完再还原 const backup { window: global.window, navigator: global.navigator }; global.window window; global.navigator navigator; try { return fn(); } finally { Object.assign(global, backup); } }, accessed, }; }accessed这个集合是排查利器。签名结果不对时把它打印出来看看算法到底读了哪些属性有没有哪个属性你补的值和真实浏览器不一样。run方法用try/finally保证执行完一定还原全局避免污染后续调用。3.3 环境一致性校验补完环境不代表就对了还得验证。最直接的办法是拿真实浏览器算一次签名再拿补环境算一次同样的输入对比输出。如果一致说明环境补对了如果不一致用accessed集合逐个属性排查。// test.js 本地校验 const { getSign } require(./js/sign); const params { a: 1, b: 2, t: Date.now() }; const sign getSign(params); console.log(补环境签名:, sign); // 把浏览器里同样参数算出的签名贴进来对比 const expected 浏览器里算出的签名值; console.log(是否一致:, sign expected);注意t这种时间戳参数浏览器和补环境算的时候要传同一个值否则对比没有意义。校验通过后这个test.js可以留着每次算法更新后跑一遍快速判断补环境是否还兼容。4. 从零跑通环境准备、依赖安装与首次调用4.1 Node 和 Python 环境准备这套方案需要 Node 和 Python 两个运行时。Node 负责跑算法Python 负责调度。版本上Node 建议 16 以上因为用到了Proxy和一些较新的语法Python 建议 3.8 以上主要是为了PyExecJS或子进程调用的兼容性。# 检查版本 node -v python --version # 安装 Node 侧依赖如果有 package.json npm install # 安装 Python 侧依赖 pip install requests pyexecjspyexecjs是用来在 Python 里调 JS 的但它依赖本机已安装的 Node且对复杂环境的支持一般。如果发现pyexecjs跑补环境不稳定常见做法是改成子进程调用Python 用subprocess起一个 Node 脚本通过标准输入输出传参。这样隔离性好环境不会互相干扰。4.2 配置文件和请求头对齐config.json里放接口地址和请求头模板。请求头里的User-Agent、Referer、Cookie这些必须和补环境里navigator、location、document.cookie保持一致。{ api: https://example.com/api/feed, headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Referer: https://example.com/, Accept: application/json }, timeout: 10 }这里最容易踩的坑是 UA 不一致。补环境里写的是桌面端 UA请求头里发的是移动端 UA服务端一对比就判定异常。我一般会把 UA 抽成一个常量补环境和请求头都引用它从源头保证一致。4.3 首次调用与结果验证跑通的最小闭环是这样# client.py 调用示意 import json import subprocess import requests def get_sign(params): # 通过子进程调用 Node 签名脚本 result subprocess.run( [node, js/sign_cli.js], inputjson.dumps(params), capture_outputTrue, textTrue, timeout5 ) return result.stdout.strip() def fetch(): with open(config.json) as f: cfg json.load(f) params {page: 1, size: 20} sign get_sign(params) headers dict(cfg[headers]) headers[x-s] sign resp requests.get(cfg[api], headersheaders, paramsparams, timeoutcfg[timeout]) return resp.json() if __name__ __main__: print(fetch())sign_cli.js是一个命令行入口读标准输入、调getSign、把结果打到标准输出。subprocess.run的timeout要设防止 Node 卡死拖住 Python。返回结果先看是不是正常 JSON如果返回空对象或错误码八成是签名不对回到第 3 章的校验流程排查。注意首次跑通后把成功的请求头和签名存一份样本后面算法更新时用来做回归对比能省很多事。5. 避坑与常见问题排查5.1 签名结果每次都不一样但服务端还是拒绝现象是本地算出来的签名格式看着没问题长度也对但请求就是返回空。原因通常是签名里包含了时间戳或随机数服务端会校验时间窗口你本地算的时间和请求发出的时间差太大或者随机数没按算法要求生成。解决方法是确认算法里t参数的取值逻辑保证签名和请求在同一时间窗口内发出别先算好签名放几分钟再发。5.2 补环境跑通但结果和浏览器不一致现象是 Node 里不报错签名也能算出来就是和浏览器对不上。原因一般是某个环境属性值不同最常见的是navigator.userAgent、navigator.platform、document.cookie。解决方法是打开第 3 章的accessed记录逐个属性对比浏览器里的真实值重点看 UA 和 cookie。cookie 尤其容易被忽略有些算法会把 cookie 里的某个字段拼进签名。5.3 算法更新后旧代码直接失效现象是之前跑得好好的某天开始全部返回空。原因是目标站点更新了vendor.js里的算法或者改了环境依赖。解决方法是重新从站点提取最新的算法代码替换vendor.js然后跑test.js校验。如果替换后补环境报缺属性按第 3 章的方法逐个补。8.28 这次更新大概率就是这类情况旧版本用户需要同步替换。5.4 PyExecJS 调用超时或内存泄漏现象是跑一段时间后 Python 进程越来越慢或者pyexecjs报超时。原因是pyexecjs每次调用可能重建 JS 运行时补环境这种重初始化的场景下开销被放大。解决方法是改用子进程常驻模式起一个 Node 进程通过标准输入输出持续接收签名请求避免反复初始化。或者干脆用pyexecjs的compile缓存编译结果减少重复编译。5.5 请求频率一高就被限制现象是单次请求正常连续请求几次后开始返回空或错误码。原因是服务端除了签名校验还有频率和风控策略。签名只解决「请求合法」的问题不解决「请求太频繁」的问题。解决方法是控制请求间隔加上合理的重试和退避别把签名当成万能钥匙。这块属于使用策略和算法本身无关但实际采集时绕不开。6. 二次开发与算法更新时的改造思路拿到这份资源后最值得做的二次开发是把签名调用做成常驻服务而不是每次请求都起一个 Node 进程。具体做法是写一个简单的 HTTP 服务或者用标准输入输出常驻的 Node 进程Python 侧通过本地端口或管道调用。这样单次签名耗时可从几十毫秒降到几毫秒高频场景下差别很明显。// sign_server.js 常驻服务示意 const readline require(readline); const { getSign } require(./sign); const rl readline.createInterface({ input: process.stdin }); rl.on(line, (line) { try { const params JSON.parse(line); const sign getSign(params); process.stdout.write(sign \n); } catch (e) { process.stdout.write(ERROR: e.message \n); } });Python 侧保持一个长连接进程每次写一行 JSON、读一行结果。注意错误处理Node 侧出错要返回可识别的错误标记Python 侧据此决定重试还是放弃。算法更新时的改造流程我一般固定走这几步先从目标站点重新提取vendor.js替换后跑test.js看签名是否和浏览器一致不一致就打开accessed记录对比环境属性属性都对齐了还不一致说明算法内部逻辑变了需要重新分析vendor.js的入口和参数拼接规则。这个流程走熟之后一次更新通常半小时内能搞定。改造点改造前改造后收益调用方式每次起 Node 进程常驻进程耗时降一个量级环境初始化每次重建模块级复用减少重复开销错误处理直接抛异常返回错误标记便于重试和排查版本管理手动替换保留样本回归更新时快速定位还有个小技巧把每次成功签名的输入输出存成样本对算法更新后先拿样本跑一遍能快速判断是环境问题还是算法问题。环境问题样本会报缺属性算法问题样本能跑但结果对不上。这个习惯帮我省了很多来回折腾的时间。从那以后我每次拿到新的补环境版本都强制先跑一遍样本回归再上真实请求。希望帮到你。本文还有配套的精品资源点击获取