从 Cookie 签名到请求重放:中级开发者实战分析 Web 逆向中的鉴权参数生成逻辑
很多开发者第一次接触 Web 逆向,往往不是从“破解”开始,而是从一个非常朴素的问题开始:
为什么我把浏览器里的请求原样复制到代码里,还是会 401、403,或者返回“签名错误”?
我自己刚开始做这类分析时,也以为“请求头抄全了就行”。后来才发现,真正决定请求能不能成功的,往往不是 User-Agent,而是那些看起来不起眼、其实动态生成的参数:Cookie、签名字段、时间戳、随机串、设备指纹、加密载荷等等。
这篇文章不讲“神秘技巧”,而是从中级开发者能落地的角度,带你系统梳理:
- Cookie 签名到底在保护什么
- 请求重放为什么会失败
- 鉴权参数通常在哪一层生成
- 如何一步步定位签名逻辑
- 如何写一个可运行的小型复现实验
说明:本文聚焦合法授权的测试、调试、联调和安全研究场景。不要将文中方法用于未授权目标。
背景与问题
在现代 Web 应用里,一个请求能否被服务端接受,通常不只取决于接口路径和业务参数。
服务端往往会验证以下信息:
- Cookie 是否有效
- Cookie 是否被签名
- 请求参数是否参与签名
- 时间戳是否在允许窗口内
- 随机数是否重复
- 请求体是否被篡改
- Header 中某些字段是否参与校验
- 是否存在一次性 token / CSRF token
所以你在浏览器里抓到一个成功请求,不代表它可以被无限次重放。失败通常有三类原因:
- 静态复制失败:你抄的是一次性参数
- 时效性失效:时间戳、nonce、session 过期
- 上下文缺失:浏览器环境里有 JS 动态生成逻辑,而你在脚本里没执行
一个典型场景
假设某接口请求如下:
POST /api/order/list HTTP/1.1
Host: example.com
Cookie: session=abc123; sig=9f8...
X-Timestamp: 1700000000
X-Nonce: 4d2a1c
X-Sign: 5f4dcc3b5aa765d61d8327deb882cf99
Content-Type: application/json
{"page":1,"size":20}
表面上看只是普通 JSON 请求,但真实校验链路可能是:
session标识当前登录态sig是服务端签发的 Cookie 签名X-Timestamp控制请求时效X-Nonce防止重复提交X-Sign=MD5(path + body + timestamp + nonce + secret)或更复杂逻辑
如果你只复制一次请求,几分钟后再发,很可能就失效了。
前置知识
在正式动手前,建议你对下面几件事有基础认识:
- HTTP 请求结构:URL、Header、Body、Cookie
- 浏览器开发者工具:Network、Sources、Application
- 常见编码与哈希:Base64、URL Encode、MD5、SHA256、HMAC
- JavaScript 基础:对象序列化、JSON、时间戳、随机数
- Python / Node.js 至少会一种,方便做重放验证
如果你已经做过接口联调,但对“签名为什么这样算”还不够有把握,那么本文正适合你。
核心原理
这一部分是关键。先建立一个分析框架,不然后面抓到一堆 JS 很容易迷路。
1. 鉴权参数的常见分类
我习惯把鉴权参数分成四类:
| 类型 | 常见位置 | 作用 |
|---|---|---|
| 会话态参数 | Cookie / Authorization | 标识登录身份 |
| 完整性参数 | sign / token / digest | 防止参数被篡改 |
| 时效性参数 | timestamp / expire | 限制请求可用时间 |
| 防重放参数 | nonce / requestId | 防止相同请求重复使用 |
它们通常不是孤立存在,而是组合使用。
flowchart TD
A[请求发起] --> B[读取登录态 Cookie]
B --> C[生成 timestamp]
C --> D[生成 nonce]
D --> E[按规则拼接 path/query/body/header]
E --> F[使用 secret/hash/HMAC 计算 sign]
F --> G[发送请求]
G --> H[服务端校验 Cookie]
H --> I[校验时间窗口]
I --> J[校验 nonce 是否重复]
J --> K[重算 sign]
K --> L{是否一致}
L -- 是 --> M[返回业务数据]
L -- 否 --> N[返回 401/403/签名错误]
2. Cookie 签名的本质
很多人把 Cookie 理解成“浏览器自动带上的字符串”,这没错,但不完整。
如果服务端直接信任客户端传来的 Cookie 值,就有被伪造的风险。所以常见做法是:
- 将用户数据写入 Cookie
- 再对 Cookie 内容做签名
- 服务端收到后重新计算签名并比对
例如:
cookie_value = "uid=1001&role=user"
signature = HMAC_SHA256(cookie_value, secret_key)
最终 Cookie:
uid=1001&role=user; sig=abc123...
这样客户端即使知道 Cookie 结构,也不能随意把 role=user 改成 role=admin,因为签名会失效。
常见 Cookie 签名模式
- 明文值 + 签名
- Base64 编码值 + 签名
- JSON 序列化后签名
- 加密后再签名
- 服务端 session ID + 服务端存储态
最后一种其实最常见:Cookie 里只是一个 session id,真正数据在服务端。逆向时别一看到长字符串就默认它是 JWT 或加密数据,也可能只是随机 session key。
3. 请求重放为什么会失败
请求重放失败,通常不是“你姿势不对”,而是服务端明确做了防护。
常见防重放机制
- 时间戳必须在 ±300 秒内
- 同一个 nonce 只能使用一次
- sign 中包含 body 摘要
- sign 中包含顺序严格的参数串
- token 和当前 session 绑定
- token 和设备指纹、IP、UA 绑定
sequenceDiagram
participant C as Client
participant S as Server
C->>S: 请求(path, body, ts, nonce, sign)
S->>S: 检查 ts 是否过期
S->>S: 检查 nonce 是否已使用
S->>S: 用相同规则重算 sign
alt 校验通过
S-->>C: 200 OK
else 校验失败
S-->>C: 401/403
end
所以重放分析的重点是:
找到“服务端重算签名时使用的输入材料和顺序”。
4. 鉴权参数通常在哪生成
中级开发者分析时,最容易卡在“到底该看哪里”。
一般来说,生成逻辑分布在以下位置:
前端 JS
- webpack 打包后的业务代码
- 单独的加密工具模块
- axios/fetch 请求拦截器
- 登录态刷新逻辑
浏览器运行时
- 某些值来自
localStorage/sessionStorage - 某些值来自
document.cookie - 某些值来自浏览器指纹 API
服务端下发
- HTML 模板里内嵌配置
- 接口返回的临时 token
- 登录成功后写入的 Cookie
原生 / 混合容器
- App WebView 注入参数
- JSBridge 返回设备信息
- wasm 模块做加密计算
环境准备
本文用一个最小可运行实验来模拟真实场景。你可以直接在本地跑起来,感受“Cookie 签名 + 时间戳 + nonce + sign”的完整链路。
需要的环境
- Node.js 16+
- 一个终端
- 可选:Postman / curl / 浏览器
初始化项目
mkdir web-auth-demo
cd web-auth-demo
npm init -y
npm install express cookie-parser
实战代码(可运行)
下面我们自己搭一个服务端,再写一个客户端重放脚本。这样你不仅知道“原理上怎么回事”,还知道“排查时应该看哪里”。
1. 服务端:签发 Cookie 并校验请求签名
新建 server.js:
const express = require('express');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
const PORT = 3000;
// 模拟服务端密钥
const COOKIE_SECRET = 'cookie_secret_demo';
const API_SECRET = 'api_secret_demo';
// 用内存模拟 nonce 去重
const usedNonces = new Set();
function hmacSHA256(content, secret) {
return crypto.createHmac('sha256', secret).update(content).digest('hex');
}
function buildCookieSig(sessionId) {
return hmacSHA256(sessionId, COOKIE_SECRET);
}
function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
const bodyText = JSON.stringify(body || {});
const canonical = [
method.toUpperCase(),
path,
bodyText,
String(timestamp),
nonce,
sessionId
].join('\n');
return hmacSHA256(canonical, API_SECRET);
}
// 登录接口:签发 session + cookie 签名
app.get('/login', (req, res) => {
const sessionId = 'sess_' + crypto.randomBytes(8).toString('hex');
const sig = buildCookieSig(sessionId);
res.cookie('sessionId', sessionId, { httpOnly: true });
res.cookie('sessionSig', sig, { httpOnly: true });
res.json({
message: 'login success',
sessionId,
sessionSig: sig
});
});
// 受保护接口
app.post('/api/data', (req, res) => {
const sessionId = req.cookies.sessionId;
const sessionSig = req.cookies.sessionSig;
if (!sessionId || !sessionSig) {
return res.status(401).json({ error: 'missing cookie' });
}
const expectedCookieSig = buildCookieSig(sessionId);
if (sessionSig !== expectedCookieSig) {
return res.status(403).json({ error: 'invalid cookie signature' });
}
const timestamp = req.header('X-Timestamp');
const nonce = req.header('X-Nonce');
const sign = req.header('X-Sign');
if (!timestamp || !nonce || !sign) {
return res.status(400).json({ error: 'missing auth headers' });
}
const now = Math.floor(Date.now() / 1000);
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) {
return res.status(403).json({ error: 'timestamp expired' });
}
if (usedNonces.has(nonce)) {
return res.status(403).json({ error: 'replay detected' });
}
const expectedSign = buildApiSign({
path: '/api/data',
method: 'POST',
body: req.body,
timestamp: ts,
nonce,
sessionId
});
if (sign !== expectedSign) {
return res.status(403).json({
error: 'invalid sign',
expectedSign
});
}
usedNonces.add(nonce);
res.json({
ok: true,
data: {
user: 'demo-user',
payload: req.body
}
});
});
app.listen(PORT, () => {
console.log(`Server listening at http://localhost:${PORT}`);
});
启动:
node server.js
2. 客户端:先登录,再生成签名请求
新建 client.js:
const crypto = require('crypto');
function hmacSHA256(content, secret) {
return crypto.createHmac('sha256', secret).update(content).digest('hex');
}
function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
const bodyText = JSON.stringify(body || {});
const canonical = [
method.toUpperCase(),
path,
bodyText,
String(timestamp),
nonce,
sessionId
].join('\n');
return hmacSHA256(canonical, 'api_secret_demo');
}
function parseSetCookie(setCookieHeaders) {
const cookies = {};
for (const item of setCookieHeaders) {
const pair = item.split(';')[0];
const index = pair.indexOf('=');
const key = pair.slice(0, index);
const value = pair.slice(index + 1);
cookies[key] = value;
}
return cookies;
}
async function main() {
// 1) 登录拿 Cookie
const loginResp = await fetch('http://localhost:3000/login');
const setCookie = loginResp.headers.getSetCookie();
const cookies = parseSetCookie(setCookie);
const sessionId = cookies.sessionId;
const sessionSig = cookies.sessionSig;
console.log('login cookies:', cookies);
// 2) 生成动态鉴权参数
const body = { page: 1, size: 20 };
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(6).toString('hex');
const sign = buildApiSign({
path: '/api/data',
method: 'POST',
body,
timestamp,
nonce,
sessionId
});
// 3) 发受保护请求
const resp = await fetch('http://localhost:3000/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': String(timestamp),
'X-Nonce': nonce,
'X-Sign': sign,
'Cookie': `sessionId=${sessionId}; sessionSig=${sessionSig}`
},
body: JSON.stringify(body)
});
const result = await resp.json();
console.log('protected api result:', result);
}
main().catch(console.error);
运行:
node client.js
正常情况下会返回:
{
"ok": true,
"data": {
"user": "demo-user",
"payload": {
"page": 1,
"size": 20
}
}
}
3. 故意重放一次,观察失败
把 client.js 稍微改一下:第一次成功后,直接再发一次完全相同的请求。
const crypto = require('crypto');
function hmacSHA256(content, secret) {
return crypto.createHmac('sha256', secret).update(content).digest('hex');
}
function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
const bodyText = JSON.stringify(body || {});
const canonical = [
method.toUpperCase(),
path,
bodyText,
String(timestamp),
nonce,
sessionId
].join('\n');
return hmacSHA256(canonical, 'api_secret_demo');
}
function parseSetCookie(setCookieHeaders) {
const cookies = {};
for (const item of setCookieHeaders) {
const pair = item.split(';')[0];
const index = pair.indexOf('=');
const key = pair.slice(0, index);
const value = pair.slice(index + 1);
cookies[key] = value;
}
return cookies;
}
async function sendProtectedRequest({ sessionId, sessionSig, body, timestamp, nonce, sign }) {
const resp = await fetch('http://localhost:3000/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': String(timestamp),
'X-Nonce': nonce,
'X-Sign': sign,
'Cookie': `sessionId=${sessionId}; sessionSig=${sessionSig}`
},
body: JSON.stringify(body)
});
return resp.json();
}
async function main() {
const loginResp = await fetch('http://localhost:3000/login');
const setCookie = loginResp.headers.getSetCookie();
const cookies = parseSetCookie(setCookie);
const sessionId = cookies.sessionId;
const sessionSig = cookies.sessionSig;
const body = { page: 1, size: 20 };
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(6).toString('hex');
const sign = buildApiSign({
path: '/api/data',
method: 'POST',
body,
timestamp,
nonce,
sessionId
});
const first = await sendProtectedRequest({
sessionId,
sessionSig,
body,
timestamp,
nonce,
sign
});
const second = await sendProtectedRequest({
sessionId,
sessionSig,
body,
timestamp,
nonce,
sign
});
console.log('first:', first);
console.log('second:', second);
}
main().catch(console.error);
你会看到第二次返回类似:
{
"error": "replay detected"
}
这就把“为什么抓包能发一次,第二次不行”这件事,完整演示出来了。
4. 真实项目中如何定位签名生成逻辑
上面的 demo 是我们自己写的,真实站点当然复杂得多。但分析路径其实很像。
第一步:先在 Network 面板确定“谁是动态的”
建议先抓两次同一接口,做 diff,比对:
- URL query 是否变化
- body 是否变化
- Cookie 是否变化
- 哪些 header 每次都不同
- 哪些字段长度固定、字符集像 hex/base64
最值得优先怀疑的字段:
signtokenauthts/timestampnoncereqIdtraceIdx-*私有请求头
第二步:搜索字段名
在 Sources 全局搜索这些关键词:
sign
timestamp
nonce
authorization
cookie
md5
sha256
hmac
encrypt
digest
如果代码没混淆,很多时候几分钟就能摸到边。
第三步:盯住请求发送点
重点看:
fetch(...)XMLHttpRequest.send(...)axios.interceptors.request.use(...)
因为就算签名函数名字很乱,请求真正发出去之前,参数总得被组装一次。
flowchart LR
A[Network 发现动态参数] --> B[Sources 搜索参数名]
B --> C[定位 fetch/axios/XHR 调用]
C --> D[向上追踪参数来源]
D --> E[确认拼接顺序与输入项]
E --> F[在 Console/断点中验证]
F --> G[脚本复现]
第四步:断点看“原始串”
我自己最常用的不是一上来就研究算法,而是先找:
- 签名前的原始拼接字符串
- 最后一次调用哈希函数的位置
因为 MD5/HMAC/SHA256 本身不重要,重要的是输入内容和顺序。
举个例子,下面三个虽然看起来差不多,但签名一定不同:
md5(path + body + ts)
md5(body + path + ts)
md5(path + JSON.stringify(body) + ts)
第五步:验证边界条件
拿到疑似算法后,不要急着写脚本,先验证几个关键点:
- body 字段顺序是否影响签名
- 空格、换行、编码是否影响
- query 参数是否需要排序
- header 是否参与签名
- 时间戳单位是秒还是毫秒
- nonce 是否纯随机还是带时间前缀
逐步验证清单
这是我平时做这类分析时很实用的一份 checklist:
A. 先判断是不是“纯复制可重放”
- 同一请求 10 秒内重放是否成功
- 换 Cookie 后是否仍成功
- 去掉某些 header 是否失败
B. 再判断签名输入项
- path 是否参与签名
- query 是否参与签名
- body 是否参与签名
- Cookie/sessionId 是否参与签名
- timestamp/nonce 是否参与签名
C. 最后判断算法细节
- MD5 / SHA1 / SHA256 / HMAC
- hex 还是 base64 输出
- 是否大小写敏感
- 是否二次编码
- 是否使用固定盐值或动态密钥
常见坑与排查
这部分非常重要。很多“明明算法对了还是不行”的问题,都是细节坑。
1. JSON 序列化不一致
前端可能是:
JSON.stringify({a:1,b:2})
而你的脚本可能生成了:
{"b":2,"a":1}
如果服务端按原始字符串签名,这两个就不同。
排查建议
- 固定字段顺序
- 明确是否需要去空格
- 确认是否做 key 排序
示例:
function stableStringify(obj) {
if (obj === null || typeof obj !== 'object') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(obj).sort();
return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
}
console.log(stableStringify({ b: 2, a: 1 }));
2. 时间戳单位搞错
有些接口要秒:
Math.floor(Date.now() / 1000)
有些接口要毫秒:
Date.now()
只差三位数,但签名一定错。
排查经验
我当时踩过一个坑:接口文档写的是“Unix 时间戳”,结果前端实际传毫秒。后来还是对照浏览器真实请求才发现。
3. nonce 看着像随机,实际有格式要求
有些 nonce 不是纯随机串,而是:
- 时间戳 + 随机数
- UUID
- 固定长度十六进制
- base36 编码
- 含设备前缀
如果你只“随便生成一个字符串”,服务端可能在格式校验阶段就拒了。
4. Cookie 不是唯一身份材料
有些请求除了 Cookie,还会带:
Authorization: Bearer xxxX-CSRF-Tokenx-xsrf-token- localStorage 中的 access_token
也就是说,你看到 Cookie 没变,不代表登录态没变。
5. Header 名字没错,但大小写/值格式错了
HTTP header 名大小写通常不敏感,但签名计算时,前端可能按原始对象名拼接。比如:
headers['X-Timestamp']
和
headers['x-timestamp']
在某些自定义实现里可能影响最终字符串。
6. 浏览器环境值缺失
真实站点中常见情况:
- 从
window.navigator取语言、平台 - 从
localStorage取设备 ID - 从
document.cookie取某个埋点值 - 从 canvas/webgl 计算指纹
你在 Node 脚本里重放时,这些值都不存在。
排查建议
- 在浏览器 Console 中直接调用签名函数
- 先在浏览器里复现成功,再迁移到脚本
- 必要时补 mock 环境
7. 误把“加密”当“签名”
很多参数看起来像长字符串,就容易误判。
区别很关键
- 签名:用于校验内容未篡改,通常可重算比对
- 加密:用于隐藏明文内容,需要解密后使用
- 编码:只是格式变换,不提供安全性
例如:
- Base64 不是加密
- MD5 更像摘要,不是可逆加密
- HMAC 是带密钥的签名方式
安全/性能最佳实践
这一节从工程角度说,不只是“怎么逆向看懂”,也包括“如果你是后端/前端,应该怎么设计得更合理”。
1. 不要把长期密钥硬编码在前端
如果前端 JS 中直接写死:
const SECRET = "my_super_secret_key";
那从防护角度几乎等于公开。
更合理的方式
- 前端只持有短期 token
- 真正密钥保留在服务端
- 通过会话态、一次性票据、挑战响应等机制控制访问
2. Cookie 只存最小必要信息
最佳实践:
- 尽量存 sessionId,不存敏感业务数据
- 设置
HttpOnly - 设置
Secure - 设置合理的
SameSite
示例思路:
Cookie = sessionId
服务端 session store 中保存用户态与权限
这样即使客户端可见性受限,也降低了伪造与泄露风险。
3. 防重放至少要有“时间窗口 + nonce”
只校验时间戳不够,因为攻击者可以在短窗口内重复请求。
建议组合:
- 时间戳限制有效期
- nonce 保证唯一性
- 服务端记录已用 nonce
- 高价值接口增加一次性 token
4. 签名原文要规范化
如果你是系统设计者,千万别让签名规则模糊不清。
建议明确:
- query 是否排序
- body 如何序列化
- header 哪些参与签名
- 是否忽略空字段
- 字符编码统一 UTF-8
一个稳定的 canonical string 规范,比“临时拼字符串”重要得多。
5. 不要迷信前端混淆
很多团队会把前端代码混淆、压缩、拆分,觉得这样就能挡住分析。实际效果有限:
- 混淆只能增加成本,不能阻止还原
- 请求最终总要发出去
- 参数最终总要落地到网络层
所以真正有效的安全策略,仍然应放在服务端校验上。
6. 性能上避免过重签名流程
如果每个请求都:
- 大对象深度排序
- 多次 JSON stringify
- 多轮加密/哈希
- 再走 wasm 计算
前端性能和接口延迟都会受影响。
建议:
- 只对必要字段签名
- 大 payload 使用摘要而不是全文重复计算
- 在拦截器中复用稳定逻辑
- 避免重复序列化
一个更贴近实战的分析思路
如果你面对的不是 demo,而是一个打包得很乱的线上站点,我建议按下面顺序来:
- 先抓两次成功请求做 diff
- 锁定动态字段
- 在请求发送点下断点
- 找签名前的原始字符串
- 确认 hash/HMAC 算法
- 确认是否依赖 Cookie/localStorage/环境值
- 先在浏览器 Console 验证
- 再迁移到 Node/Python 脚本
- 最后做自动化重放或联调脚本
这个顺序的好处是:
你不会一上来陷进“读一万行混淆代码”的泥潭,而是沿着请求真实执行路径倒推。
总结
从 Cookie 签名到请求重放,表面看是在“还原一个请求”,本质上是在回答三个问题:
- 服务端信任什么?
- 客户端提交了哪些会变的材料?
- 这些材料是按什么规则被组织和校验的?
你可以把整件事理解成一条链:
- Cookie 解决“你是谁”
- 时间戳解决“你是不是现在发的”
- nonce 解决“你是不是重复发的”
- sign 解决“你发的内容有没有被改”
只要这条链里有一环没复现到位,请求重放就会失败。
最后给几个可执行建议
- 抓包不要只看请求值,要看值是怎么来的
- 遇到签名先找原始拼接串,别急着猜算法
- 两次请求 diff 是最快的切入口
- 能在浏览器里验证,就别一开始就硬上 Node 脚本
- 对高价值接口,始终假设“前端逻辑可被观察”,真正安全依赖服务端
边界条件也要记住
- 如果签名在服务端生成、前端拿不到关键密钥,那么你只能做有限重放分析
- 如果依赖原生容器、wasm、硬件指纹,复现成本会明显上升
- 如果接口是一次性挑战响应模式,就不能简单靠“复制参数”解决
如果你已经能看懂普通接口联调日志,那么把本文的分析框架用起来,基本就能跨过 Web 逆向里最容易卡人的那道坎:不是不会发请求,而是不知道请求为什么这样发。