从浏览器抓包到参数还原:中级开发者实战 Web 逆向中的接口签名分析与复现
很多中级开发者第一次接触 Web 逆向时,最大的困惑不是“怎么抓包”,而是:
- 包抓到了,但参数看不懂
- 看到了
sign、token、nonce,却不知道谁参与了计算 - 抄请求头发过去,结果还是
401、403、验签失败 - 浏览器里能请求成功,脚本里却复现不出来
我自己刚开始做这类分析时,也踩过很典型的坑:以为复制一份请求就够了,后来才发现,真正难的是还原参数生成链路,而不是“看到参数值”。
这篇文章我会按一个比较贴近实战的顺序,带你从浏览器抓包开始,一步步走到:
- 定位签名参数位置
- 分析签名参与字段
- 还原前端生成逻辑
- 用脚本完成接口复现
文章会以一个教学化的模拟案例来演示,代码可直接运行。重点不是某个网站的细节,而是你可以迁移到大多数 Web 接口签名分析中的方法。
背景与问题
现代 Web 应用为了防止接口被随意调用,常见会增加一层或多层校验:
- 时间戳
ts - 随机串
nonce - 摘要签名
sign - 登录态相关
token - 设备指纹、环境指纹
- 请求体加密、字段混淆、排序拼接
对于中级开发者来说,真正的挑战通常不是 HTTP 本身,而是这几个问题:
1. 参数到底是谁生成的?
有些参数来自:
- URL 查询串
- Cookie
- LocalStorage / SessionStorage
- JS 运行时动态计算
- Webpack 打包后的混淋函数
- 请求拦截器统一注入
2. 为什么“复制请求”不等于“复现请求”?
因为接口签名往往和这些因素绑定:
- 当前时间
- 登录态
- 请求路径
- 请求体内容
- Header 中的某些字段
- 参数顺序
只复制某一次请求,不理解计算逻辑,下一次就失效。
3. 为什么浏览器能成功,脚本失败?
典型原因有:
- 少了 Cookie 或 Authorization
- 请求体 JSON 序列化方式不一致
- Header 大小写、值格式不一致
- 参与签名的字符串排序错了
- Python 和 JS 对对象转字符串的行为不同
所以本文的核心不是“抓包工具怎么打开”,而是:如何把“抓到的结果”还原成“可重复生成的过程”。
前置知识与环境准备
如果你已经熟悉这些,可以直接跳到实战部分。
建议具备的基础
- 会使用浏览器开发者工具 Network 面板
- 了解 HTTP 请求结构
- 会一点 JavaScript 或 Python
- 知道哈希函数如 MD5 / SHA256 的基本用途
- 能接受“先猜测,再验证”的分析节奏
环境准备
本文示例使用:
- Chrome 浏览器
- Node.js 16+
- Python 3.9+
- 一个可运行的本地模拟服务
我们会构造一个“前端签名 + 后端验签”的最小案例,方便你理解真实站点里会发生什么。
分析全流程总览
先看全景,再进入细节。
flowchart TD
A[浏览器抓包] --> B[定位目标接口]
B --> C[识别关键参数 sign ts nonce]
C --> D[全局搜索参数名]
D --> E[定位发起请求代码]
E --> F[还原签名拼接规则]
F --> G[本地脚本复现]
G --> H[逐项校验请求差异]
H --> I[稳定调用接口]
这个流程里最容易卡住的是两段:
- 从参数到代码
- 从代码到可运行复现
后面我会重点讲这两步。
核心原理
Web 接口签名本质上是在做一件事:
把请求中的部分信息,按约定规则拼成一个字符串,再通过某种算法计算出摘要值。
后端收到请求后,使用同样规则重新计算,如果一致,就认为请求可信。
常见签名组成方式
一个常见的签名输入可能长这样:
method=POST&path=/api/search&ts=1690000000&nonce=abc123&body={"kw":"phone","page":1}&secret=xxxx
再经过:
- MD5
- SHA1
- SHA256
- HMAC-SHA256
- AES 后再摘要
- 多轮编码(URL encode / Base64 / Hex)
最终得到 sign。
常见参与字段
| 字段 | 作用 |
|---|---|
ts | 防重放,限制时间窗口 |
nonce | 防止相同请求重复使用 |
token | 绑定用户身份 |
path | 防止签名被用于其他接口 |
body | 绑定具体请求内容 |
secret | 前后端共享的隐藏常量或变种密钥 |
签名分析的思维模型
我建议把逆向签名分析拆成三层:
- 表象层:看到请求里有哪些字段
- 链路层:这些字段在哪段代码中被生成和注入
- 规则层:签名算法、排序规则、编码规则是什么
很多人卡在表象层:只盯着 Network 面板里的某个 sign 值,却不追到代码里去。
下面这个时序图可以帮助你建立“参数生成链”的感觉。
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant R as 请求拦截器
participant A as API服务端
U->>P: 点击搜索
P->>S: 传入 body/path/token
S->>S: 生成 ts/nonce/sign
S-->>P: 返回签名参数
P->>R: 发起请求
R->>R: 注入 Header/Cookie
R->>A: 发送完整请求
A->>A: 按同规则验签
A-->>R: 响应结果
R-->>P: 返回数据
实战案例:从抓包到参数还原
下面我们做一个完整的教学案例。
目标接口定义
假设前端会调用:
POST /api/search
Content-Type: application/json
{
"kw": "laptop",
"page": 1
}
同时会附带这些 Header:
X-TimestampX-NonceX-SignAuthorization
服务端验签规则为:
sign = SHA256(
method + "\n" +
path + "\n" +
ts + "\n" +
nonce + "\n" +
canonicalBody + "\n" +
token + "\n" +
secret
)
其中:
canonicalBody是按 key 排序后的 JSON 字符串token从Authorization: Bearer xxx中取出secret是前端代码里某个常量
这个规则在真实站点里可能更复杂,但分析方式基本类似。
第一步:浏览器抓包,先确认“有什么”
你在浏览器 Network 面板里,至少要记录这些信息:
1. 请求基础信息
- 请求方法:
POST - 请求路径:
/api/search - Host
- Query 参数
- 请求体原始内容
2. Header 重点字段
重点盯这些名字:
signx-signauthorizationx-timestampnoncex-request-id
3. 响应错误信息
后端报错经常会给你线索,比如:
signature expiredinvalid nonceinvalid bodysign verify failed
这些错误对排查非常有价值,不要只盯状态码。
第二步:在前端代码里定位签名生成点
搜索优先级建议
在 Sources 里优先搜索:
- 参数名:
X-Sign、sign - 时间戳字段:
X-Timestamp - 请求路径:
/api/search - 常见库调用:
axios.create、interceptors.request.use - 哈希关键词:
md5、sha256、CryptoJS
很多站点的签名不是在业务代码里直接生成,而是在请求拦截器里统一注入。
一个典型前端实现
下面是一段模拟的前端签名代码:
const SECRET = "demo_secret_2024";
function canonicalize(obj) {
const sortedKeys = Object.keys(obj).sort();
const result = {};
for (const key of sortedKeys) {
result[key] = obj[key];
}
return JSON.stringify(result);
}
async function sha256(text) {
const data = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
async function buildSign({ method, path, body, token }) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = Math.random().toString(36).slice(2, 10);
const canonicalBody = canonicalize(body);
const raw = [
method.toUpperCase(),
path,
ts,
nonce,
canonicalBody,
token,
SECRET
].join("\n");
const sign = await sha256(raw);
return { ts, nonce, sign };
}
如果你在真实项目里看到类似结构,基本就能开始还原了。
第三步:拆出“签名输入串”
这是整个分析中最关键的一步。
很多人会直接盯着哈希结果,但哈希是不可逆的。你真正要还原的是:
哈希之前的原始字符串长什么样。
我自己的经验
如果页面没做严重混淆,我通常优先想办法打印中间值:
- 在 DevTools 中打断点
- 覆盖函数返回值
- 在控制台 patch 原函数
- 直接查看请求拦截器的入参和出参
你一旦拿到原始输入串,剩下基本就是体力活。
示例:原始拼接串
比如某次请求实际拼接的是:
POST
/api/search
1720000000
ab12cd34
{"kw":"laptop","page":1}
user-token-001
demo_secret_2024
那你只要保证脚本里生成的字符串和它完全一致,签名就能对上。
这里的“完全一致”包括:
- 换行符是否是
\n - JSON 中字段顺序
- 布尔值/数字/空值格式
- 是否多了空格
- 路径是否包含域名
- 时间戳单位是秒还是毫秒
第四步:本地搭建一个可运行案例
为了把过程讲透,我们先写一个模拟服务端,再写客户端复现代码。
实战代码:Node.js 模拟服务端
保存为 server.js:
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const SECRET = "demo_secret_2024";
function canonicalize(obj) {
const sortedKeys = Object.keys(obj).sort();
const result = {};
for (const key of sortedKeys) {
result[key] = obj[key];
}
return JSON.stringify(result);
}
function sha256(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
function verifySign(req) {
const ts = req.header("X-Timestamp");
const nonce = req.header("X-Nonce");
const sign = req.header("X-Sign");
const auth = req.header("Authorization") || "";
const token = auth.replace(/^Bearer\s+/i, "");
if (!ts || !nonce || !sign || !token) {
return { ok: false, msg: "missing required headers" };
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) {
return { ok: false, msg: "signature expired" };
}
const canonicalBody = canonicalize(req.body || {});
const raw = [
req.method.toUpperCase(),
req.path,
ts,
nonce,
canonicalBody,
token,
SECRET
].join("\n");
const expected = sha256(raw);
if (expected !== sign) {
return {
ok: false,
msg: "sign verify failed",
debug: { raw, expected, sign }
};
}
return { ok: true };
}
app.post("/api/search", (req, res) => {
const result = verifySign(req);
if (!result.ok) {
return res.status(403).json(result);
}
const { kw, page } = req.body;
res.json({
code: 0,
data: {
list: [
{ id: 1, title: `${kw}-result-${page}-1` },
{ id: 2, title: `${kw}-result-${page}-2` }
]
}
});
});
app.listen(3000, () => {
console.log("server running at http://localhost:3000");
});
安装依赖并启动:
npm init -y
npm install express
node server.js
实战代码:Node.js 客户端复现签名请求
保存为 client.js:
const crypto = require("crypto");
const SECRET = "demo_secret_2024";
function canonicalize(obj) {
const sortedKeys = Object.keys(obj).sort();
const result = {};
for (const key of sortedKeys) {
result[key] = obj[key];
}
return JSON.stringify(result);
}
function sha256(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
function buildHeaders({ method, path, body, token }) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = Math.random().toString(36).slice(2, 10);
const canonicalBody = canonicalize(body);
const raw = [
method.toUpperCase(),
path,
ts,
nonce,
canonicalBody,
token,
SECRET
].join("\n");
const sign = sha256(raw);
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign
};
}
async function main() {
const method = "POST";
const path = "/api/search";
const body = { kw: "laptop", page: 1 };
const token = "user-token-001";
const headers = buildHeaders({ method, path, body, token });
const resp = await fetch(`http://localhost:3000${path}`, {
method,
headers,
body: JSON.stringify(body)
});
const data = await resp.json();
console.log("status:", resp.status);
console.log(data);
}
main().catch(console.error);
执行:
node client.js
如果一切正常,你会拿到成功响应。
用 Python 复现同一套签名
真实工作里,浏览器分析完之后,很多人会用 Python 落地脚本。这里给一个可运行版本。
保存为 client.py:
import time
import json
import hashlib
import random
import string
import requests
SECRET = "demo_secret_2024"
def canonicalize(obj: dict) -> str:
ordered = {k: obj[k] for k in sorted(obj.keys())}
return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def build_headers(method: str, path: str, body: dict, token: str) -> dict:
ts = str(int(time.time()))
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
canonical_body = canonicalize(body)
raw = "\n".join([
method.upper(),
path,
ts,
nonce,
canonical_body,
token,
SECRET
])
sign = sha256(raw)
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign
}
def main():
method = "POST"
path = "/api/search"
body = {"kw": "laptop", "page": 1}
token = "user-token-001"
headers = build_headers(method, path, body, token)
resp = requests.post(
f"http://localhost:3000{path}",
headers=headers,
data=json.dumps(body, separators=(",", ":"), ensure_ascii=False)
)
print("status:", resp.status_code)
print(resp.text)
if __name__ == "__main__":
main()
安装依赖并运行:
pip install requests
python client.py
逐步验证清单
如果你已经拿到某个真实站点的请求,但暂时复现不出来,我建议按这个清单一项项验证。
flowchart LR
A[请求失败] --> B{时间戳正确?}
B -- 否 --> B1[检查秒/毫秒与时区]
B -- 是 --> C{请求体序列化一致?}
C -- 否 --> C1[统一 JSON key 顺序与空格]
C -- 是 --> D{签名原串一致?}
D -- 否 --> D1[打印 raw 串逐字符比对]
D -- 是 --> E{Token/Cookie 正确?}
E -- 否 --> E1[补齐登录态]
E -- 是 --> F{请求头完整?}
F -- 否 --> F1[补齐拦截器注入字段]
F -- 是 --> G[检查算法与编码细节]
这张图很重要,因为很多问题不是算法错,而是“前置条件没满足”。
常见坑与排查
这一节我会讲一些非常常见、而且足够折磨人的问题。
坑 1:JSON 看起来一样,签名却不一样
比如:
{"kw":"laptop","page":1}
和:
{"page":1,"kw":"laptop"}
从业务上看没区别,但如果签名对原始字符串做摘要,顺序不同,结果就完全不同。
排查方式
- 前端里找到真正序列化 body 的函数
- 看它有没有
sort() - Python 里使用
separators=(",", ":") - 避免默认序列化带空格
坑 2:时间戳单位错了
有的接口要秒:
1720000000
有的要毫秒:
1720000000123
你如果把毫秒当秒传,后端一般会报过期;反过来也一样。
排查方式
看浏览器中真实值长度:
- 10 位,大概率秒
- 13 位,大概率毫秒
坑 3:路径参与签名,但你用了完整 URL
很多签名规则只用:
/api/search
而不是:
https://example.com/api/search
这个细节很容易忽略。
排查方式
看前端传给签名函数的参数到底是 url、pathname,还是拼接后的完整地址。
坑 4:Authorization 看似没参与,实际上参与了
不少接口虽然把 token 放在 Header,但签名函数会从 Header 再拿一遍 token 参与计算。
排查方式
检查:
Authorization- Cookie 中的 session
- LocalStorage 中的 token
- 请求拦截器是否把 token 传给签名函数
坑 5:浏览器请求成功,Python 仍失败
最常见不是算法问题,而是请求发送方式不一致。
比如:
- 浏览器发的是
application/json - 你 Python 发成了
form-data - 或者
requests.post(json=body)与前端实际发送格式不同
排查方式
和浏览器逐项比对:
Content-Type- body 原始字节
- Cookie
- Header 中的自定义字段
- 是否走了重定向
坑 6:签名算法定位对了,但值还是不一致
这时要怀疑“输入字符串中含有你没注意到的预处理”。
真实项目里常见:
- 先
encodeURIComponent - 再 Base64
- 再做 SHA256
- 或字符串先转小写/大写
- 数字字段被转成字符串
- 空值字段被过滤掉
排查方式
最有效的方法是:
在浏览器中打印每一步中间结果,而不是只看最终 sign。
进阶定位技巧
如果站点代码是打包压缩过的,可以这样拆。
技巧 1:从请求拦截器逆推
很多项目使用 axios:
axios.interceptors.request.use((config) => {
// 注入 ts, nonce, sign
return config;
});
你可以直接在这里下断点,观察:
config.urlconfig.dataconfig.headers- 签名函数调用位置
这通常比在全局搜 sha256 更快。
技巧 2:hook 哈希函数
如果站点使用 CryptoJS.SHA256 或自定义 md5 函数,你可以在控制台临时 hook:
const oldDigest = crypto.subtle.digest;
crypto.subtle.digest = async function(algo, data) {
const text = new TextDecoder().decode(data);
console.log("digest input:", algo, text);
return oldDigest.call(this, algo, data);
};
这样就能看到哈希前的输入。
注意:这类 hook 在某些场景可能会被 CSP、只读属性或运行时封装影响,不一定总能成功,但值得试。
技巧 3:搜索固定常量
如果你发现 sign 总和某个固定盐值有关,可以搜索:
- 特定字符串片段
- Header 名字
- API path
secret相关常量
很多时候你找不到函数名,但能通过常量把代码块揪出来。
安全/性能最佳实践
这部分不只是“怎么复现”,还包括“怎么做得更稳”。
1. 不要把单次抓包结果当成最终答案
抓到一个请求只能说明那一次成功了,不代表规则已掌握。
更靠谱的做法是做两次或三次对比,观察:
- 哪些字段固定不变
- 哪些字段每次都变
- 哪些字段只在 body 改变时变化
2. 建立“最小可复现脚本”
我强烈建议在分析早期就写一个最小脚本,只做三件事:
- 生成参数
- 发请求
- 打印原始签名串和响应
不要一开始就堆业务逻辑。先把签名跑通,再扩展。
3. 对中间结果做日志留存
建议至少打印:
- 参与签名的原始字符串
- 最终 sign
- 实际发送的 body
- 关键 Header
这样以后接口变更时,你能快速知道变的是哪一层。
4. 控制请求频率,避免误伤服务
即便是测试接口复现,也不要高频轰炸:
- 增加重试间隔
- 做并发限制
- 遵守目标系统的访问边界和合规要求
5. 注意敏感信息保护
在日志和代码仓库中,不要直接暴露:
- token
- cookie
- 用户标识
- 真实 secret
- 生产接口地址
建议:
- 用环境变量存储凭据
- 打日志时做脱敏
- 区分测试与生产配置
一个实用的分析模板
当你面对新的目标站点时,可以按下面这个表来记录,效率会高很多。
| 项目 | 记录内容 |
|---|---|
| 请求方法 | POST / GET |
| 请求路径 | /api/xxx |
| 请求体格式 | JSON / Form / Query |
| 动态字段 | ts、nonce、sign |
| 登录态来源 | Cookie / Authorization |
| 签名算法 | SHA256 / MD5 / 未知 |
| 拼接顺序 | method + path + … |
| 编码处理 | URL encode / Base64 / Hex |
| 校验窗口 | 5 分钟 / 60 秒 |
| 可复现状态 | 已跑通 / 待确认 |
这类记录很朴素,但在多次迭代时特别有用。
边界条件与合规提醒
这篇文章讨论的是签名分析与参数还原的方法论,适用于:
- 自有系统联调排障
- 授权测试环境分析
- 安全研究与防护验证
- 学习前端请求链路与接口安全机制
不适用于未授权的数据抓取、绕过访问控制或违反目标系统使用规则的场景。
这点非常重要,技术能力和使用边界要一起建立。
总结
从浏览器抓包到参数还原,真正关键的不是“会不会复制请求”,而是能不能建立这套分析闭环:
- 抓到完整请求
- 定位动态参数来源
- 还原签名输入串
- 在脚本中一致地重建请求
- 通过逐项比对修正差异
你可以把这篇文章中的方法总结成一句话:
Web 接口签名分析,本质上是在找“请求生成过程”,而不是在猜“参数值”。
如果你已经是中级开发者,我建议你在真实工作里优先养成两个习惯:
- 每次都打印签名原串,不只打印 sign
- 每次都先做最小可复现,再接业务脚本
这两个习惯,会让你少走很多弯路。我自己后来做这类问题时,基本都靠这套方法稳定落地。
如果你现在手头正好有一个“浏览器能成功、脚本却失败”的接口,不妨按本文的流程重走一遍。很多时候,问题不在算法本身,而是在某个你以为“不重要”的细节上。