从浏览器到接口:一次典型 Web 逆向中请求签名算法的定位、还原与自动化复现
很多人第一次做 Web 逆向,最容易卡住的不是“请求发不出去”,而是“明明参数都一样,接口就是返回签名错误”。
这类问题的根源通常不在接口文档,而在浏览器里那段不起眼的前端代码:它会在发请求前,拼接参数、补时间戳、做编码、套哈希,最后生成一个签名字段。
这篇文章我想从一个典型实战路径来讲:怎么从浏览器网络请求出发,定位签名算法,如何还原它,以及怎样把它做成自动化脚本稳定复现。重点不是某个站点的私有实现,而是一套可迁移的方法论。
背景与问题
在现代 Web 应用里,请求签名通常承担几个作用:
- 防止参数被随意篡改
- 给后端一个“请求来自合法前端”的弱校验
- 增加接口滥用的门槛
- 配合时间戳、nonce 防重放
典型表现一般有这些:
- 请求里多了
sign、sig、token、auth、x-sign之类字段 - 同样的 URL 和参数,复制到 Postman 后返回
invalid sign - 请求头里带了动态值,比如
x-ts、x-nonce - 参数顺序一变,签名就失效
- 明文参数之外,还混入固定盐值、设备指纹、Cookie 字段
如果你只盯着 Network 面板去“抄请求”,成功率通常不高。因为签名算法往往依赖:
- 运行时变量:时间戳、随机数、页面状态
- 上下文对象:Cookie、本地存储、浏览器环境
- 编码细节:URL 编码、Unicode、排序规则
- 混淆代码:压缩、重命名、字符串表、控制流平坦化
所以真正有效的方式,不是硬猜,而是按链路拆解。
一个典型签名链路长什么样
先建立整体心智模型。浏览器里一次带签名的请求,通常会走这条路径:
flowchart TD
A[页面触发请求] --> B[收集业务参数]
B --> C[补充公共参数 ts nonce appKey]
C --> D[按规则排序/拼接]
D --> E[编码或序列化]
E --> F[哈希/加密生成 sign]
F --> G[写入请求头或请求体]
G --> H[发送到接口]
H --> I[服务端按同样规则验签]
逆向时要做的,就是把 D、E、F 这三步还原清楚,并确认 C 是否还依赖上下文。
核心原理
1. 请求签名本质上是“确定性字符串变换”
无论代码看起来多复杂,大多数签名逻辑最后都可以抽象成:
sign = HASH( canonical_string + secret_or_salt )
其中关键是 canonical_string,也就是“规范化后的待签名字符串”。常见生成规则有:
- 按参数名 ASCII 排序
- 忽略空值字段
- 排除
sign本身 - 拼接成
k1=v1&k2=v2... - 或直接拼 JSON 字符串
- 再附加固定盐值、版本号、路径等
比如:
appId=1001&nonce=abc123&query=phone&ts=1710000000 + secret
然后做:
- MD5
- SHA1 / SHA256
- HMAC-SHA256
- AES 后再 Base64
- 自定义字符映射或二次摘要
2. 真正难点常常不是哈希,而是“前置处理”
我自己踩过很多坑,最后发现问题不在 MD5 算错,而在下面这些小细节:
- 排序是按键名排序,还是
key=value整体排序 - 数字参数在前端被转成字符串
- 布尔值是
true/false还是1/0 undefined字段是否参与签名- URL 编码是签名前做,还是签名后做
- Unicode 字符串是否做了 UTF-8 编码
- 请求体 JSON 是否去空格、是否稳定键序
所以逆向时不能只找“用了什么算法”,还要找“算法输入到底是什么”。
3. 定位签名代码的几种高效入口
比起全局硬翻代码,我更推荐从这几个点切入:
入口 A:从 Network 反查调用栈
在浏览器开发者工具里,找到目标请求后看:
- Request Payload / Form Data
- Request Headers
- Initiator / Call Stack
如果能点到发起脚本,往往能直接看到请求封装函数,比如:
axios.interceptors.request.usefetchWrapperrequest(config)signParams(data)
入口 B:全局搜索可疑关键词
重点搜这些关键词:
signshamd5cryptononcetimestamptokenheadersinterceptors
如果代码混淆严重,还可以搜某个固定参数名,比如请求中的 x-sign。
入口 C:Hook 加断点
如果直接搜不到,最稳的办法是:
- 给
XMLHttpRequest.prototype.send window.fetchaxios请求拦截器
下断点,观察请求发出前的配置对象。
这时你通常能看到签名已经被写进 header 或 body,沿调用栈向上回溯,就能找到生成位置。
定位流程:从请求到算法
下面给出一个比较实用的定位流程。
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant N as 网络层
participant A as 接口服务
U->>P: 点击查询
P->>P: 组装业务参数
P->>S: 传入参数/时间戳/上下文
S-->>P: 返回 sign
P->>N: 发起请求(headers/body含sign)
N->>A: HTTP 请求
A-->>N: 验签通过/失败
N-->>P: 返回结果
实际操作建议这样做:
第一步:固定输入,减少变量
先让环境尽可能稳定:
- 使用同一个账号
- 固定查询词
- 保持 Cookie 不变
- 连续抓两次包,对比差异
如果只有 ts 和 sign 在变,说明签名很可能只依赖时间戳和固定参数。
如果 nonce、deviceId 也在变,那就要继续追踪这些动态来源。
第二步:对比多次请求,推断输入集合
比如抓到两组请求:
请求1:
query=book
page=1
ts=1710000001
sign=xxxx
请求2:
query=book
page=2
ts=1710000005
sign=yyyy
这时可以推断:
page大概率参与签名ts大概率参与签名- 若 Cookie 改变签名也变,则 Cookie 可能参与签名
第三步:在请求发送点断住
在 Console 中可临时注入 Hook:
(function () {
const rawFetch = window.fetch;
window.fetch = async function (...args) {
debugger;
console.log("fetch args:", args);
return rawFetch.apply(this, args);
};
})();
如果站点用的是 XHR:
(function () {
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
console.log("xhr body:", body);
debugger;
return rawSend.call(this, body);
};
})();
一旦断住,就看:
- 请求体里是否已出现签名
- headers 是在哪一步被补进去的
- 调用栈上方是否存在格式化或哈希函数
第四步:识别“规范化”函数和“摘要”函数
通常会看到两类函数:
-
参数整理函数
- 排序
- 过滤空字段
- 拼接字符串
-
摘要/加密函数
- 调用
CryptoJS.MD5 - 调用
CryptoJS.HmacSHA256 - 或调用自定义实现
- 调用
如果是混淆代码,也别慌。你要做的是先判断角色:
- 输入是对象,输出是字符串:多半是规范化
- 输入是字符串,输出是十六进制/Base64:多半是摘要
一个可运行的实战示例
下面我用一个简化但非常接近真实场景的例子来演示:
前端签名规则如下:
- 收集参数:
appId、query、page、ts、nonce - 过滤掉空值和
sign - 按键名升序排序
- 拼成
k=v&k=v - 末尾加盐值
&secret=demo_secret - 取 SHA256 十六进制小写
浏览器侧原始逻辑示意
function buildSign(params) {
const secret = "demo_secret";
const canonical = Object.keys(params)
.filter((k) => k !== "sign" && params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort()
.map((k) => `${k}=${String(params[k])}`)
.join("&");
return sha256(`${canonical}&secret=${secret}`);
}
实战代码(可运行)
下面分别给出浏览器端和 Python 自动化复现代码。
方案一:Node.js 复现签名
保存为 sign.js:
const crypto = require("crypto");
function buildCanonical(params) {
return Object.keys(params)
.filter((k) => k !== "sign" && params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort()
.map((k) => `${k}=${String(params[k])}`)
.join("&");
}
function buildSign(params, secret = "demo_secret") {
const canonical = buildCanonical(params);
const raw = `${canonical}&secret=${secret}`;
return crypto.createHash("sha256").update(raw, "utf8").digest("hex");
}
function buildPayload(query, page = 1) {
const payload = {
appId: "1001",
query,
page,
ts: Math.floor(Date.now() / 1000),
nonce: Math.random().toString(36).slice(2, 10),
};
payload.sign = buildSign(payload);
return payload;
}
const payload = buildPayload("book", 1);
console.log(payload);
运行:
node sign.js
方案二:Python 自动化请求
保存为 client.py:
import time
import random
import string
import hashlib
import requests
def random_nonce(n=8):
return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
def build_canonical(params: dict) -> str:
items = []
for k in sorted(params.keys()):
v = params[k]
if k == 'sign':
continue
if v is None or v == '':
continue
items.append(f"{k}={str(v)}")
return '&'.join(items)
def build_sign(params: dict, secret='demo_secret') -> str:
canonical = build_canonical(params)
raw = f"{canonical}&secret={secret}"
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def build_payload(query: str, page: int = 1) -> dict:
payload = {
'appId': '1001',
'query': query,
'page': page,
'ts': int(time.time()),
'nonce': random_nonce(),
}
payload['sign'] = build_sign(payload)
return payload
def main():
url = 'https://httpbin.org/post'
payload = build_payload('book', 1)
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/json'
}
resp = requests.post(url, json=payload, headers=headers, timeout=10)
print('request payload:', payload)
print('status:', resp.status_code)
print(resp.text)
if __name__ == '__main__':
main()
运行:
python client.py
这个例子虽然简单,但它已经覆盖了 Web 逆向里最常见的签名复现要素:
- 参数规范化
- 字段过滤
- 排序
- 字符串化
- 哈希摘要
- 自动补动态参数
如何验证你“还原对了”
不要一上来就直接写自动化。最稳的方式是分三层验证。
验证 1:中间字符串是否一致
先不要比最终 sign,先比待签名串。
比如你在浏览器断点里拿到:
appId=1001&nonce=abc123&page=1&query=book&ts=1710000000&secret=demo_secret
那你本地脚本生成的也必须逐字符一致。
只要这一步不一致,后面的哈希必然不一致。
验证 2:摘要输出是否一致
把同一份待签名串分别丢到:
- 浏览器里的原始函数
- 你自己的 Node/Python 实现
看输出是否完全一样。
验证 3:接口是否接受
最后再发真实请求。
如果本地算出的 sign 和浏览器一致,但接口还是报错,问题通常就转移到:
- Cookie
- Header
- Referer / Origin
- 时间戳有效期
- nonce 去重策略
- 请求体编码格式
常见坑与排查
这部分非常关键。我把实战里高频翻车点集中列一下。
1. 参数顺序错了
很多人会说:“参数一样啊。”
但签名不只看参数值,还看顺序。
排查方式:
print(build_canonical(payload))
和浏览器里的原始待签名串逐字比较。
2. 漏掉了隐藏参与字段
有些字段不在请求体里,却参与签名,比如:
- 当前路径
/api/search - 请求方法
POST User-Agent- Cookie 中的某个 token
- localStorage 里的设备 ID
排查思路:
- 看签名函数的参数来源
- 追踪它是否读取了
document.cookie、location.pathname、localStorage
3. JSON 序列化不一致
特别是请求体直接签 JSON 时,常见问题有:
- Python 字典默认键序与前端不同
- 是否带空格
- 中文是否转义
- 布尔值格式不同
例如浏览器可能签的是:
JSON.stringify(obj)
而你在 Python 里用了默认 json.dumps(obj),输出可能不同。
应当显式控制:
import json
raw = json.dumps(obj, separators=(',', ':'), ensure_ascii=False)
4. 时间戳位数错了
有的接口要秒级时间戳:
1710000000
有的要毫秒级:
1710000000123
还有的会把时间戳转成字符串再参与拼接。
看起来很小的差异,实际会让签名完全不同。
5. URL 编码时机错了
常见有三种:
- 先拼明文,再整体编码
- 每个 value 先编码,再拼接
- 签名用明文,请求发送时才编码
这个坑我真的踩过很多次。
最笨但最有效的办法就是:在浏览器签名前断点,直接看函数输入。
6. 混淆后看不懂算法
如果代码被混淆,不要试图“一眼读懂全文件”。建议这样拆:
- 先找到请求发起点
- 只追这一条调用链
- 给关键函数重命名
- 把中间返回值打印出来
- 必要时复制函数到本地逐步去壳
你不是在审计整个前端工程,而是在找“哪段代码决定了这个 sign”。
7. 浏览器环境依赖没补齐
有些签名函数在浏览器里能跑,到 Node 里就报错,因为它依赖:
windowdocumentnavigatoratob/btoaWeb Crypto API
这时有几种策略:
- 只抽取纯算法部分重写
- 用
jsdom补简化环境 - 用 Puppeteer 在真实浏览器上下文执行原函数
如果算法混淆重、环境耦合深,直接在浏览器上下文复用原 JS 往往更省事。
自动化复现的两种思路
实际项目里,一般是这两条路线。
flowchart LR
A[目标: 自动化复现签名] --> B[方案1: 纯本地还原]
A --> C[方案2: 浏览器上下文调用原函数]
B --> B1[速度快]
B --> B2[部署轻]
B --> B3[维护成本低]
B --> B4[但前期逆向更费劲]
C --> C1[适合强混淆]
C --> C2[兼容浏览器环境]
C --> C3[还原成本低]
C --> C4[但性能和稳定性要额外处理]
方案 1:纯算法还原
也就是把签名规则彻底抄出来,用 Python/Node 重写。
优点:
- 性能好
- 部署简单
- 易于批量化
- 不依赖真实浏览器
缺点:
- 前期分析成本高
- 页面改版后需要重新适配
适合:
- 签名逻辑相对稳定
- 算法清晰可重写
- 批量请求量较大
方案 2:复用前端原始函数
通过 Puppeteer、Playwright 等工具进入页面上下文,直接调用原站点加载后的签名函数。
优点:
- 对重混淆场景更友好
- 环境依赖问题少
- 适合快速验证
缺点:
- 资源开销大
- 速度慢
- 更容易受页面改版、风控、登录状态影响
适合:
- 算法强依赖浏览器环境
- 只是少量调用
- 需要先验证方案可行性
一个基于 Playwright 的自动化示例
如果你已经知道页面上有个可调用函数 window.signer.sign(payload),可以这么做。
保存为 playwright_sign.js:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const payload = {
appId: '1001',
query: 'book',
page: 1,
ts: Math.floor(Date.now() / 1000),
nonce: Math.random().toString(36).slice(2, 10)
};
const sign = await page.evaluate((data) => {
if (!window.signer || typeof window.signer.sign !== 'function') {
throw new Error('sign function not found');
}
return window.signer.sign(data);
}, payload);
payload.sign = sign;
console.log(payload);
await browser.close();
})();
这个模式的核心价值在于:
先证明你能稳定拿到正确 sign,再考虑是否要纯本地重写。
安全/性能最佳实践
这里的“最佳实践”,我更偏向工程落地,而不是单纯“能跑”。
1. 不要把签名逻辑写死成黑盒
建议把自动化脚本拆成几个明确模块:
- 参数采集
- 规范化
- 签名生成
- 请求发送
- 响应校验
这样一旦接口升级,你只需要替换签名部分,而不是重写整个脚本。
2. 保留中间态日志,但注意脱敏
最有价值的调试日志不是最终 sign,而是:
- 原始参数
- 规范化字符串
- 动态字段来源
- 请求头摘要
例如:
print({
'canonical': canonical,
'ts': payload['ts'],
'nonce': payload['nonce'],
'sign': payload['sign'][:8] + '...'
})
注意不要在日志里完整输出敏感 token、cookie、secret。
3. 做签名结果缓存要谨慎
如果签名依赖:
- 时间戳
- nonce
- 一次性 token
那就不适合长时间缓存。
能缓存的往往是:
- 固定盐值
- 公共参数模板
- 已编译好的 JS 运行环境
不要为了省几次哈希,把本来应该动态刷新的字段缓存掉。
4. 控制并发,别把自己送进风控
很多接口的签名校验和风控策略是绑定的。即使签名正确,也可能因为:
- 并发过高
- 请求间隔过短
- UA/代理不稳定
- Cookie 复用异常
而被判定为异常流量。
建议:
- 加随机抖动
- 按账号/IP 做并发隔离
- 做失败重试但限制次数
- 区分“签名错误”和“风控拦截”
5. 优先还原“规则”,不要沉迷“跑通一次”
能跑通一次不代表已经还原成功。
真正可靠的标志是:
- 多组参数都能通过
- 不同时间都能通过
- 换一台机器仍然能通过
- 中间规则可以解释清楚
如果你的方案只能在当前浏览器 tab 里偶尔成功一次,那大概率只是碰巧,而不是完成了复现。
6. 合法合规边界必须明确
请求签名逆向本身是技术分析行为,但在真实环境中,必须确保你的操作:
- 获得授权
- 用于安全研究、测试或自有系统联调
- 不触碰未授权数据
- 不破坏目标服务稳定性
- 不绕过法律与平台规则
这点不只是“形式上的提醒”,而是底线。
一个推荐的实操清单
如果你准备自己做一遍,可以按这个顺序:
- 抓到目标请求,记录 URL、方法、参数、headers
- 连续抓 2~3 次包,对比动态字段
- 在
fetch或xhr.send下断点 - 沿调用栈回溯到签名生成函数
- 区分“规范化函数”和“摘要函数”
- 打印中间待签名字符串
- 本地重写并对齐中间字符串
- 对齐最终 sign
- 发真实请求验证
- 封装成自动化脚本并加入日志与重试
如果第 7 步一直过不去,不要急着怀疑哈希算法,先回去检查:
- 排序
- 编码
- 空值过滤
- 动态字段来源
十次里有八次,问题都出在这里。
总结
从浏览器到接口,请求签名逆向的核心并不是“猜中某个加密算法”,而是把整个链路拆开:
- 先找到请求发出前的真实输入
- 再还原参数规范化规则
- 最后确认摘要算法与上下文依赖
实战里最有效的方法也很朴素:
- 从 Network 找请求
- 在发送点断住
- 沿调用栈回溯
- 打印中间态
- 先对齐待签名串,再对齐最终签名
- 最后做自动化封装
如果你是中级读者,我给一个最可执行的建议:
以后遇到签名错误,先别急着改哈希算法,先把浏览器里的“待签名原文”拿出来。
只要这一步拿到了,后面的问题基本就从“猜”变成“对齐”。
边界条件也要记住:当签名高度依赖浏览器环境、混淆很重、且页面变动频繁时,直接复用前端原函数往往比纯重写更划算;而当你需要高性能、稳定批量化时,完整还原规则才是最终解法。
说到底,Web 逆向里最重要的不是某个技巧,而是能不能把复杂问题拆成可验证的步骤。签名算法这件事,尤其如此。