背景与问题
做接口联调、自动化测试、数据采集,很多人都会遇到一个典型问题:接口参数明明都对,但服务端总返回签名错误。
这类场景往往不是简单的“多传了一个字段”,而是前端在发请求前,做了额外处理,比如:
- 对参数按特定顺序排序
- 拼接时间戳、随机串、设备指纹
- 做一层或多层编码
- 用
md5/sha256/hmac/aes/rsa等方式生成签名 - 把签名逻辑藏进 webpack 打包后的混淆代码里
中级工程师常见的卡点,不是“不懂加密算法”,而是:
- 找不到签名逻辑入口
- 找到后看不懂混淆代码
- 能看懂局部,但无法完整复现
- 本地跑通了,线上接口还是不认
这篇文章我会按“真实排查路径”带你走一遍:如何定位前端签名参数生成逻辑,并在本地稳定复现。
先说边界:本文用于合法的接口调试、安全研究、自动化测试和自有系统分析,不讨论绕过鉴权、攻击他人系统等内容。
前置知识与环境准备
建议先准备这些工具:
- 浏览器:Chrome
- 抓包/调试:DevTools、Charles/Fiddler(二选一)
- JS 运行环境:Node.js 14+
- 代码格式化:Prettier、在线 JS Beautify
- 调试辅助:
mitmproxy或本地代理(可选)
你至少要熟悉:
- 浏览器 Network / Sources / Console 的基本使用
- JS 基础:对象、数组、闭包、Promise
- 常见摘要算法:MD5 / SHA 系列 / HMAC 的使用方式
- Node.js 中如何执行浏览器抽出来的 JS
核心原理
前端签名逻辑,本质上通常是这几个阶段:
- 收集原始参数
- 标准化参数
- 排序
- 过滤空值
- 序列化
- 拼接上下文信息
- 时间戳
- nonce
- appKey
- token
- 签名运算
- hash / hmac / aes / rsa
- 写回请求
- header
- query
- body
很多站点看起来“很复杂”,实际拆开后就两件事:
- 参数如何被整理
- 最终用什么算法算摘要
一个通用识别框架
flowchart TD
A[抓到目标请求] --> B[观察请求中的 sign ts nonce]
B --> C[全局搜索关键字段]
C --> D[定位请求发起点]
D --> E[向上追踪参数组装函数]
E --> F[定位加密/摘要调用]
F --> G[抽离最小可运行代码]
G --> H[对照浏览器结果逐步验证]
常见签名模式
1. 排序后拼接再 MD5
例如:
a=1&b=2&ts=1626490800&key=secret
然后做:
md5(上面的字符串)
2. HMAC
例如:
HMAC-SHA256(payload, secret)
3. 先 JSON 序列化再加密
例如:
JSON.stringify(data)AES.encrypt(...)- 再把密文作为
data
4. 混合方案
真实项目里很常见:
- 业务参数 JSON
- 时间戳 + nonce
- 参与签名字符串排序
sha256(...)+自定义编码
一套实战定位方法
这里我不走“只讲概念”的路线,而是按照实际逆向顺序来。
第一步:先在 Network 里确认“谁是签名参数”
打开 Chrome DevTools,进入 Network,筛选目标接口。
重点看:
- Query String Parameters
- Request Payload / Form Data
- Request Headers
通常这些字段最值得怀疑:
signsignaturetokentstimestampnonceauthx-signx-token
如果你看到:
{
"page": 1,
"size": 20,
"ts": 1720000000,
"nonce": "8f3a2c",
"sign": "e4d909c290d0fb1ca068ffaddf22cbd0"
}
那基本可以判断:sign 大概率由前面的字段和某个密钥共同生成。
第二步:全局搜索关键字,不要一上来就硬读混淆代码
在 Sources 里全局搜索:
- 请求路径的一部分
signtimestampnonce- 请求方法名,比如
axios.post、fetch - header 名,比如
x-sign
很多人一开始就盯着几十万行压缩代码看,这很容易陷进去。我更推荐从请求发起点反推。
第三步:给可疑位置打断点
常用断点位置:
XMLHttpRequest.prototype.sendwindow.fetchaxios请求拦截器- 设置 header 的地方
- 给 body 赋值的地方
你可以直接在 Console 里先挂钩,快速观察请求参数。
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log("fetch args =>", args);
debugger;
return rawFetch.apply(this, args);
};
如果页面走的是 XHR:
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
console.log("xhr body =>", body);
debugger;
return rawSend.call(this, body);
};
这一步的目的不是“改页面”,而是让请求发出前停住,看清楚签名是何时被塞进去的。
Mermaid:签名定位过程时序图
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名函数
participant R as 请求模块
participant API as 服务端接口
U->>P: 点击查询/翻页
P->>P: 组装业务参数
P->>S: 传入 data + ts + nonce
S-->>P: 返回 sign
P->>R: 写入 header/body/query
R->>API: 发起请求
API-->>R: 返回结果
R-->>P: 渲染页面
实战演示:从页面逻辑中抽离签名函数
下面用一个可运行的简化案例演示完整过程。真实站点会更绕,但定位思路是一致的。
假设我们在浏览器里观察到请求体长这样:
{
"page": 1,
"size": 20,
"keyword": "phone",
"ts": 1720000000,
"nonce": "abc123",
"sign": "待生成"
}
经过调试,找到页面里有一段逻辑:
function buildSign(params, secret) {
const keys = Object.keys(params)
.filter(k => params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort();
const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
return md5(plain);
}
这个例子故意保持简单,因为我们重点是“如何抽离并复现”。
实战代码(可运行)
方案一:Node.js 中复现排序 + MD5 签名
新建 sign.js:
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function buildSign(params, secret) {
const keys = Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort();
const plain = keys.map((k) => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
return md5(plain);
}
function buildPayload(data, secret) {
const payload = {
...data,
ts: 1720000000,
nonce: "abc123",
};
payload.sign = buildSign(payload, secret);
return payload;
}
const payload = buildPayload(
{
page: 1,
size: 20,
keyword: "phone",
},
"my_secret_key"
);
console.log("payload =>", payload);
运行:
node sign.js
方案二:直接发请求验证
新建 request.js:
const crypto = require("crypto");
const https = require("https");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function buildSign(params, secret) {
const keys = Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort();
const plain = keys.map((k) => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
return md5(plain);
}
function postJson(url, data) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const body = JSON.stringify(data);
const req = https.request(
{
hostname: u.hostname,
path: u.pathname + u.search,
method: "POST",
port: 443,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
let buf = "";
res.on("data", (chunk) => (buf += chunk));
res.on("end", () => resolve({ status: res.statusCode, body: buf }));
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
async function main() {
const payload = {
page: 1,
size: 20,
keyword: "phone",
ts: 1720000000,
nonce: "abc123",
};
payload.sign = buildSign(payload, "my_secret_key");
const result = await postJson("https://example.com/api/search", payload);
console.log(result);
}
main().catch(console.error);
如果目标站点还校验 Cookie、Header、Referer、设备指纹,这段代码还需要补全对应上下文,否则签名即使对,服务端也可能拒绝。
更贴近真实项目的抽离技巧
实际逆向时,签名函数通常不会这么清爽。更常见的是:
- webpack 模块包装
- 变量名被压缩成
a,b,c,d - 核心算法被拆到多个函数
- 用了浏览器环境对象,如
window,document,navigator
这时建议按下面方式抽离。
1. 先找“最小依赖闭包”
不要把整份打包文件复制到 Node.js 里跑。优先提取:
- 参数排序函数
- 编码函数
- hash 函数
- 入口函数
例如浏览器里是:
function n(e) {
return Object.keys(e).sort().map(function(t) {
return t + "=" + e[t];
}).join("&");
}
function r(e) {
return window.btoa(e);
}
function s(e) {
return md5(r(n(e)) + "secret");
}
抽离到 Node.js 时,如果只依赖 window.btoa,那就替换成 Buffer:
function btoaNode(text) {
return Buffer.from(text, "utf8").toString("base64");
}
2. 先保证“中间结果一致”
很多人一上来就追求最终 sign 一致,结果调半天找不到差异。
更稳的做法是逐层打印:
- 排序后的 key 列表
- 拼接后的明文串
- 编码结果
- 最终摘要
例如:
const keys = Object.keys(params).sort();
console.log("keys =>", keys);
const plain = keys.map(k => `${k}=${params[k]}`).join("&");
console.log("plain =>", plain);
const encoded = Buffer.from(plain, "utf8").toString("base64");
console.log("encoded =>", encoded);
const sign = md5(encoded + "secret");
console.log("sign =>", sign);
只要某一步和浏览器不一致,问题就缩小了。
逐步验证清单
我自己做这类问题时,通常会按这个清单过一遍:
- 请求方法、路径、参数位置是否一致
-
sign参与计算的字段是否完整 - 字段顺序是否一致
- 空值、
null、undefined是否被过滤 - 数字和字符串是否发生隐式转换
- 时间戳单位是秒还是毫秒
-
nonce是否每次重新生成 - JSON 序列化是否稳定
- URL 编码是否在签名前还是签名后
- 大小写是否一致(尤其十六进制摘要)
- 服务端是否还校验 Header/Cookie/UA/Referer
这个清单看起来普通,但非常实用。很多签名不一致,最后都死在这些细节上。
常见坑与排查
坑一:你复现的是“算法”,但漏了“上下文”
这是最常见的。
有些站点的签名并不只依赖业务参数,还依赖:
- 登录态 Cookie
- 本地存储 token
- 用户代理
- 页面路径
- 指纹值
- 某个初始化时下发的动态盐值
如果你只把 sign 算法抠出来,很可能得到“形式正确但服务端不认”的结果。
排查方法
在浏览器断点时,重点看签名函数入参是否只有业务对象。若不是,要把这些外部依赖一起记录下来。
坑二:时间戳单位搞错
常见三种:
- 秒:
1626490800 - 毫秒:
1626490800123 - 微秒/字符串形式的变体
排查方法
观察浏览器真实请求值,不要猜。
坑三:参数顺序不一致
很多签名算法强依赖顺序,哪怕字段完全一样,只要顺序不同,结果就不同。
排查方法
确认是:
- 按字典序排序
- 按原始插入顺序
- 按服务端约定顺序
坑四:对象序列化不稳定
如果签名字段里包含嵌套对象,比如:
{
"filter": {
"price": 100,
"brand": "xx"
}
}
那么你得确认前端是否用了:
JSON.stringify- 自定义排序后再 stringify
- QueryString 风格拍平
排查方法
在浏览器里打印参与签名的最终明文串,而不是只看原始对象。
坑五:浏览器环境函数在 Node 中缺失
例如代码依赖:
windowdocumentnavigatoratob/btoacrypto.subtle
排查方法
优先做最小替换,不要急着上 jsdom。很多时候只需要补几个函数。
坑六:混淆后函数被动态调用
有些代码会写成:
var x = ["md5", "sha256"];
a[x[0]](...)
或者字符串被解码后再调用。
排查方法
在关键函数处打断点,结合 Call Stack 看真实调用链,比静态硬读快得多。
Mermaid:排查分支图
flowchart TD
A[签名不通过] --> B{浏览器与本地参数一致吗}
B -- 否 --> C[先对齐请求参数/顺序/编码]
B -- 是 --> D{中间明文串一致吗}
D -- 否 --> E[检查排序 过滤 序列化]
D -- 是 --> F{摘要算法结果一致吗}
F -- 否 --> G[检查编码算法 密钥 大小写]
F -- 是 --> H[检查Cookie Header 指纹 时间窗口]
安全/性能最佳实践
这部分我想多说一点。因为很多同学学会“复现签名”后,容易只盯着跑通,忽略工程质量。
1. 把“定位逻辑”和“业务调用”分离
建议把代码拆成两层:
sign-core.js:只管签名算法client.js:只管请求发送
这样后面算法变更时,你只需要改一个文件。
// sign-core.js
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function buildSign(params, secret) {
const keys = Object.keys(params).filter(k => params[k] !== "").sort();
const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
return md5(plain);
}
module.exports = { buildSign };
2. 记录中间结果,便于回归验证
前端签名经常会改。建议保留调试日志开关:
function buildSign(params, secret, debug = false) {
const keys = Object.keys(params).sort();
const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
if (debug) {
console.log("[debug] keys:", keys);
console.log("[debug] plain:", plain);
}
return require("crypto").createHash("md5").update(plain, "utf8").digest("hex");
}
这样站点一升级,你能很快比较差异。
3. 不要把敏感密钥硬编码进仓库
如果你是在做自有系统联调,签名用到的密钥不要直接提交到 Git。
更好的方式:
- 环境变量
- 配置中心
- 本地
.env文件(不提交)
4. 控制请求频率
即使是合法测试,也要避免高频压测到线上服务。尤其签名逻辑里如果包含昂贵计算,多线程并发要评估 CPU 开销。
5. 优先使用官方或合规方式
如果目标系统有:
- OpenAPI
- SDK
- 正式测试环境
- 文档化签名规则
优先走官方方式。逆向应该是排障手段,不是第一选择。
一个更完整的本地复现模板
如果你已经确认逻辑是“排序 + 时间戳 + nonce + md5”,可以直接套这个模板改:
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function normalizeParams(params) {
return Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
.sort()
.reduce((acc, k) => {
acc[k] = params[k];
return acc;
}, {});
}
function buildPlainText(params, secret) {
const normalized = normalizeParams(params);
const plain = Object.keys(normalized)
.map((k) => `${k}=${normalized[k]}`)
.join("&");
return `${plain}&key=${secret}`;
}
function buildSign(params, secret) {
const plain = buildPlainText(params, secret);
return md5(plain);
}
function buildRequestData(data, options = {}) {
const ts = options.ts || Math.floor(Date.now() / 1000);
const nonce = options.nonce || Math.random().toString(16).slice(2, 10);
const secret = options.secret || "my_secret_key";
const payload = {
...data,
ts,
nonce,
};
const plain = buildPlainText(payload, secret);
const sign = buildSign(payload, secret);
return {
payload: {
...payload,
sign,
},
debug: {
plain,
sign,
},
};
}
// demo
const result = buildRequestData(
{ page: 1, size: 20, keyword: "phone" },
{ ts: 1720000000, nonce: "abc123", secret: "my_secret_key" }
);
console.log(JSON.stringify(result, null, 2));
这份模板的好处是:能同时输出 payload 和中间明文,非常适合对照浏览器结果。
实战建议:如何判断“该继续逆向”还是“该换思路”
中级工程师最需要的,不只是技术动作,还有判断力。
以下情况,继续逆向是合理的:
- 你在做自有系统联调
- 你在定位接口异常,且只有前端能跑通
- 你在验证安全方案是否合理
- 你已获得合法授权进行测试
以下情况,建议换成官方/合规方案:
- 站点签名依赖频繁更新的动态脚本
- 强绑定指纹、会话、风控设备信息
- 签名之外还有行为校验
- 已有开放接口或 SDK 可用
因为这时候,问题已经不是“把 sign 算出来”,而是整套风控上下文。继续硬抠,投入产出比会很差。
总结
复现前端签名参数生成逻辑,核心不是“会几种加密算法”,而是掌握一套稳定的定位路径:
- 先在 Network 里识别签名字段
- 从请求发起点反推,而不是死读混淆代码
- 通过断点和 hook 抓到签名生成时刻
- 抽离最小可运行代码到 Node.js
- 逐层比对中间结果,而不是只看最终 sign
- 别忽略 Cookie、Header、时间戳、nonce、指纹等上下文
如果你只记住一句话,那就是:
签名逆向的关键,不在“算法有多复杂”,而在“你有没有把生成链路拆开验证”。
这是我自己踩坑之后总结出来最有效的方法。很多看起来很“玄学”的前端签名,拆到最后其实就几步:排序、拼接、编码、摘要。难的是定位,不是计算。
只要你把“请求入口 -> 参数组装 -> 签名函数 -> 中间结果验证”这条链跑通,大多数中等复杂度的前端签名问题,都能比较稳地复现。