最近接手了一个维护了很久的采集项目,本来跑得好好的,突然某天开始大面积报错。不是常规的封IP,而是接口直接返回一段JSON:request is rejected。抓包一看,请求头里多了一个陌生参数:x-s。从那天起,我就正式踏上了和某红薯x-s逆向死磕的路。这篇文章把完整路径整理出来:从断点定位、抠代码、补环境,到算法还原、稳定调用,以及这一路上踩过的坑。如果你正在处理Web端签名校验,或者准备做类似客户端的逆向分析,这篇的思路应该能帮你省掉至少一周的弯路。文中所有结论都来自我实际调试的某个线上版本,不是官方文档,所以拿到新版本时,思路比结论更值得参考。
1. x-s:请求头里多出来的那串签名,到底在防什么
1.1 一次请求是如何被“验明正身”的
打开某红薯的Web端页面,随便点开一个笔记列表接口,会看到请求头里塞着好几个自定义参数:x-s、x-t、还有一串类似x-s-common的字段。x-s是一串动态生成、每次请求都不一样的签名;x-t是时间戳;服务端拿到请求后,会先校验这两个值是否匹配,再校验签名是否在允许的时间窗口内。如果签名和请求的路径、参数、时间戳对不上,直接拒绝。
打个比方:这就像进入一栋大楼不仅要刷工卡,还要现场对着工卡上的照片做面部识别,而且每进一次门,门禁系统都会重新发一张带时间戳的新卡。伪造一张静态卡号是没有用的,因为每一张卡都是动态生成的,卡号和进门时间强绑定。
某红薯Web端的签名机制还不是单一参数,x-s和x-t只是最深的一层,很多接口还会带上x-s-common,这个字段里藏了设备环境、浏览器指纹、操作行为等信息,服务端会用一套独立逻辑去解。所以就算你搞定了x-s,如果x-s-common里的环境信息看起来不对劲,还是会被风控挡下来。这也是为什么很多人在网上找到一份旧版x-s生成代码,跑两下就失效的原因——只搞定了一层,没有搞清楚完整的校验链路。
1.2 为什么签名反爬比封IP难缠得多
前几年大部分网站的反爬还停留在“封IP、封账号、校验User-Agent”的阶段,只要代理池够大、Cookie维护得好,大多数问题都能硬扛。签名机制的出现彻底改变了游戏规则。
传统封IP是一刀切:一个IP访问频率太高就拉黑,换IP就能绕。而签名校验是“逐请求校验”:每个请求都要携带一个和当前参数完全匹配的签名,签名里通常还绑定了时间戳、请求体、路径、盐值,有些版本甚至把鼠标轨迹、页面停留时间都算进去。你改一个字符,签名就失效;你重放同一个签名,时间窗口一过就失效;你批量复用一套签名,服务端一比对行为特征就标记异常。
更麻烦的是,生成签名的那段代码是运行在浏览器环境里的,它会读取navigator、document、canvas指纹、localStorage等环境信息。你用Node直接跑同样的算法,跑出来的结果可能和浏览器不完全一样,因为环境探针返回了不同的值。这也是为什么很多新手把x-s的加密函数抠出来在Node里调用,怎么都比对不上——不是算法的锅,是环境的锅。
这一步理解了,后面整个逆向改造就有了方向:先定位签名生成入口,再搞定运行环境,最后才是算法本身的还原。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点定位实战——从点击到x-s生成的完整搜索思路
2.1 最容易踩的误区:直接搜参数名
很多新手拿到这个需求,第一反应是打开DevTools,切到Sources面板,按Ctrl+Shift+F全局搜索x-s。结果搜出来的只有一堆压缩混淆后的代码,没有明显可读逻辑。我一开始也这样干过,浪费了大半天,后来才意识到问题出在哪。
在打包混淆后的生产环境代码里,x-s这个参数名大概率不是以明文形式出现的。它可能是被拼接出来的,比如"x-" + "s",也可能是在某个封装好的请求函数里通过变量统一赋值的。你在全局搜索里看到x-s,往往只是“设置请求头”的位置,真正的签名生成逻辑藏得更深。
正确的搜索姿势是组合关键词:x-s、x_s、xS、sign、signature、timeStamp、_time,以及和t、s挨着的变量名。搜到疑似位置后,不要急着下结论,先看这个变量被哪些函数引用,顺着数据流往上追。
2.2 用XHR/fetch断点截停请求
全局搜索不可靠,那就换个思路:直接在请求发出的那一刻把页面停住。
操作步骤很简单:
- 打开DevTools,切到Sources面板,右侧最下面有XHR/fetch断点区域。
- 点“+”号,输入要拦截的URL关键字,比如
/api/。 - 刷新页面,触发目标请求时,代码会自动停在发出请求的那一行。
- 这时候看Call Stack(调用栈),从最底层往上层一层层翻。
大多数情况下,签名生成逻辑就在请求函数的上层调用链里。我实际调试时,有一次顺着调用栈翻了4层,看到一个名字里带sign的匿名函数,进去发现它前面几行就在拼时间戳和用户代理信息,再走几步看到一段固定字符串,后面跟了一堆字符串拼接和哈希调用。那一刻基本就确定了:这就是x-s的生成入口。
如果XHR断点拦截不到,还有一种可能是页面用了fetch而不是XMLHttpRequest,需要在断点区域同时勾选fetch断点。另外,页面内可能还有Service Worker劫持请求,这种情况断点会被绕开,但某红薯Web端目前还没这么复杂,XHR/fetch断点的方案是够用的。
2.3 搜不到、断不到时的兜底方案
如果XHR断点也定位不到,或者代码里做了反调试(比如debugger踩点、无限循环),就需要用Hook方案兜底。
最简单的是在页面加载前注入一段脚本,Hook掉发起请求的底层方法,把每次请求的URL和调用栈打出来:
javascript复制(function () {
var originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
console.log('[Hooked XHR Open]', method, url);
this._url = url;
return originalOpen.apply(this, arguments);
};
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
console.log('[Hooked XHR Send]', this._url, body);
console.trace();
debugger;
return originalSend.call(this, body);
};
var originalFetch = window.fetch;
window.fetch = function () {
console.log('[Hooked Fetch]', arguments[0], arguments[1]);
console.trace();
debugger;
return originalFetch.apply(this, arguments);
};
})();
用debugger强行把页面停在请求发出前,开发工具会切到调用栈顶部,然后一层层往外找签名生成的位置。这个过程虽然粗暴,但在代码极度混淆、比如变量名全部变成单字母的情况下,比人工翻代码高效得多。
3. 拿到加密入口后,如何干净地抠代码并跑通签名
3.1 两种路线的选择:执行环境优先还是代码自救
签名生成函数定位到以后,接下来的问题是怎么让它脱离浏览器跑起来。主流有两种路线。
路线A:Node直接加载打包后的完整业务代码,利用它自身的启动逻辑,只在最后调用签名函数。优点是省事,不用手工清理大量依赖,缺点是要加载一堆无关代码,运行慢,而且业务代码里可能有环境检测,启动时就开始报错。
路线B:把签名相关的模块单独抠出来,在Node里补一个简易浏览器环境。优点是体积小、干净、便于维护,缺点是需要手工处理依赖关系,并且得自己补各种浏览器环境变量。
我的建议是:先走B,因为生产环境稳定性优先。A适合快速验证签名能不能过,也就是“先跑通一次”的阶段;B适合长期运行,也就是要把签名服务做成接口给其他模块调用的时候。我在实际操作中基本都是先A后B,先用A验证整个流程,再用B替换成干净版本。
3.2 从webpack构建产物里导出模块
某红薯Web端的代码是webpack打包的,整体结构类似这样:
javascript复制!function (e) {
var t = {};
function n(r) {
if (t[r]) return t[r].exports;
var o = t[r] = { i: r, l: false, exports: {} };
return e[r].call(o.exports, o, o.exports, n), o.l = true, o.exports;
}
n(123);
}([...模块数组...]);
这种结构下,模块ID就是数组下标,模块之间的依赖通过n(模块ID)来引用。拿到签名模块后,往往不是单独一个函数,而是一串依赖链,可能涉及字符串处理、加解密工具、环境信息采集等好几个模块。
实操时,我习惯在控制台里改一下webpack启动逻辑,把模块表挂到全局,比如在代码最前面加一行window.__modules = e;,然后刷新页面,在控制台直接通过__modules[目标ID]调用,看看导出的是函数还是对象,再逐步展开依赖链。找到依赖后的处理方式是:把目标模块和它依赖的模块一起复制出来,放到Node里按同样的模块结构加载。这步没法偷懒,缺一个依赖就报一个错,报错就补一个,循环往复。
3.3 搭建一个最小的Node补环境
模块抠出来之后,最大的工程是补环境。因为签名逻辑里几乎一定会用到window、document、navigator、location这些浏览器全局对象。比如它可能读navigator.userAgent作为签名输入,如果读不到,生成的结果和浏览器里就不一致。
我常用的基础环境模板是用jsdom搭一个浏览器壳:
javascript复制const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://www.example.com/',
referrer: 'https://www.example.com/',
contentType: 'text/html',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
});
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
global.location = dom.window.location;
global.localStorage = dom.window.localStorage;
global.sessionStorage = dom.window.sessionStorage;
然后在这个环境里加载抠出来的签名模块,调用生成函数,打印结果。
补环境有个经验:不要一上来就引jsdom全家桶。很多环境变量其实用不到,可以用一个Proxy或空对象先糊弄过去,跑起来报错再按需补齐。比如代码里读window.screen.width,你用空对象会得到undefined,但有些算法对undefined也照样拼接,不影响结果。真正影响结果的只有那些参与拼接和计算的字段,需要逐个验证。
另一种做法是手写一个轻量stub,比如:
javascript复制global.window = {
navigator: { userAgent: 'xxx', platform: 'Win32', language: 'zh-CN' },
location: { href: 'https://www.example.com/', search: '' },
screen: { width: 1920, height: 1080 },
localStorage: { getItem: () => null, setItem: () => {} }
};
这种方式干净可控,没有jsdom的额外开销,也避免了jsdom自带的一些环境指纹特征。缺点是要手工维护的属性有点多,第一次搭可能要花小半天。
4. x-s算法的还原与模拟:从追码到出参
4.1 先判断算法长相
环境跑通、能稳定输出签名之后,下一步才是算法本身的还原。很多人一上来就盯着算法细节,方向反了。先观察x-s输出长什么样,能省很多事。
| 输出长度及特征 | 可能算法 |
|---|---|
| 32位十六进制字符串 | MD5或自研类MD5风格 |
| 40位十六进制字符串 | SHA1 / HmacSHA1 |
| 64位十六进制字符串 | SHA256 / HmacSHA256 |
包含+、/、= |
Base64或其变体 |
| 长度较短且每组请求变化 | 时间戳+随机数拼接,再做简单变换 |
某红薯Web端某个版本的x-s是64位大写十六进制字符串,基本可以确定是SHA256这一系的算法。但确定算法类型只是开始,真正的难点在后面。
4.2 在JS代码里定位加密函数
拿到算法方向后,回到JS代码里搜索加密函数特征。常见的搜索关键词有:createHash、createHmac、sha256、md5、btoa、Base64,以及它们的混淆变体。因为代码经过混淆,这些函数名可能是被分割的,比如create和Hash分在两个变量里再拼接,这时候用AST工具或者直接读压缩后的代码会容易一些。
找到加密函数后,最重要的事情不是背代码,而是搞清楚三个问题:
- 哪些字符串参与了拼接?
- 拼接顺序是什么?
- 盐值(secret)是从哪里来的?
盐值有时候是写死的字符串,有时候是从某个配置接口动态获取的,还有可能是从某个Cookie或localStorage里取的。我见过一个版本,盐值藏在页面HTML的一个自定义属性里,不抓HTML根本找不到。这种信息通常在浏览器里跑签名函数时,通过断点看变量面板就能追踪出来。
4.3 验证还原结果:先离线后在线
拿到拼接规则和盐值后,我在Python里做了个伪代码级别的模拟:
python复制import hashlib
import hmac
import time
def generate_x_s(path: str, body: str, timestamp: int, secret: str) -> str:
# 伪代码,实际拼串规则以JS逻辑为准
raw_string = f"{path}|{body}|{timestamp}|{secret}"
return hashlib.sha256(raw_string.encode()).hexdigest().upper()
这里要强调的是,上面的代码只是示意,不是真实算法。真实逻辑中可能还会对body做序列化、排序、去空格、转小写等操作,任何一个细节不一致都会造成结果不同。
验证分两步。第一步离线验证:固定一组输入,用浏览器里跑出来的签名和Node里跑出来的签名比对。一致说明代码抠对了。第二步在线验证:用签名生成服务跑一次真实请求,看服务端是否接受。如果离线一致但在线失效,十有八九是时间戳的问题,比如前端用毫秒,后端用秒,或者时间窗口只有几秒,你生成完再发送,中间经过了一两个代理延迟,窗口就过期了。
5. 从能出签名到稳定调用:环境检测、封装与高频坑位
5.1 过了签名校验不等于过了风控
这是我在整个实战中最想强调的一点。很多人以为拿到x-s的生成逻辑就等于畅通无阻了,实际远不是这样。签名校验只是第一道门,真正决定你能跑多久的是后面的风控体系。
| 风控维度 | 常见触发点 |
|---|---|
| 请求频率 | 同账号或同IP每秒/每分钟请求数超阈值 |
| 接口路径 | 刷核心数据接口更容易触发 |
| 账号状态 | 新注册、低活跃账号权重低 |
| 请求头一致性 | Cookie、User-Agent、x-s-common环境信息不匹配 |
| 行为特征 | 点击间隔、滚动行为不符合真人习惯 |
签名只解决“请求是合法的”这个问题,但服务端还会问:“这个请求是不是机器发出的?”而后面这个问题,签名是回答不了的。
5.2 环境检测的常见探针
既然x-s-common这类字段里塞了环境信息,那前端代码里一定有对应的采集逻辑。常见的探针包括:
navigator.plugins、navigator.platform、navigator.deviceMemorycanvas指纹、WebGL渲染器信息- 浏览器窗口尺寸、语言、时区
localStorage、sessionStorage里是否存在特定keyUser-Agent和Accept-Language是否匹配
我实际遇到的一个坑是:代码里藏了一个检查localStorage里某个key是否存在的探针,jsdom默认的localStorage是空的,结果签名模块启动时报错。手动把这个key补成一个固定值后,一切正常。
所以补环境的时候,不能只盯着签名函数本身,还要把签名模块依赖的环境探针一起过一遍。最稳妥的办法是逐个字段对比:在浏览器里执行Object.keys(navigator),拿到完整键列表,再在Node里补同样的值。
5.3 一个可以稳定复用的请求封装
签名服务跑通后,端到端调用我习惯封装成下面这种结构:
python复制import json
import time
import random
import requests
def build_headers(path: str, body: dict, timestamp: int, secret: str, cookie: str) -> dict:
body_str = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
x_s = generate_x_s(path, body_str, str(timestamp), secret)
return {
"x-s": x_s,
"x-t": str(timestamp),
"x-s-common": "",
"User-Agent": "Mozilla/5.0 ...",
"Cookie": cookie,
"Content-Type": "application/json;charset=UTF-8",
}
def fetch_with_retry(url: str, body: dict, headers: dict, max_retries: int = 3):
for i in range(max_retries):
resp = requests.post(url, json=body, headers=headers)
if resp.status_code == 200 and "rejected" not in resp.text:
return resp
time.sleep(random.uniform(1, 3) * (2 ** i))
raise RuntimeError("request still rejected after retries")
这个封装有一个关键点:json.dumps时要用ensure_ascii=False并指定separators,保证序列化结果和前端JSON.stringify尽量一致。很多签名不一致的问题都出在这里——前端把对象按插入顺序序列化,Python的字典也是按插入顺序,但格式不一样,ensure_ascii默认把中文转成\uXXXX,一个转义不同,签名就不同。
5.4 几个真实翻车案例
| 场景 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 签名生成后直接发送 | 偶发403 | 时间戳窗口只有几秒 | 签名与请求之间不要经过多层延迟,签名服务最好本地运行 |
| Python序列化请求体后签名 | 100%签名失败 | json.dumps默认格式和JSON.stringify不一致 |
统一序列化参数,保证顺序、分隔符、中英文一致 |
| 同一个x-s在多个请求里复用 | 请求被标记异常 | 签名和请求强绑定,不允许复用 | 每个请求单独生成签名 |
| 补环境时漏了canvas指纹 | 偶发掉签名,概率性问题 | 签名计算用了canvas输出作为随机因子 | 在浏览器里采集真实canvas值,固化到Node环境 |
还有一个隐藏很深的坑:有时候签名算法里会掺入随机数。也就是说,同一个输入内容,连续调用两次生成函数,输出可能不一样。这时候不能用“输出一致”来判断算法是否正确,而是要看服务端是否接受。这种带随机数的签名逻辑,通常还会在签名里附带一个随机数标记,服务端拿到后能反推校验。遇到这种情况,只能靠在线验证来兜底。
6. 写在最后:逆向的边界与“能跑就好”背后的责任
说实话,把x-s跑通的那一刻确实很爽,但冷静下来想,这只是整个数据采集链路里的一环。签名、补环境、算法还原,每一层都在和另一端的工程师斗智斗勇。技术层面能做的事情很多,但“技术上能做到”和“应该去做”是两码事。逆向分析用于学习、安全研究、接口调试,这些都是正常的技术活动,但如果是大规模采集用户数据、恶意刷接口、绕过平台规则去获取未授权数据,这就越界了。我在实际项目中,一直坚守几条底线:只采集自己有权访问的数据,严格控制请求频率,不批量抓取用户个人信息,遵守平台的robots规则和用户协议。
回到技术本身,我想说的是:x-s逆向的核心不在于那几句加密代码,而在于定位思路和排查方法。算法会变,盐值会换,环境探针会越来越多,但只要掌握了断点定位、模块抠取、环境补全、在线验证这套方法论,遇到任何新的签名机制,都能有一套完整的应对路径。这也是我写这篇文章的初衷——不是给你一条鱼,而是把钓鱼的路数完整摊开。以后某红薯再改版,你也不会慌。
