最近在调网页端采集接口时,又跟某红薯的 x-s 签名较上了劲。熟悉的朋友应该都知道,这个平台绝大多数 JSON 接口都要带上 x-s、x-t 这几个自定义 header,否则服务端直接不搭理你,要么 403,要么返回一段“验证码页面”。很多刚入坑的朋友第一次看到 x-s 就懵了,不知道这串东西怎么来的、去哪看算法、本地怎么调试。这篇文章我把整个分析过程和复盘记录整理出来,从抓包定位、调用栈回溯、源码还原到补环境执行,尽量用完整链路的方式讲清楚。
内容更适合有一定 JS 逆向基础、想系统掌握签名定位思路的读者。如果你只是随手想拿现成代码,那这篇不一定适合;如果你想搞明白“为什么是这一步”而不是“背一个结果”,那可以认真看完。
1. 先搞清楚 x-s 在整个请求链路里的角色
1.1 它到底是谁生成、为谁服务的
某红薯网页端的请求流程很典型。你打开任意一个详情页或者搜索接口,打开 DevTools 的 Network 面板,刷新一次就能看到一堆名为 /api/sns/web/v1/... 的请求。随便点开一个请求,在 Request Headers 里能看到 x-s、x-t,有时候还有 x-s-common。
先说结论:这一串 header 是前端 JS 在请求发出之前,用当前请求的 URL、请求体、时间戳等信息动态计算出来的签名。服务端收到请求后会拿同样的参数重新计算一遍,如果跟你提交的值不一致,就直接判定为非法请求。所以它本质上是一个 HMAC 类的签名校验机制,再加了一点“客户端特征”进去,用来做风控辅助判断。
对爬虫开发来说,x-s 是绕不过去的一道关。你不把这块还原清楚,就算把 Cookie 处理得再完美,浏览器能正常访问,你的脚本一跑起来就报错。很多人一上来就搜“x-s 算法是什么”,其实正确思路不是先去问结论,而是先搞清楚它在哪个环节产生的。
1.2 用最笨的办法定位生成位置
定位生成位置,我习惯用 XHR/fetch 断点,而不是直接搜代码。
在某个接口列表页刷新前,切到 DevTools 的 Sources 面板,右侧找到 XHR/fetch breakpoints,点加号输入 api/sns/web 之类的关键字。这样任何发往这个路径的请求都会被断住。重新触发一次搜索,代码就会停在发请求的位置,这时候点击右侧的 Call Stack 往回看调用栈。
需要找的,是那种参数里出现 x-s 赋值的调用帧。正常情况下找两三帧就能看到类似:
javascript复制headers['x-s'] = window.__web_require__('...').default.sign(...)
或者一段自执行函数封装出来的对象方法。到这里就能确定:x-s 不是某个接口单独处理的,而是公共请求库统一注入的。
我见过不少朋友在这个阶段卡住,原因是断点断下来后,被压缩成一堆字母的代码劝退了。别慌,这一堆压缩代码恰恰说明方向是对的。先用格式化按钮把当前文件“pretty print”一下,再搜索 x-s 字样,你会看到所有入口点,通常也就两三个。
1.3 搞清 x-t 和时间戳的关系
x-s 通常跟 x-t 成对出现。x-t 是毫秒级时间戳,稍微分析一下就发现,x-s 计算结果里有很大一块跟这个时间戳相关。这就有个重要推论:x-s 是带时效性的,通常几秒到几分钟内有效。
我自己验证过:手动把某个请求里生成的 x-s 复制出来,改下请求时间,隔一分钟再重放,大部分情况下服务端已经不认了。原因是签名原始串里带了时间戳,这个值一变,整个签名就变了。
所以本地还原的时候,一定要保证传入算法的时间戳跟当前请求头里的 x-t 完全一致,否则你怎么算都对不上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法定位与还原思路
2.1 顺着调用栈找到加密函数本体
断点定位到这个“公共请求库”后,我们继续往下追。有些版本的代码结构比较规整,能在模块里看到这样的模式:
javascript复制var r = {
sign: function(e) {
var t = (0, s.default)(...);
return t
}
}
此时把断点打在 r.sign 内部,然后刷新触发请求,查看调用时传入的 e 到底是什么。通常它是一个包含请求路径、请求方法、请求体、时间戳等字段的配置对象。
下一步就是进入 sign 函数内部,逐行单步执行。你会看到它不断调用各种工具函数,比如编码、序列化、取固定盐值,最后丢给一个哈希函数。
这里有个经验:不要在还原算法时直接把整个大文件都复制出来,而是把单步执行时关键步骤的输入输出记录下来。比如第一步是 JSON.stringify,第二步是某种字符替换,第三步是拼上固定字符串,第四步是 MD5。有了输入输出链,后面在 Python 或 Node.js 里重写就非常轻松。
2.2 压缩代码里如何快速识别哈希算法
在压缩代码里找哈希算法有个小技巧:搜索特征长度和特征函数名。MD5 的实现不管怎么混淆,内部总会有 4 个初始向量常量 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476。搜索这些常量,基本就能命中算法本体。
某红薯的 x-s 算法历史上经历过多次升级。最初的简单版本基本就是 MD5 的魔改,后来慢慢加上了自定义 key、hash 后处理等。我在分析时发现,当前 web 端的核心计算本质上是:构造原始字符串(包含路径、参数、时间戳、盐值),然后走一次自定义哈希。不是纯粹的 MD5,里面有一些变种操作,比如位移、替换、补位逻辑修改等。
所以别直接套网上的老代码,最好以自己断点命中的版本为准。不同版本算法差别很大,偶尔调一次接口也许看运气能过,批量场景下旧代码基本废了。
2.3 理清流程后手动还原出签名函数
当我把关键步骤捋清楚之后,写代码就容易多了。一个典型的还原思路长这样:
python复制import time
import hashlib
def generate_x_s(path: str, data: dict, ts: str) -> str:
# path: 请求路径,例如 /api/sns/web/v1/search/notes
# data: 请求体或 query 参数序列化后的内容
# ts: 当前毫秒级时间戳字符串
# 第 1 步:参数归一化
normalized = normalize_params(path, data)
# 第 2 步:拼接盐值,实际盐值不方便直接公开,这里用占位符表示
raw_string = normalized + "salt_placeholder" + ts
# 第 3 步:自定义哈希处理
digest = custom_hash(raw_string)
return digest
这样看起来是不是很简单?但实际上真正难的点在 normalize_params 的规则,比如对象里面的 key 顺序要不要排序、空值怎么处理、数组怎么序列化,这些细节差一个字符结果就完全不同。
我的做法是先用请求实跑一次,拿到正确的 x-s 值,然后用上面这个结构的脚本不断调整 normalize 规则。对比本地结果与线上结果,一致了说明规则还原成功。
3. 补环境与本地执行方案选型
3.1 为什么不能只把 JS 文件放 Node 里跑
很多人走到这一步会犯一个错误:把提取出来的 JS 文件直接塞到 Node.js 里执行,然后报一堆 window is not defined、document is not defined。这是因为该平台前端代码大量依赖浏览器环境,跟 DOM、Cookie、动态全局变量都有关联。
x-s 相关模块在初始化时,很有可能读取当前页面的一些环境变量,甚至某些隐藏的 HTML 节点属性。这些在纯 Node 环境下不存在,自然就报错了。
此时有两条路:一是写补环境脚本,把缺失的 window、document、navigator 等对象一一补上;二是用现成的工具链,比如 jsdom 模拟浏览器环境,或者在真实浏览器中通过 CDP(Chrome DevTools Protocol)调用页面函数。
我的建议是:如果只是验证算法逻辑,用 jsdom 就够了;如果是要高并发调用,尽量把核心算法从 JS 里抠出来,在 Node 或 Python 里原生实现,而不是每次启动一个浏览器。
3.2 补环境的几个关键坑点
第一,Cookie 环境。某红薯前端代码执行时可能会取 Cookie 里的某个值参与签名计算,但不是每次都需要。你可以在补环境时先忽略,如果签名结果不对再考虑补 Cookie 维度。
第二,浏览器指纹相关对象。某些版本会检测 navigator.userAgent、navigator.platform,用到 screen.width 之类。这些值不影响核心计算,但如果环境里根本没有,代码可能会直接抛异常。
第三,window.__web_require__ 这种自定义模块加载函数。很多压缩代码通过它们来按需加载模块,单纯补 web API 还不够,需要把模块初始化方法也跑一遍。
补环境代码不宜一次性写太大,否则出 bug 时很难排查。正确姿势是:先在 Node 里用 try-catch 捕获第一个报错,补上缺失的对象;再执行,再捕获下一个报错,直到能跑通。每次只处理一个错误,效率最高。
3.3 在 Node 端做 output 校验
跑通之后,先别急着接业务。我把本地生成的 x-s 跟抓包拿到的真实 x-s 放在一起比对,确保在相同参数和相同时间戳下完全一致。
这里有个细节:抓包得到的 x-s 跟本地代码算的 x-s 不能跨请求对比。因为 x-s 是请求维度的,你要拿同一个请求的 URL、参数、时间戳喂给本地函数,再比较输出。实际操作时,我会在浏览器 console 里手动执行前端封装的请求函数,复制它实际的入参和时间戳,再用本地代码算一遍。如果完全一致,说明环境补齐了,算法也对了。
4. 常见问题与排障技巧实录
4.1 签名结果一致,请求还是被拒绝
这是最令人崩溃的情况。你的 x-s 算得跟浏览器完全一样,但脚本发出去仍然 403 或 461。
大概率问题出在两点:一是请求头顺序问题,有些服务端会校验 header 顺序,你需要把浏览器发出的 header 顺序完整复刻;二是还有别的维度的校验,比如 TLS 指纹或 HTTP/2 指纹。服务端能从网络层识别出这不是正常浏览器发的请求,x-s 再正确也没用。
遇到这种问题,先开一个代理,把脚本的真实请求头跟浏览器对比一遍。逐项检查,尤其是 accept、accept-language、content-type 等不太起眼的 header。注意 user-agent 必须跟生成 x-s 时用的浏览器版本保持一致,因为签名原始串里很可能包含了 UA 特征。
4.2 算法对,但跑一段时间后突然大量失败
如果之前一切正常,某天突然大面积请求失败,别急着怀疑代码,先看是不是时间戳问题。x-s 有生效窗口,窗口很短的话,网络延迟稍微高一点,服务端收到请求时算出的窗口已经不同了,就会拒绝。
这种情况常见于分布式部署、服务器跟目标服务器时间不同步。解决方法是在代码里统一用一个时间源,比如每次请求前从目标服务器获取标准时间,或者定时校准本机时间。我自己遇到过服务器时间慢了 3 秒,结果所有请求都失败,捣鼓了一个下午才发现。
4.3 验证码和频控怎么面对
再强的签名也顶不住无节制请求。某红薯的频控维度非常多:单 IP 每秒请求数、单账号每天请求数、相同参数重复请求数等等。触发后可能不直接拒绝,而是给你一个验证码页面,重试几次后会封禁 Cookie。
针对学习场景,我的建议是控制请求频率,加随机延时,别对线上数据造成压力。频繁触发风控不仅影响他人,也会让目标平台持续升级签名方案,最终大家都麻烦。
5. 合规边界与学习心态
5.1 分清学习用途与滥用风险
x-s 逆向分析本身是一件很有代表性的 JS 逆向学习案例。通过它,你可以学到如何定位签名、还原哈希、补齐浏览器环境,这套方法放到其它平台同样适用。很多企业的前端加密思路大同小异,掌握方法论比背出某一个平台的盐值更有价值。
但做技术研究一定要有边界意识。公开文章和代码里不要集成完整的、可用于大规模抓取的工具;不要涉及用户隐私数据、交易数据;在测试环境验证时尽量使用自己的账号和少量请求。个人学习、学术研究、安全测试,跟商业化采集、恶意攻击之间有明确区别,别把路走窄了。
5.2 平台风控升级后怎么办
追逆向方案的过程,其实也是跟风控策略博弈的过程。平台每次前端版本更新,算法都可能有变化。有的版本只是换个盐值,有的版本干脆把整个签名模块结构重写。
所以不要试图维护一个“永久有效”的方案,更理性的做法是建立一套快速定位、快速验证的分析流程。前端代码一更新,用断点定位新算法,用脚本对比确认新逻辑,几个小时就能跟上节奏。这比每次都从零开始研究要高效得多。
6. 一些值得留住的实操土办法
这类签名逆向做得多了,我复盘出一个很朴素的体会:真正影响成功率的往往不是算法本身,而是对细节的耐心。比如时间戳格式是毫秒还是秒、字符串拼接顺序、是否做了 encodeURIComponent、序列化时 key 顺序是否和浏览器一致,这些微小的点每个都能折腾你半天。
我平时会维护一个通用的“接口签名定位清单”:第一步,抓包确认哪些请求带签名;第二步,打断点找到公共请求层;第三步,单步跟进去确认原始串构造逻辑;第四步,分模块手写还原;第五步,本地输出与线上输出做一致性比对。遇到任何平台的签名问题都走这套流程,基本都能在两三个小时内理出头绪。
再分享一个调试心得:在分析过程中,尽量保留一份完整可复现的请求样本,包括请求 URL、请求体、请求头、Cookie、x-s、x-t。本地验证时反复用这组数据,比每次重新抓包更可控。我一般会把样本存成 JSON 文件,脚本读取后自动跑,跑挂了也知道去哪排查。
希望大家都能从这个 case 里提炼出适合自己的一套逆向分析思路。后面如果时间充裕,我再单独写一篇关于其它平台签名机制的对比分析,相信会更有意思。
