从抓包到补环境:中级开发者实战 Web 逆向中的签名参数还原与请求重放
很多中级开发者第一次接触 Web 逆向时,都会卡在同一个点:接口明明抓到了,请求参数也看到了,但一重放就报错。
常见报错包括:
sign invalidtimestamp expiredrequest forbiddenillegal tokenenv check failed
这篇文章我想用一种“带你走一遍”的方式,讲清楚一个很核心的实战路径:
先抓包定位关键请求,再分析签名生成链路,最后通过补环境或脱离浏览器重放请求。
重点不是讲某个站点的细节,而是总结一套中级开发者能真正落地的方法论和代码框架。
背景与问题
在现代 Web 应用里,前端请求通常不只是简单地带上查询参数。服务端为了防刷、鉴权、风控,会在请求中加入一些动态参数,比如:
- 时间戳
ts - 随机串
nonce - 摘要签名
sign - 设备信息指纹
fingerprint - token / session
- 某些加密后的业务参数
这就导致一个典型现象:
- 你用浏览器打开页面;
- 在 DevTools 或抓包工具里看到某个接口;
- 复制成 cURL 或 Python 请求;
- 结果服务端不认。
根因通常不是“接口地址错了”,而是以下几类问题:
- 签名参数依赖当前时间
- 签名串拼接顺序有要求
- 签名算法藏在前端 JS 中
- 执行过程中读取了浏览器环境对象
- 请求头、Cookie、Referer、Origin 参与了签名
- 某些值来自异步初始化流程,不在单个函数参数里
我自己踩坑最多的一次,不是算法难,而是少补了一个 window.navigator.userAgent,结果签名函数能跑,但生成值始终不对。这个阶段很容易误判成“算法还没还原”。
所以做 Web 逆向,不能只盯着某个加密函数,要看完整链路。
前置知识与环境准备
如果你已经会基本抓包,可以直接跳到实战部分;如果没有,建议先确认下面这些准备项。
你需要具备的基础
- 会用浏览器 DevTools 看 Network / Sources
- 能读懂基本 JavaScript
- 知道 Cookie、Header、XHR / Fetch 的区别
- 能用 Python 发 HTTP 请求
- 理解哈希、HMAC、AES 这些常见术语,但不要求精通密码学
建议工具
- 浏览器 DevTools
- Charles / Fiddler / mitmproxy 任选其一
- Node.js
- Python 3
- 一个能全局搜索 JS 的编辑器,比如 VS Code
本文演示环境
为了让代码可运行,我用一个最小可复现案例来模拟真实站点行为:
- 前端会生成
ts、nonce、sign sign依赖参数排序、请求路径和一个“浏览器环境值”- 服务端会校验签名是否正确
这样你能在本地把思路跑通,再迁移到真实目标。
核心原理
先讲清楚问题模型,再写代码。
1. 签名参数通常由什么组成
在大量 Web 接口里,签名大致遵循这个模式:
sign = hash(请求路径 + 排序后的业务参数 + 时间戳 + nonce + secret/env)
也就是说,一个签名函数往往包含四层输入:
- 固定输入:接口路径、方法名
- 业务输入:分页参数、查询词、ID 等
- 动态输入:时间戳、随机串
- 环境输入:UA、screen、token、cookie、本地存储值
2. 为什么“复制请求”经常失败
因为你复制的是“结果”,不是“生成过程”。
例如你抓到这样的请求:
POST /api/search
ts=1684290000
nonce=ab12cd34
sign=8f5a...
几秒钟后再发一次,ts 已经过期;或者服务端会重新根据请求体和当前 Cookie 计算签名,你之前抓到的 sign 自然就失效了。
3. Web 逆向里常见的两条路径
路径 A:直接调用前端原始签名函数
适合情况:
- 已经定位到签名函数
- 混淆不重
- 环境依赖少
- 可以在浏览器控制台或 Node 中补齐对象
优点:
- 还原速度快
- 逻辑偏稳定
- 不容易拼错细节
缺点:
- 依赖环境补全
- 前端升级后容易失效
路径 B:重写签名逻辑
适合情况:
- 算法明确,例如 MD5/HMAC/SHA256
- 参数拼接规则已搞清楚
- 原函数混淆严重、不好直接跑
优点:
- 代码更干净
- 更适合自动化
缺点:
- 容易漏细节
- 一旦顺序、编码、空值处理错了,就全错
从抓包到还原的整体流程
下面这个流程图,基本覆盖了中级开发者在实战里的主线。
flowchart TD
A[抓包定位目标接口] --> B[确认请求方法/头/体/响应]
B --> C[识别动态参数 ts nonce sign]
C --> D[全局搜索 sign/ts/nonce 关键词]
D --> E[定位调用栈与签名函数]
E --> F{依赖浏览器环境吗}
F -- 否 --> G[直接抽离函数到 Node]
F -- 是 --> H[补 window/document/navigator 等环境]
G --> I[生成签名并重放请求]
H --> I
I --> J{返回正常吗}
J -- 否 --> K[排查编码/排序/时间漂移/请求头]
J -- 是 --> L[封装自动化脚本]
抓包分析:先确定你要逆向的是“哪一层”
很多人一上来就搜索 sign,其实容易迷路。更稳妥的方法,是先在 Network 面板里做三件事。
第一步:锁定真正的业务请求
不要被这些内容干扰:
- 静态资源请求
- 埋点上报
- 预加载接口
- 轮询保活接口
重点找:
- 触发某个明确业务动作后才发出的请求
- 响应里确实包含你想拿的数据
- 每次操作时
sign、ts发生变化
第二步:对比两次请求差异
同样的操作做两遍,比较:
- Query String 是否变了
- Request Payload 是否变了
- Header 是否多了动态字段
- Cookie 是否更新
- 请求顺序是否有前置依赖
如果两次请求中,只有 ts、nonce、sign 不同,那说明逆向范围相对清晰。
第三步:看 Initiator / 调用栈
浏览器 DevTools 里经常能看到请求由哪段 JS 发起。继续点进去,往往能看到:
- axios/fetch 封装
- 请求拦截器
- 统一签名函数
- 参数序列化逻辑
这一步特别关键,因为很多站点的签名不是在业务页面代码里生成,而是在请求拦截器里统一注入。
核心原理拆解:签名生成链路
下面用一个时序图表示从用户点击到服务端校验签名的过程。
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant B as 浏览器环境
participant API as 服务端接口
U->>P: 点击搜索/翻页
P->>S: 传入 path + params
S->>B: 读取 UA/token/时间戳/随机值
B-->>S: 返回环境数据
S-->>P: 生成 sign/ts/nonce
P->>API: 发送带签名请求
API->>API: 按相同规则校验签名
API-->>P: 返回业务数据或错误
这里最容易被忽略的是:
签名函数不一定只依赖它的入参,它可能隐式读取环境。
例如:
localStorage.getItem("token")navigator.userAgentdocument.cookielocation.pathnamewindow._appConfig.secret
如果你只把某个函数复制出来运行,多半会报错,或者运行了但结果不对。
实战代码(可运行)
下面我们构造一个本地可运行的示例,包括:
- 一个模拟服务端校验签名的 Flask 服务
- 一个模拟前端签名逻辑的 Node 脚本
- 一个 Python 重放请求脚本
这样你能完整体验“抓包参数 → 还原签名 → 请求重放”的过程。
第 1 步:模拟服务端
创建 server.py:
from flask import Flask, request, jsonify
import hashlib
import time
app = Flask(__name__)
SERVER_SECRET = "my_server_secret"
ALLOWED_TIME_DRIFT = 10 # 秒
def canonical_string(path: str, params: dict, ts: str, nonce: str, ua: str) -> str:
filtered = {k: v for k, v in params.items() if k not in ("sign",)}
sorted_items = sorted(filtered.items(), key=lambda x: x[0])
query = "&".join(f"{k}={v}" for k, v in sorted_items)
return f"{path}|{query}|{ts}|{nonce}|{ua}|{SERVER_SECRET}"
def make_sign(path: str, params: dict, ts: str, nonce: str, ua: str) -> str:
raw = canonical_string(path, params, ts, nonce, ua)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
@app.route("/api/search", methods=["GET"])
def search():
args = request.args.to_dict()
ts = args.get("ts", "")
nonce = args.get("nonce", "")
sign = args.get("sign", "")
keyword = args.get("keyword", "")
page = args.get("page", "1")
ua = request.headers.get("User-Agent", "")
if not ts or not nonce or not sign:
return jsonify({"code": 4001, "msg": "missing sign params"}), 400
try:
ts_int = int(ts)
except ValueError:
return jsonify({"code": 4002, "msg": "invalid ts"}), 400
now = int(time.time())
if abs(now - ts_int) > ALLOWED_TIME_DRIFT:
return jsonify({"code": 4003, "msg": "timestamp expired"}), 403
expected = make_sign(
"/api/search",
{"keyword": keyword, "page": page, "ts": ts, "nonce": nonce},
ts,
nonce,
ua
)
if sign != expected:
return jsonify({"code": 4004, "msg": "sign invalid"}), 403
return jsonify({
"code": 0,
"msg": "ok",
"data": {
"keyword": keyword,
"page": int(page),
"items": [
{"id": 1, "title": f"{keyword}-result-1"},
{"id": 2, "title": f"{keyword}-result-2"}
]
}
})
if __name__ == "__main__":
app.run(port=5000, debug=True)
安装并运行:
pip install flask
python server.py
第 2 步:模拟前端签名逻辑
创建 sign.js:
const crypto = require("crypto");
function getBrowserEnv() {
return {
navigator: {
userAgent: "Mozilla/5.0 DemoBrowser/1.0"
}
};
}
function sortParams(params) {
return Object.keys(params)
.sort()
.map((k) => `${k}=${params[k]}`)
.join("&");
}
function makeSign(path, params) {
const env = getBrowserEnv();
const ua = env.navigator.userAgent;
const secret = "my_server_secret";
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = Math.random().toString(36).slice(2, 10);
const fullParams = {
...params,
ts,
nonce
};
const query = sortParams(fullParams);
const raw = `${path}|${query}|${ts}|${nonce}|${ua}|${secret}`;
const sign = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
return {
...fullParams,
sign,
ua
};
}
if (require.main === module) {
const result = makeSign("/api/search", {
keyword: "python",
page: "1"
});
console.log(JSON.stringify(result, null, 2));
}
module.exports = {
makeSign
};
运行:
node sign.js
你会看到类似输出:
{
"keyword": "python",
"page": "1",
"ts": "1711111111",
"nonce": "abc123ef",
"sign": "xxxx...",
"ua": "Mozilla/5.0 DemoBrowser/1.0"
}
第 3 步:请求重放
创建 replay.py:
import subprocess
import json
import requests
def get_signed_params():
result = subprocess.check_output(["node", "sign.js"], text=True)
return json.loads(result)
def replay():
signed = get_signed_params()
params = {
"keyword": signed["keyword"],
"page": signed["page"],
"ts": signed["ts"],
"nonce": signed["nonce"],
"sign": signed["sign"],
}
headers = {
"User-Agent": signed["ua"]
}
resp = requests.get("http://127.0.0.1:5000/api/search", params=params, headers=headers, timeout=10)
print("status:", resp.status_code)
print(resp.text)
if __name__ == "__main__":
replay()
安装依赖并运行:
pip install requests
python replay.py
返回正常时应该类似:
{
"code": 0,
"msg": "ok",
"data": {
"keyword": "python",
"page": 1,
"items": [
{"id": 1, "title": "python-result-1"},
{"id": 2, "title": "python-result-2"}
]
}
}
到这里,一个完整闭环就跑通了。
第 4 步:从“可运行 demo”映射到真实站点
真实站点当然不会把签名逻辑写得这么直白,但你可以按下面这套方式迁移。
1. 找关键词
在 Sources 里全局搜索:
signsha256md5noncetimestampinterceptors.requestheaders.commonfetch(/axios.create(
如果站点经过混淆,关键词不一定直接出现,这时可以反向找:
- 请求 URL 片段
- 某个固定请求头名
- 响应报错文案,如
sign invalid
2. 断点看实参
比起静态硬读代码,我更推荐直接在可疑函数下断点,看:
- 入参值
- 局部变量
- 最终拼接字符串
- 返回值
如果能看到“签名原文串”,难度会瞬间下降一个量级。
3. 优先抽离“最小签名路径”
不要试图一口气搬完整前端工程。更好的方法是:
- 先把签名函数所在模块单独提出来
- 补齐它真正依赖的最少环境
- 保证能在 Node 中返回正确签名
- 再接入自动化请求
这一步的关键不是“代码多完整”,而是“依赖足够小”。
补环境:什么时候必须做
有些前端签名逻辑在浏览器里能跑,复制到 Node 里就报这些错:
window is not defineddocument is not definednavigator is not definedlocation is not definedlocalStorage is not defined
这就是典型的“需要补环境”。
常见依赖对象
| 对象 | 常见用途 |
|---|---|
window | 全局挂载配置、SDK |
document | Cookie、DOM 属性 |
navigator | UA、语言、平台 |
location | 当前路径、域名 |
screen | 分辨率、颜色深度 |
localStorage | token、设备 ID |
sessionStorage | 会话态参数 |
一个最小补环境示例
创建 env_sign.js:
const crypto = require("crypto");
global.window = {};
global.document = {
cookie: "sessionid=demo123; token=abcxyz"
};
global.navigator = {
userAgent: "Mozilla/5.0 DemoBrowser/1.0",
language: "zh-CN"
};
global.location = {
pathname: "/search",
origin: "https://example.com"
};
global.localStorage = {
store: {
token: "abcxyz"
},
getItem(key) {
return this.store[key] || null;
}
};
function getTokenFromCookie() {
const cookie = document.cookie || "";
const match = cookie.match(/token=([^;]+)/);
return match ? match[1] : "";
}
function makeSignWithEnv(path, params) {
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = "fixed1234";
const token = localStorage.getItem("token") || getTokenFromCookie();
const ua = navigator.userAgent;
const fullParams = { ...params, ts, nonce };
const query = Object.keys(fullParams)
.sort()
.map((k) => `${k}=${fullParams[k]}`)
.join("&");
const raw = `${path}|${query}|${token}|${ua}`;
const sign = crypto.createHash("md5").update(raw, "utf8").digest("hex");
return {
...fullParams,
sign
};
}
console.log(makeSignWithEnv("/api/search", { keyword: "node", page: "2" }));
这个例子虽然简单,但说明了一件实战里很重要的事:
补环境不是为了“伪造浏览器”,而是为了让目标函数顺利拿到它依赖的值。
补环境依赖关系图
下面这张图能帮助你判断:签名不对时,应该补哪一类依赖。
classDiagram
class SignFunction {
+makeSign(path, params)
}
class Params {
+keyword
+page
+ts
+nonce
}
class BrowserEnv {
+navigator.userAgent
+document.cookie
+location.pathname
+localStorage.getItem()
}
class AppConfig {
+secret
+appId
}
SignFunction --> Params
SignFunction --> BrowserEnv
SignFunction --> AppConfig
逐步验证清单
真实项目里,我很少一上来就“全量重放”。更稳妥的方法是逐项验证。
验证 1:确认是否为纯摘要算法
先观察输出特征:
- 32 位十六进制:常见 MD5
- 40 位:常见 SHA1
- 64 位:常见 SHA256
- Base64:可能是加密结果或 HMAC 输出
这只是经验判断,不绝对,但能缩小范围。
验证 2:确认参数排序规则
重点看是否:
- 按 key 字典序排序
- 排除空值
- 排除
sign本身 - 对数组、对象做 JSON 序列化
- URL 编码前签名还是编码后签名
这一点错了,结果几乎必错。
验证 3:确认时间戳单位
常见有三种:
- 秒:
1684290000 - 毫秒:
1684290000123 - 自定义格式:如 ISO 时间、格式化字符串
别小看这个问题,我见过很多签名算法完全还原了,最后只是秒和毫秒搞反。
验证 4:确认是否参与 Header
有些签名虽然放在 Query 里,但计算时还包含:
User-AgentOriginRefererX-Requested-With- 自定义 header,比如
x-app-id
验证 5:确认是否有前置接口
比如:
- 先请求配置接口拿动态公钥
- 先执行 challenge 获取一次性 token
- 先加载某段 wasm 再算签名
如果漏了前置步骤,你看到的签名函数可能只是最后一层。
常见坑与排查
这一节我尽量讲得“像排错现场”一点。
坑 1:签名函数找对了,但结果总差一点
表现:
- 自己算出来的签名和浏览器里的只差一点点
- 每次都不一样,但规律类似
重点排查:
- 参数排序是否一致
- 是否把
sign自己也算进去了 - 空字符串参数是否参与
- 数字和字符串是否混用
- URL 编码顺序是否正确
例如这两种字符串,看起来差不多,结果完全不同:
page=1&keyword=python
keyword=python&page=1
坑 2:Node 能跑,签名不对
表现:
- 没报错
- 输出也有值
- 但服务端一直说
sign invalid
重点排查环境值:
navigator.userAgent是否一致- Cookie 是否完整
- localStorage 中的 token 是否同步
- 当前路径
location.pathname是否一致
这类问题最隐蔽,因为函数“能运行”会让人误以为逻辑已经对了。
坑 3:请求重放时偶发成功、偶发失败
表现:
- 有时候能过,有时候超时或验签失败
重点排查:
- 时间戳窗口太短
- 签名生成后到发请求间隔太久
- 并发下 nonce 重复
- 复用了过期 Cookie / token
建议把“生成签名”和“发送请求”尽量放到同一个短链路里。
坑 4:浏览器里有值,脚本里没有值
表现:
- 浏览器调试时能看到 token
- 脚本执行时
undefined
原因通常是:
- 值在闭包里,不在全局
- 异步初始化尚未完成
- webpack 模块作用域隔离
- 被 getter 动态计算
排查方法:
- 在调用点断点,不只看定义点
- 观察运行时对象而不是源代码猜测
- 直接打印最终参与签名的原文串
坑 5:请求参数正确,但还是被风控
表现:
- 签名校验通过了
- 仍然返回空数据、验证码、403
这说明问题已经不只是签名,还可能涉及:
- IP 风控
- 请求频率限制
- 设备指纹
- TLS 指纹
- 行为轨迹校验
这时候要明确边界:签名还原成功,不等于整个风控链路都绕过了。
安全/性能最佳实践
这一部分很重要。能跑只是第一步,稳定、可控、合规才是长期方案。
1. 不要硬编码敏感信息到仓库
例如:
- Cookie
- token
- secret
- 代理账号密码
建议:
- 用环境变量读取
- 本地
.env管理 - 不要提交到 Git
示例:
import os
API_TOKEN = os.getenv("API_TOKEN", "")
2. 将签名逻辑和请求逻辑解耦
不要把所有东西揉在一个函数里。建议分层:
signer:只负责签名client:只负责发请求parser:只负责解析数据
这样当签名规则变化时,只改一个点。
3. 控制重放频率
即使你成功还原签名,也不要高频并发重放。否则很容易触发:
- 封 IP
- 封账号
- 动态升级风控
建议:
- 加随机 sleep
- 做指数退避重试
- 控制并发数
示例:
import time
import random
time.sleep(random.uniform(0.5, 1.5))
4. 给关键步骤打日志
实战里最值得记录的不是“请求成功了”,而是这些内容:
- 签名前原文串
- 最终 sign
- 请求时间
- 响应状态码
- 响应错误码
- 关键 header
这样出现问题时能快速对比浏览器和脚本的差异。
5. 优先最小补环境,避免引入整套浏览器模拟
像 jsdom、无头浏览器当然能用,但成本较高。对很多签名场景来说,先试:
- 手工 mock 必要对象
- 只补被访问到的属性
- 把依赖压缩到最少
这样更轻、更稳定,也更容易维护。
6. 明确合规边界
Web 逆向和请求重放必须遵守目标系统的使用条款、法律法规与授权边界。尤其不要用于:
- 未经授权的数据抓取
- 绕过访问控制
- 规避付费限制
- 大规模攻击性请求
技术上能做到,不代表场景上就合适。这个边界要非常清楚。
一个更实用的项目结构建议
如果你准备把这类逻辑做成长期维护的工具,建议项目结构像这样:
project/
├── signer/
│ ├── env.js
│ ├── sign.js
│ └── utils.js
├── client/
│ ├── api.py
│ └── session.py
├── tests/
│ ├── test_sign.py
│ └── fixtures.json
├── server.py
└── replay.py
好处是:
- 签名逻辑升级时更容易定位
- 测试用例可复用
- 浏览器抓到的新样本能直接回归验证
一个简单的回归测试思路
签名逻辑最怕“前端偷偷升级”。建议保留一组样本做回归测试。
例如记录:
- path
- params
- ua
- 预期 sign
这样每次改动补环境或重构算法,都能快速确认有没有偏差。
import hashlib
def make_sign(path, query, ts, nonce, ua, secret):
raw = f"{path}|{query}|{ts}|{nonce}|{ua}|{secret}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def test_sign():
path = "/api/search"
query = "keyword=python&nonce=abc123&page=1&ts=1684290000"
ts = "1684290000"
nonce = "abc123"
ua = "Mozilla/5.0 DemoBrowser/1.0"
secret = "my_server_secret"
expected = hashlib.sha256(
f"{path}|{query}|{ts}|{nonce}|{ua}|{secret}".encode("utf-8")
).hexdigest()
assert make_sign(path, query, ts, nonce, ua, secret) == expected
虽然这个测试很简单,但在维护中非常值钱。
总结
把 Web 逆向里的“签名参数还原与请求重放”做成稳定方案,我建议你记住下面这条主线:
- 先抓包,确认真正目标请求
- 找动态参数,识别
ts/nonce/sign - 顺着调用栈找到签名注入点
- 判断是直接抽离,还是需要补环境
- 最小化复现签名函数
- 把签名生成和请求重放连成短链路
- 用日志与回归测试保证稳定性
如果只给一个最实用的建议,那就是:
不要急着“重写算法”,先尽量“还原运行现场”。
很多时候,真正难的不是加密算法本身,而是它依赖的上下文。把上下文找全了,问题就会从“逆向很玄学”变成“普通工程调试”。
最后再强调一下边界条件:
- 这套方法适合中级开发者做接口分析、自动化测试、协议调试、授权范围内的研究;
- 如果目标站点引入了更重的风控,如设备指纹、行为校验、WASM、TLS 指纹,那么签名还原只是第一步;
- 在任何真实场景中,都应确保你的行为合法、合规且获得授权。
如果你现在正卡在“抓到了请求但重放失败”的阶段,建议就按本文 demo 的结构,先把本地最小案例跑通。等你把这条链路真正打透,再回到真实站点,思路会清晰很多。