从浏览器 DevTools 到脚本复现:中级开发者实战拆解 Web 逆向中的签名参数生成逻辑
很多中级开发者第一次接触 Web 逆向时,卡住的不是“怎么发请求”,而是“为什么我照着请求头和参数抄了一遍,服务端还是返回签名错误”。
我当时第一次碰到这类接口时,也天真地以为:把 Network 面板里看到的参数原样复制出来,不就完了?
结果很快发现,真正关键的 sign、token、ts、nonce 往往不是静态值,而是前端运行时计算出来的。
这篇文章不讲偏门技巧,也不走“玄学猜签名”的路线,而是带你从 浏览器 DevTools 定位生成逻辑,一步步走到 脚本可运行复现。重点不是某个网站的特例,而是一套中级开发者可以迁移复用的方法。
背景与问题
在现代 Web 应用中,签名参数常用于:
- 防止接口被随意调用
- 验证请求是否被篡改
- 绑定时间戳、设备信息、会话状态
- 提高自动化调用门槛
典型现象通常是这样的:
- 你在浏览器里请求接口,一切正常
- 你把 URL、Header、Body 复制到脚本里
- 服务端返回:
sign invalidtimestamp expiredillegal requestauth check failed
这说明问题往往不在“请求格式”,而在“动态参数生成过程”。
常见签名参数长什么样
常见字段包括:
signsignaturetokenx-signx-timestampnoncedidtraceid
这些字段背后一般涉及:
- 请求参数排序
- JSON 序列化规则
- 时间戳拼接
- 固定盐值
- 用户态 token
- 哈希算法,如
md5、sha1、sha256 - 某些简单加密或编码,如
Base64、URL encode
前置知识与环境准备
开始前,建议你准备:
- Chrome 或 Edge 浏览器
- DevTools 基础使用能力
- Node.js 16+
- 一点点 JavaScript 调试能力
推荐工具
- Chrome DevTools
- Network
- Sources
- Console
- Node.js
- 用于脚本复现
- 可选代理工具
- Charles / Fiddler / mitmproxy
- 可选格式化工具
- 在线 JS Beautify
- SourceMap 支持
核心原理
把问题先抽象一下。绝大多数签名生成逻辑,都可以归纳成下面这条链路:
flowchart LR
A[用户操作/页面加载] --> B[前端组装业务参数]
B --> C[追加动态字段 ts nonce token]
C --> D[按规则排序/序列化]
D --> E[拼接盐值或密钥]
E --> F[哈希/编码生成 sign]
F --> G[发送请求]
G --> H[服务端按相同规则校验]
也就是说,你要复现签名,不是“拿到 sign 值”,而是找到:
- 输入是什么
- 规则是什么
- 算法是什么
- 上下文依赖是什么
一个典型签名公式
很多站点本质上都是类似下面这种:
sign = md5(path + sortedQuery + bodyString + timestamp + secret)
或者:
sign = sha256(token + nonce + JSON.stringify(data) + salt)
真正难的点通常不是算法本身,而是这些细节:
- 参数排序是否按字典序
- 空值是否参与签名
- JSON 是否压缩无空格
- 数字和字符串是否严格区分
- Header 中某个字段是否也参与签名
- 时间戳单位是秒还是毫秒
- secret 是写死的、动态下发的,还是由别处计算出来的
如何在 DevTools 里定位签名生成逻辑
这是全文最核心的实战方法。
第一步:在 Network 面板找到目标请求
先打开页面,触发目标接口,然后在 Network 里筛选:
- XHR / Fetch
- 请求 URL
- 特征参数,比如
sign、timestamp
重点看这几处:
- Query String Parameters
- Request Payload
- Request Headers
你要先确认:
sign在 URL、Body 还是 Header- 哪些参数每次都变
- 哪些参数稳定不变
快速判断思路
如果你连续发两次相同操作,观察到:
ts每次变化:说明时间戳参与签名概率很高nonce每次变化:说明存在随机数sign随着参数变化而变化:说明是内容相关签名sign长度固定 32 位:大概率md5sign长度固定 40 位:大概率sha1sign长度固定 64 位:大概率sha256
当然,这只是经验,不是绝对。
第二步:从请求发起栈反查调用位置
在 Network 里点开目标请求,查看:
- Initiator
- Call Stack
如果是未压缩站点,通常能直接跳到发请求的代码位置。
如果是打包代码,也不用慌,重点找这些关键词:
signsignaturetimestampnoncemd5shacryptoheadersinterceptorsfetchaxios
一个很实用的经验
很多项目不是在业务函数里直接算签名,而是统一放在:
- Axios request interceptor
- 封装的
request()方法 - SDK 层
- 公共 util 函数
所以如果你只盯着某个页面组件,容易找偏。
第三步:用断点把“输入”和“输出”钉住
定位到可疑代码后,不要急着读完所有混淆逻辑。
先干一件最值钱的事:打断点,看签名前后的变量值。
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant N as Network请求
participant B as 后端
U->>P: 点击查询
P->>S: 传入业务参数
S->>S: 排序/拼接/哈希
S-->>P: 返回 sign
P->>N: 发送请求
N->>B: 携带 sign/ts/nonce
B-->>N: 返回结果
N-->>P: 渲染页面
你要重点观察这些变量:
- 原始业务参数对象
- 时间戳
- 随机串
- 序列化后的字符串
- 最终哈希输入串
- 输出的 sign 值
断点位置建议
优先打在这些地方:
md5(...)
sha256(...)
JSON.stringify(...)
Object.keys(...)
sort(...)
setRequestHeader(...)
axios.interceptors.request.use(...)
fetch(...)
XMLHttpRequest.prototype.send(...)
如果站点用了第三方加密库,这种方式尤其高效。
第四步:验证“签名是否只依赖你看到的参数”
这是很多人容易忽略的一步。
有些签名不仅依赖请求体,还会依赖:
- Cookie 中的某个值
- LocalStorage 的 token
- 页面初始化时下发的配置
- 设备指纹
- 当前 URL path
- Referer
- 某个自定义 Header
所以你要有意识地做“变量排除实验”:
- 只改业务参数,看 sign 是否变
- 只改 ts,看 sign 是否变
- 只改 Header,看 sign 是否变
- 切换账号,看 sign 规则是否变
这样可以快速圈定真正参与签名的输入集合。
一个通用的定位流程图
flowchart TD
A[抓到目标请求] --> B{sign 在哪}
B -->|Query| C[检查 URL 参数]
B -->|Body| D[检查请求体]
B -->|Header| E[检查请求头]
C --> F[查看 Initiator/Call Stack]
D --> F
E --> F
F --> G[全局搜索 sign md5 sha crypto]
G --> H[锁定 request 封装或拦截器]
H --> I[在签名函数前后打断点]
I --> J[记录输入参数和最终拼接串]
J --> K[在控制台复算验证]
K --> L[迁移到 Node 脚本复现]
实战案例:从页面请求到 Node 脚本复现
下面我用一个自造但高度贴近真实项目的例子演示。
场景:某接口请求时带有以下参数:
appIdtsnoncedatasign
前端签名规则如下:
- 将
appId、ts、nonce和data放入对象 - 按 key 字典序排序
- 按
key=value用&拼接 - 在末尾追加固定盐值
secret=demo_secret - 对完整字符串做
md5
最终:
sign = md5("appId=web001&data=...&nonce=...&ts=...&secret=demo_secret")
浏览器侧示例代码
假设你在 Sources 里看到了类似代码:
function buildSign(params) {
const secret = 'demo_secret';
const sortedKeys = Object.keys(params).sort();
const str = sortedKeys
.map((key) => `${key}=${params[key]}`)
.join('&') + `&secret=${secret}`;
return md5(str);
}
function sendRequest(keyword) {
const payload = {
keyword,
page: 1,
pageSize: 20
};
const params = {
appId: 'web001',
ts: Date.now().toString(),
nonce: Math.random().toString(36).slice(2, 10),
data: JSON.stringify(payload)
};
params.sign = buildSign(params);
return fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
});
}
这类代码已经很友好了。真实环境中可能是压缩后的:
function a(b){var c="demo_secret";return d(Object.keys(b).sort().map(function(e){return e+"="+b[e]}).join("&")+"&secret="+c)}
别怕,本质一样。
在控制台先做最小验证
在彻底写 Node 脚本前,我建议先在浏览器 Console 做一次原地复算。
这是降低试错成本最有效的方法。
const payload = {
keyword: 'phone',
page: 1,
pageSize: 20
};
const params = {
appId: 'web001',
ts: '1710000000000',
nonce: 'abcd1234',
data: JSON.stringify(payload)
};
const sortedKeys = Object.keys(params).sort();
const raw = sortedKeys.map(k => `${k}=${params[k]}`).join('&') + '&secret=demo_secret';
console.log(raw);
如果你已经能拿到页面里的 md5 函数,继续:
console.log(md5(raw));
如果输出和请求中的 sign 一致,说明规则基本确认了。
实战代码:Node.js 可运行复现
下面给出一个可运行版本。你可以保存为 sign-demo.js。
const crypto = require('crypto');
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function buildSign(params, secret) {
const sortedKeys = Object.keys(params).sort();
const raw = sortedKeys
.map((key) => `${key}=${params[key]}`)
.join('&') + `&secret=${secret}`;
return {
raw,
sign: md5(raw)
};
}
function buildRequestData(keyword) {
const payload = {
keyword,
page: 1,
pageSize: 20
};
const params = {
appId: 'web001',
ts: Date.now().toString(),
nonce: Math.random().toString(36).slice(2, 10),
data: JSON.stringify(payload)
};
const { raw, sign } = buildSign(params, 'demo_secret');
return {
raw,
body: {
...params,
sign
}
};
}
const result = buildRequestData('phone');
console.log('签名原串:');
console.log(result.raw);
console.log('\n最终请求体:');
console.log(JSON.stringify(result.body, null, 2));
示例输出
{
"appId": "web001",
"ts": "1710000000000",
"nonce": "k3j9ab2x",
"data": "{\"keyword\":\"phone\",\"page\":1,\"pageSize\":20}",
"sign": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
加上真实请求发送
如果你想继续发请求,可以这样写。这里用 Node 18+ 自带 fetch。
const crypto = require('crypto');
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function buildSign(params, secret) {
const raw = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&') + `&secret=${secret}`;
return md5(raw);
}
async function search(keyword) {
const payload = {
keyword,
page: 1,
pageSize: 20
};
const params = {
appId: 'web001',
ts: Date.now().toString(),
nonce: Math.random().toString(36).slice(2, 10),
data: JSON.stringify(payload)
};
const sign = buildSign(params, 'demo_secret');
const body = {
...params,
sign
};
const resp = await fetch('https://example.com/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const text = await resp.text();
console.log(text);
}
search('phone').catch(console.error);
逐步验证清单
写脚本时,我建议你按下面顺序验证,不要一上来就“整包发出去再看命”。
第 1 步:验证原始输入
确认这些值是否和浏览器一致:
appIdtsnoncedata
第 2 步:验证排序结果
打印排序后的 key:
console.log(Object.keys(params).sort());
第 3 步:验证签名原串
这是最关键的一步:
console.log(raw);
只要原串不一致,最终 sign 一定不一致。
第 4 步:验证哈希结果
console.log(sign);
第 5 步:验证完整请求
确认:
- Body 是否是 JSON
- Header 是否缺少必要字段
- Cookie / token 是否缺失
常见坑与排查
这一节很重要。很多“看起来规则一样却总失败”的问题,都栽在细节上。
1. JSON.stringify 结果不一致
最常见的坑之一。
比如浏览器侧是:
JSON.stringify({a:1,b:2})
你脚本侧如果手工拼成:
'{"b":2,"a":1}'
虽然语义相同,但字符串不同,签名就变了。
排查建议
- 永远打印签名原串
- 尽量使用和前端同样的序列化方式
- 注意对象 key 的插入顺序
2. 时间戳单位搞错
有些接口要秒:
Math.floor(Date.now() / 1000).toString()
有些接口要毫秒:
Date.now().toString()
差 1000 倍,服务端直接判过期。
排查建议
观察浏览器请求里的 ts 长度:
- 10 位:通常是秒
- 13 位:通常是毫秒
3. 签名前是否包含 sign 自己
有些人会把 sign 字段也放进去排序,然后再算 sign。
这肯定错。
正确做法
签名时一般只对原始待签字段做计算,最后再把 sign 放入请求。
4. URL 编码时机不一致
比如 data 里包含中文、空格、特殊字符。
浏览器可能先:
- JSON.stringify
- 再参与签名
- 最后请求时由 HTTP 层编码
而你脚本可能先 encodeURIComponent,这就会导致原串不同。
排查建议
明确这几个阶段:
- 参与签名的是原始字符串?
- 还是 URL 编码后的字符串?
- 编码发生在签名前还是签名后?
5. 随机数生成规则不同
有些站点的 nonce 看起来像随机,其实有固定格式:
- 固定长度
- 指定字符集
- 前缀/后缀
- 时间戳 + 随机段
如果你生成规则偏差太大,服务端也可能拒绝。
6. 浏览器环境依赖没补齐
有些签名函数依赖:
windowdocumentnavigator.userAgentlocation.hreflocalStorage
你直接把函数复制到 Node 跑,很可能报错,或者算出来不对。
处理方式
- 补 mock 环境
- 抽取纯计算函数
- 在浏览器控制台先验证
- 必要时用
jsdom或轻量模拟对象
7. 混淆代码看不懂,搜索也搜不到
我踩过这个坑。站点把函数名压成了 n(), r(), o(),看起来很恶心。
应对思路
不要硬读所有代码,换成“围点打援”:
- 从请求发起位置逆推
- 在
fetch/xhr.send前打断点 - 在参数对象写入点观察
- Hook 哈希函数输入输出
一个很实用的 Hook 思路
如果你怀疑页面用了 md5 或 sha256,可以在 Console 里临时包一层。
下面是一个示意写法:
(function () {
const originalFetch = window.fetch;
window.fetch = async function (...args) {
console.log('fetch args:', args);
return originalFetch.apply(this, args);
};
})();
如果你已经拿到了可疑签名函数,也可以直接包裹:
function wrapSign(fn) {
return function (...args) {
console.log('sign input:', args);
const result = fn.apply(this, args);
console.log('sign output:', result);
return result;
};
}
这类方式比“盲猜算法”高效得多。
安全/性能最佳实践
这里我想强调一个边界:本文讨论的是调试与复现方法,用于理解前端签名机制、排查接口问题、做自动化测试与安全研究。不要把它用于未授权场景。
安全实践
1. 不要把前端签名当成真正的安全边界
只要签名逻辑在前端运行,就存在被观察、被调试、被复现的可能。
真正关键的权限控制,仍然应该在服务端完成。
2. 服务端要做完整校验
至少应校验:
- 签名正确性
- 时间戳有效期
- nonce 去重
- 用户身份与 token 绑定
- 请求频率限制
3. 避免硬编码高价值密钥到前端
前端里出现固定 secret,本质上只是“增加门槛”,不是“保密”。
性能实践
1. 复现脚本里缓存稳定参数
例如:
- 固定 appId
- 固定公共 Header
- 静态业务参数模板
这样可以减少重复计算和调试噪音。
2. 把签名逻辑做成纯函数
这是我很推荐的工程化做法。
function signRequest(input) {
// 不依赖外部环境
// 输入明确,输出稳定
}
好处是:
- 容易测试
- 容易对比浏览器结果
- 容易迁移到其他语言
3. 做最小化日志
调试时打印这些信息就够了:
- 原始参数
- 排序结果
- 签名原串
- 最终 sign
不要一股脑把所有对象都打出来,容易淹没关键信息。
建议的工程结构
如果你准备长期维护某类复现脚本,可以按下面方式拆分:
classDiagram
class RequestBuilder {
+buildPayload(keyword)
+buildHeaders()
}
class Signer {
+buildSign(params, secret)
+md5(text)
}
class Client {
+search(keyword)
}
Client --> RequestBuilder
Client --> Signer
对应到代码目录可以是:
project/
signer.js
request-builder.js
client.js
index.js
这样后续遇到参数变更、Header 更新、签名升级时,不会改成一团。
总结
从浏览器 DevTools 到脚本复现,真正要掌握的不是某个网站的“答案”,而是一套稳定的方法:
- 先抓到目标请求
- 确认 sign 所在位置
- 通过 Initiator / Call Stack 找调用链
- 在签名前后打断点,拿到输入和输出
- 优先验证签名原串,而不是只看最终 hash
- 再迁移到 Node 脚本做可运行复现
如果你现在正卡在“脚本发出去总是签名错误”,我建议你先别继续猜算法,回到最基础的一步:
打印浏览器里的签名原串,再打印你脚本里的签名原串,逐字符比对。
绝大多数问题,都会在这里现形。
最后再强调一次边界条件:
- 前端签名可以提高门槛,但不是最终安全防线
- 复现时要关注环境依赖、编码细节和时间窗口
- 适合用于授权调试、测试、研究,不适用于未授权调用
如果你把这套流程真正走顺了,之后再遇到 sign、token、x-signature 这类字段,心里就不会慌了:
无非还是那件事——找到输入,确认规则,复现过程,逐步验证。