Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用
很多中级开发者做接口自动化时,最容易卡住的不是抓包,而是抓到了请求却复现不出来。参数、Header、Cookie 看起来都齐了,结果一调用就是:
401 Unauthorized403 Forbiddensign invalidtimestamp expiredillegal request
这类问题,本质上往往不是“接口不能调”,而是前端在发请求之前做了一层签名计算,你漏掉了。
这篇文章我想用“实战拆解”的方式,带你完整走一遍:
如何定位前端签名算法、理解它的组成、在 Node.js 中复现,并最终用于接口自动化调用。
先提醒一句:本文仅用于合法授权的安全测试、接口联调、自动化测试与学习研究,不适用于绕过权限、攻击或破坏他人系统。
背景与问题
现代 Web 应用里,前端签名非常常见。它通常出现在以下场景:
- App/H5 请求业务接口前,对参数做
sign - 请求头里有
x-sign、x-token、x-auth等动态字段 - 请求中带
timestamp、nonce、traceId - 参数可能被排序、拼接、加密、哈希
- 部分系统还会叠加设备指纹、环境检测、混淆代码
中级开发者经常遇到的典型误区有几个:
- 以为抓到请求就等于能复现
- 只盯着 Network,不去看 Initiator 和源码调用栈
- 看到混淆代码就放弃
- 直接复制浏览器里的 sign 值,忽略 sign 是动态生成的
- 没有拆分签名输入项,导致排查无从下手
如果你的目标是“自动化调用接口”,那关键不是拿到一次可用请求,而是建立一个稳定可复现的签名生成过程。
前置知识与环境准备
建议你先具备这些基础:
- 会用 Chrome DevTools
- 了解基本 HTTP 请求结构
- 能读懂中等复杂度 JavaScript
- 知道
md5 / sha1 / sha256 / hmac这类哈希概念 - 能用 Node.js 写脚本
本文示例环境:
- Chrome / Edge 最新版
- Node.js 18+
- Python 3.10+(可选,用于自动化调用)
- 抓包工具可选:Charles / Fiddler / mitmproxy
- JS 格式化工具可选:Prettier、在线 AST 可视化工具
核心原理
前端签名算法,大多数都逃不开下面这几个组成部分:
-
原始参数
- query 参数
- body 参数
- 固定 appKey / version
- 用户态信息(token / uid)
-
动态参数
- 时间戳
timestamp - 随机串
nonce - 请求路径
path - 设备信息 / UA / referer
- 时间戳
-
规范化处理
- 按 key 排序
- 过滤空值
- URL 编码
- JSON 序列化
- 拼接成固定字符串
-
摘要或加密
md5(str)sha256(str)hmacSHA256(str, secret)- AES/RSA 后再编码
-
输出格式
- 小写十六进制
- 大写十六进制
- Base64
- 再次 URL encode
一个很常见的签名过程可以抽象成:
sign = hash(sort(params) + timestamp + nonce + secret)
但真正难点在于:
你得先知道它到底排了什么、拼了什么、用了什么 secret、在哪一层做的 hash。
先建立排查思路:别上来就啃混淆代码
我平时做这类问题,会先按这个顺序来:
flowchart TD
A[打开页面并抓到目标请求] --> B[确认失败接口的动态字段]
B --> C[在 Network 中查看 Header/Query/Body]
C --> D[定位 sign timestamp nonce 等字段]
D --> E[查看 Initiator 或 Sources 全局搜索字段名]
E --> F[找到请求封装层 axios/fetch/XHR]
F --> G[向上追踪签名函数调用]
G --> H[还原签名输入和算法]
H --> I[在 Node.js 中独立复现]
I --> J[自动化脚本验证]
这个顺序的价值在于:
- 先从“结果”看有哪些动态字段
- 再从“发起位置”找谁生成了这些字段
- 最后才分析混淆逻辑
很多时候,签名逻辑并不在业务页面里,而在:
- axios 请求拦截器
- 公共 SDK
- webpack 打包后的工具模块
- 动态加载 chunk
- wasm 或第三方风控脚本
实战场景设定
为了让流程清晰,我们假设有这样一个请求:
POST /api/order/list
Content-Type: application/json
x-sign: 9f3f...
x-timestamp: 1711111111111
x-nonce: 6ab2c1d8
{"page":1,"pageSize":20,"status":"paid"}
抓包后你发现:
- 直接重放请求会失败
x-sign每次都变x-timestamp过期后就失效- body 里参数稍微变动,sign 也跟着变
这时基本就能判断:
签名至少和 body + timestamp + nonce 有关。
第一步:在浏览器里定位签名生成位置
1. 从 Network 面板倒查
在 Chrome DevTools 的 Network 中点开目标请求,优先看:
- Request Headers:有没有
x-sign - Payload:body 是否参与签名
- Initiator:是谁发起的请求
如果 Initiator 能直接跳源码,这是最快的入口。
2. 全局搜索关键字段
在 Sources 里全局搜索:
x-signsigntimestampnonce- 接口路径
/api/order/list
常见情况:
- 字段名没混淆,函数名混淆了
- 字段名也混淆了,但请求封装层还留有痕迹
- sign 不是直接赋值,而是统一拦截器里计算
3. 优先找请求拦截器
例如常见写法:
axios.interceptors.request.use((config) => {
const ts = Date.now().toString();
const nonce = randomString(8);
const sign = buildSign(config.url, config.data, ts, nonce);
config.headers["x-timestamp"] = ts;
config.headers["x-nonce"] = nonce;
config.headers["x-sign"] = sign;
return config;
});
如果你找到的是这种地方,恭喜,逆向难度直接下降一大截。
第二步:识别签名输入项
真正的难点不是“看到 buildSign”,而是确认它输入了什么。
一个典型函数可能长这样:
function buildSign(url, data, ts, nonce) {
const payload = normalize(data);
const str = `${url}|${payload}|${ts}|${nonce}|appSecret123`;
return sha256(str);
}
但实际项目里会更绕一点,比如:
function z(a, b, c, d) {
var s = p(a) + "&" + q(b) + "&" + c + "&" + d + "&" + m();
return n(s).toUpperCase();
}
这时要做的不是“猜”,而是逐项验证。
我的建议:把签名拆成四层来看
- 路径是否参与
- 参数是否排序
- 空值是否过滤
- secret 从哪来
可以画成一个更清晰的关系图:
sequenceDiagram
participant Page as 页面逻辑
participant Interceptor as 请求拦截器
participant Sign as 签名函数
participant Hash as 哈希算法
participant API as 服务端接口
Page->>Interceptor: 发起 /api/order/list 请求
Interceptor->>Sign: 传入 url、body、timestamp、nonce
Sign->>Sign: 参数排序/序列化/拼接 secret
Sign->>Hash: 计算 sha256/md5/hmac
Hash-->>Sign: 返回签名串
Sign-->>Interceptor: x-sign
Interceptor->>API: 携带签名后的请求
API-->>Page: 返回业务数据
第三步:动态调试,而不是静态硬读
如果代码不太好读,我更推荐你直接打断点。
常用断点策略
1. 在请求发送点断住
对这些位置下断点:
fetchXMLHttpRequest.prototype.send- axios 请求拦截器
- 设置请求头的位置
2. 对可疑函数下断点
比如你已经找到:
headers["x-sign"] = z(url, data, ts, nonce)
那就直接在 z() 里断住,看:
- 参数
a/b/c/d分别是什么 - 中间变量
s长什么样 - 最后调用的
n()是 md5、sha256 还是 hmac
3. 用 Console 验证中间值
这一步特别重要。
我踩过的坑里,很多不是算法错,而是:
- 我以为 body 是对象序列化,实际上是压缩后的 JSON 字符串
- 我以为参数按字母排序,实际上保留原顺序
- 我以为 hash 输出小写,实际上转成了大写
所以一定要把中间值打印出来。
第四步:识别常见签名实现模式
下面是我在项目里最常见到的几类。
模式一:排序 + 拼接 + MD5
function signByMd5(params, secret) {
const keys = Object.keys(params).sort();
const str = keys
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
.map((k) => `${k}=${params[k]}`)
.join("&");
return md5(`${str}&secret=${secret}`);
}
模式二:JSON 序列化 + 时间戳 + SHA256
function signBySha256(path, body, ts, secret) {
const payload = JSON.stringify(body);
return sha256(`${path}|${payload}|${ts}|${secret}`);
}
模式三:HMAC
function signByHmac(message, secret) {
return hmacSHA256(message, secret);
}
模式四:先加密再摘要
function complexSign(data, aesKey, hashSecret) {
const encrypted = aesEncrypt(JSON.stringify(data), aesKey);
return sha256(encrypted + hashSecret);
}
如果你看到明显的 CryptoJS、md5、sha256 字样,算是好消息。
如果看不到,也别慌,可以通过输出长度和格式来猜:
- 32 位十六进制:多半是 MD5
- 40 位:可能 SHA1
- 64 位:多半 SHA256
- Base64 串:可能是 HMAC 或加密结果
实战代码:从浏览器逻辑复现到 Node.js 自动化
下面给一个可运行示例。
我们假设前端签名规则是:
- body 参数按 key 排序
- 过滤空值
- 拼接成
k=v&k2=v2 - 再拼上
path|timestamp|nonce|secret - 取 SHA256 小写
1. Node.js 复现签名
// sign.js
const crypto = require("crypto");
function normalizeParams(obj) {
return Object.keys(obj)
.sort()
.filter((key) => obj[key] !== undefined && obj[key] !== null && obj[key] !== "")
.map((key) => `${key}=${formatValue(obj[key])}`)
.join("&");
}
function formatValue(value) {
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function sha256(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
function randomNonce(length = 8) {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function buildSign({ path, body, timestamp, nonce, secret }) {
const normalized = normalizeParams(body);
const raw = `${path}|${normalized}|${timestamp}|${nonce}|${secret}`;
const sign = sha256(raw);
return { normalized, raw, sign };
}
module.exports = {
buildSign,
randomNonce,
};
2. 自动化调用接口
// request.js
const axios = require("axios");
const { buildSign, randomNonce } = require("./sign");
async function main() {
const path = "/api/order/list";
const body = {
page: 1,
pageSize: 20,
status: "paid",
};
const timestamp = Date.now().toString();
const nonce = randomNonce(8);
const secret = "appSecret123";
const { normalized, raw, sign } = buildSign({
path,
body,
timestamp,
nonce,
secret,
});
console.log("规范化参数:", normalized);
console.log("签名原文:", raw);
console.log("签名结果:", sign);
const resp = await axios.post(`https://example.com${path}`, body, {
headers: {
"content-type": "application/json",
"x-timestamp": timestamp,
"x-nonce": nonce,
"x-sign": sign,
},
timeout: 10000,
});
console.log(resp.data);
}
main().catch((err) => {
if (err.response) {
console.error("状态码:", err.response.status);
console.error("响应体:", err.response.data);
} else {
console.error(err.message);
}
});
安装依赖:
npm install axios
运行:
node request.js
如果前端用的是 CryptoJS,如何对照复现
很多页面签名会直接写成这样:
const sign = CryptoJS.SHA256(raw).toString();
Node.js 对应写法通常是:
const crypto = require("crypto");
const sign = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
如果是 HMAC:
前端:
const sign = CryptoJS.HmacSHA256(raw, secret).toString();
Node.js:
const crypto = require("crypto");
const sign = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
这里一个特别容易错的点是:
CryptoJS 的 WordArray、编码方式、toString 输出格式,要和 Node 保持一致。
逐步验证清单
建议你不要一步到位,而是按下面清单逐项验证。
验证 1:时间戳是否一致
console.log(Date.now().toString());
如果服务端要求秒级,而你传了毫秒级,签名一定错。
验证 2:参数顺序是否一致
浏览器里:
console.log(Object.keys(body).sort());
Node 里也打印一遍,确保顺序一样。
验证 3:JSON 序列化结果是否一致
console.log(JSON.stringify(body));
看是否存在:
- key 顺序不同
- 空格不同
- 布尔值/数字被转成字符串
- 中文编码差异
验证 4:签名原文是否一致
这一步最关键。
浏览器调试时打印:
console.log(raw);
Node 里也打印:
console.log(raw);
如果 raw 完全一致,而签名结果不同,问题就只可能在:
- 哈希算法不对
- 编码不对
- 输出格式不对
验证 5:输出格式是否一致
例如:
- 浏览器输出大写,你 Node 输出小写
- 浏览器输出 Base64,你 Node 输出 hex
- 浏览器 hash 前做了 UTF-8 编码处理
常见坑与排查
这部分我尽量写得接地气一点,因为很多问题真不是“大原理”,就是小细节。
1. 误把“请求参数”当成“签名参数”
有些字段不会真正发给服务端,但会参与签名,比如:
- 固定 appId
- 内置版本号
- 环境标记
- secret 派生值
排查方式:
看签名函数入参,不要只看最终请求。
2. 时间戳单位错了
有些系统要:
- 秒:
Math.floor(Date.now() / 1000) - 毫秒:
Date.now() - 字符串,不是数字
现象:
- 签名看起来对,但服务端报过期
- 同一个 sign 很快失效
3. 排序规则不是你想的那样
不是所有系统都用 Object.keys().sort()。
有的规则是:
- ASCII 排序
- 忽略大小写排序
- 只对 query 排序,不对 body 排序
- 嵌套对象递归排序
可以把这个过程抽象成:
stateDiagram-v2
[*] --> 收集参数
收集参数 --> 过滤空值
过滤空值 --> 排序
排序 --> 序列化
序列化 --> 拼接动态字段
拼接动态字段 --> 哈希
哈希 --> 输出编码
输出编码 --> [*]
4. body 实际参与的是“字符串”,不是对象
前端可能这样做:
const payload = JSON.stringify(data);
const sign = sha256(path + payload + ts);
fetch(url, { body: payload });
如果你在 Node 里对对象直接排序拼接,当然对不上。
5. Header 参与了签名
有些系统把这些也算进去:
User-AgentOriginRefererAuthorizationx-device-id
这种场景下,如果你只复现 body 和 query,会始终失败。
6. 混淆后函数看不懂,就硬猜算法
不建议。
更稳的做法是:
- 找最终设置 Header 的地方
- 断点看输入输出
- 识别 hash 长度和调用链
- 必要时 hook 原生函数
7. Webpack 模块太多,搜不到函数定义
这是前端逆向里很常见的烦躁时刻。
你搜 x-sign 只有一处引用,真正实现藏在模块加载器里。
应对办法:
- 从调用栈往上找模块 ID
- 格式化打包文件
- 观察
__webpack_require__依赖 - 在运行时重写可疑函数做日志输出
进阶技巧:Hook 关键函数快速拿到原文
如果页面比较复杂,最省时间的方法往往不是硬读代码,而是直接 hook。
Hook fetch
// 在 DevTools Console 中执行
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log("fetch args:", args);
return rawFetch.apply(this, args);
};
Hook XMLHttpRequest
(function () {
const oldOpen = XMLHttpRequest.prototype.open;
const oldSend = XMLHttpRequest.prototype.send;
const oldSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return oldOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
if (!this._headers) this._headers = {};
this._headers[key] = value;
return oldSetHeader.call(this, key, value);
};
XMLHttpRequest.prototype.send = function (body) {
console.log("XHR URL:", this._url);
console.log("XHR Method:", this._method);
console.log("XHR Headers:", this._headers);
console.log("XHR Body:", body);
return oldSend.call(this, body);
};
})();
Hook 哈希函数
如果页面使用 CryptoJS,可以尝试:
(function () {
if (!window.CryptoJS || !CryptoJS.SHA256) return;
const oldSha256 = CryptoJS.SHA256;
CryptoJS.SHA256 = function (msg) {
console.log("SHA256 input:", msg);
const result = oldSha256.call(this, msg);
console.log("SHA256 output:", result.toString());
return result;
};
})();
这类方法非常适合确认:
- 签名原文到底是什么
- 算法是哪个
- 输出格式是什么
安全/性能最佳实践
这部分不只是“写给开发者”,也写给做自动化的人。
1. 不要把 secret 硬编码进自动化脚本仓库
如果你是内部联调或测试项目:
- 把 secret 放环境变量
- 区分测试环境和生产环境
- 不要提交到 Git
示例:
const secret = process.env.API_SIGN_SECRET;
if (!secret) {
throw new Error("缺少 API_SIGN_SECRET 环境变量");
}
2. 给签名逻辑做可观测性日志,但避免泄漏敏感信息
建议记录:
- 请求路径
- 时间戳
- nonce
- 签名前原文的摘要
- 签名结果前几位
不要完整打印:
- secret
- 用户 token
- 整体敏感 payload
3. 自动化调用要控制并发和重试
签名接口经常有风控限制:
- 同一 nonce 不可重复
- 时间窗口很短
- 高频请求触发限流
建议:
- 每次请求生成新 nonce
- 时间戳实时生成
- 失败后只做有限重试
- 保持与真实客户端相近的 Header
4. 做好签名函数单元测试
把“逆向复现”沉淀成可回归验证的代码,而不是一次性脚本。
// sign.test.js
const { buildSign } = require("./sign");
const fixed = {
path: "/api/order/list",
body: { page: 1, pageSize: 20, status: "paid" },
timestamp: "1711111111111",
nonce: "6ab2c1d8",
secret: "appSecret123",
};
const result = buildSign(fixed);
console.log(result);
只要前端版本一更新,你就能快速发现:
- 排序规则变了
- secret 派生变了
- 拼接格式变了
5. 注意法律、授权与边界
这是必须强调的边界:
- 只能对自己拥有、获授权、用于测试/联调的系统做分析
- 不要绕过身份认证、计费、频控、访问控制
- 不要将签名复现用于批量抓取、攻击、数据窃取等行为
技术上能做到,不代表业务上可以做。
一个更稳的落地方法:先“复现签名”,再“封装调用”
我很建议把代码拆成两层:
-
签名层
- 纯函数
- 输入固定,输出确定
- 方便测试和比对
-
请求层
- 负责发 HTTP
- 注入 timestamp / nonce / sign
- 负责重试、超时、日志
这样你后面维护起来会轻松很多。结构可以像这样:
classDiagram
class SignBuilder {
+normalizeParams(obj)
+buildRaw(path, body, ts, nonce, secret)
+sign(raw)
}
class ApiClient {
+genTimestamp()
+genNonce()
+post(path, body)
}
ApiClient --> SignBuilder
这比把所有逻辑揉在一个脚本里强得多。
尤其当你后面要接多个接口时,收益会非常明显。
总结
前端签名逆向这件事,说复杂也复杂,说简单也简单。真正决定成败的,往往不是你会不会某个哈希算法,而是你有没有按正确路径拆问题。
你可以记住这条主线:
- 先抓到真实请求
- 定位 sign/timestamp/nonce 等动态字段
- 从 Initiator、拦截器、请求封装层倒查
- 动态断点确认签名原文
- 在 Node.js 中一比一复现
- 把复现结果沉淀成可测试、可复用的自动化模块
如果你现在就准备动手,我建议按这个最小闭环来做:
- 先别急着自动化整套流程
- 先固定一组参数
- 打印浏览器中的签名原文和签名结果
- 在 Node.js 中做到完全一致
- 再接入实际 HTTP 调用
只要你能把“签名原文”对齐,后面 80% 的问题都会迎刃而解。
最后再强调一次边界:
本文方法仅适用于合法授权的测试、联调和安全研究。
在这个前提下,掌握前端签名定位与复现能力,会让你的接口自动化能力直接上一个台阶。