Web逆向实战:中级开发者如何定位并复现前端签名参数生成逻辑
很多中级开发者第一次做 Web 逆向时,最容易卡住的点不是“不会写代码”,而是找不到真正的签名生成位置。页面里一堆打包压缩后的 JavaScript,网络请求又带着 sign、token、nonce、t、ts 之类参数,看起来每个都像关键点,最后很容易陷入“全都像,又全都不是”的状态。
这篇文章我会按我自己实战里最常用的一条路线,带你完整走一遍:如何定位前端签名参数的生成逻辑、如何验证自己的判断、以及如何在本地稳定复现。重点不是某个具体站点,而是一套可迁移的方法。
说明:本文内容用于前端调试、接口联调、安全研究与教学分析。请在合法合规、获得授权的前提下使用。
背景与问题
典型场景一般长这样:
- 页面请求某个接口时会带上
sign - 你直接复制请求去重放,返回“签名错误”或“非法请求”
- 刷新页面后同样的业务参数,
sign却每次都不同 - 前端代码经过 webpack 打包、混淆甚至加了简单反调试
对于中级开发者来说,真正的难点通常有三个:
- 不知道从哪里下手
- 定位到疑似代码后,无法确认是不是最终签名点
- 即使找到了算法,也复现不出与浏览器一致的结果
所以本文的目标很明确:
- 学会从请求出发,倒推签名生成位置
- 理解前端签名常见组成方式
- 用可运行代码复现一个典型签名流程
- 知道排查失败时优先看什么
前置知识
开始之前,建议你至少熟悉这些内容:
- Chrome DevTools 基本使用
- JavaScript 基础语法
- 浏览器 Network / Sources / Debugger 面板
- 常见摘要算法概念:MD5 / SHA256 / HMAC
- Node.js 基本运行方式
如果你已经做过接口联调、看过 webpack 打包产物,那阅读会很顺。
环境准备
本文建议准备以下环境:
- Chrome 浏览器
- Node.js 16+
- 一个可编辑的本地目录
- DevTools 打开 Source map(如果目标站没关的话会轻松很多)
安装 Node 后可直接测试:
node -v
npm -v
如果你需要在 Node 中计算哈希,可直接使用内置 crypto,不必额外安装依赖。
核心原理
前端签名本质上通常不是“神秘黑魔法”,而是下面几类材料的组合:
- 业务参数,如
page=1&keyword=test - 时间戳,如
ts=1710000000 - 随机串,如
nonce=abc123 - 固定盐值,如某个常量字符串
- 用户态信息,如
token、uid - 某种序列化规则,如按 key 排序后拼接
- 最后套一层摘要算法,如
md5(...)、sha256(...)
很多站点会把逻辑写成:
sign = hash(sort(params) + secret + ts + nonce)
也可能是:
sign = hash(hash(body) + token + path + ts)
或者:
sign = HMAC(secret, method + url + canonicalQuery + bodyDigest + ts)
一个关键认知
真正难的不是算法本身,而是“输入材料”和“拼接顺序”。
我见过很多同学已经猜到了是 md5,但一直复现不出来,最后发现只是因为:
- 参数排序不一致
- 数字和字符串类型不一致
- URL 编码时机不同
- 时间戳单位是秒不是毫秒
- 某些空值字段前端偷偷过滤掉了
所以做逆向时,要把关注点放在:
- 参与签名的字段有哪些
- 字段进入签名前是否被转换
- 字段顺序如何确定
- 最终调用的 hash/hmac 是什么
定位思路总览
先给你一张总图,建立整体路线感。
flowchart TD
A[抓到目标请求] --> B[观察 Query/Header/Body 中可疑参数]
B --> C[全局搜索 sign/token/nonce/ts]
C --> D[在 XHR/fetch 发送前断点]
D --> E[回溯调用栈]
E --> F[找到参数拼接函数]
F --> G[确认摘要算法与输入顺序]
G --> H[本地复现并对比]
H --> I[逐项排查差异]
这张图里最重要的两步是:
- 发送前断点
- 回溯调用栈
很多时候你不需要一开始就硬啃整个混淆包,直接在请求即将发出时拦住它,往上追调用链,效率会高很多。
背景与问题:如何判断哪个参数是真签名
一个请求里可能有好几个“看起来像签名”的参数,例如:
GET /api/search?q=phone&page=1&ts=1710000000&nonce=8f3a2c&sign=5f4dcc3b5aa765d61d8327deb882cf99
这里通常可以这样判断:
ts:大概率时间戳nonce:大概率随机串sign:大概率最终校验值- 也有可能
token并不只是登录态,而是参与签名的材料
经验上,优先关注“变化但不可读”的字段。
比如 sign 看起来像 32 位十六进制串,很可能就是 MD5;64 位可能是 SHA256;带 =、/、+ 的可能是 Base64 编码结果。
核心原理:从请求发送点往回追
前端请求常见由这几类 API 发出:
fetchXMLHttpRequest- axios
- jQuery.ajax
- 某些站点自封装请求层
最稳的一招:在请求发送处下断点
如果页面使用 XHR,可以在 DevTools 中:
- 打开
Sources - 找到右侧
XHR/fetch Breakpoints - 添加目标接口关键字,比如
/api/search - 触发页面请求
浏览器会在请求发出前暂停。此时你要做的是:
- 看当前作用域变量
- 看调用栈
- 往上逐层点,找到构造参数对象的地方
下面用时序图表示这个过程:
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名函数
participant R as 请求封装层
participant N as 网络层
U->>P: 点击搜索
P->>S: 传入业务参数
S->>S: 排序/拼接/摘要
S-->>P: 返回 sign
P->>R: 组装 headers/query/body
R->>N: fetch/XHR 发出请求
你真正要抓住的是 P -> S 和 S -> S 这两段。
逐步实战:定位签名生成逻辑
下面用一个典型示例来演示完整过程。假设页面请求参数如下:
GET /api/search?q=phone&page=1&ts=1710000000&nonce=8f3a2c&sign=xxxxxxxx
第一步:从 Network 看请求差异
先手动发两次相同请求,观察:
q、page不变ts变化nonce变化sign变化
初步判断:
sign很可能依赖ts与nonce- 也可能依赖
q、page - 可能还隐式依赖
token或 cookie
建议做个小表格:
| 字段 | 是否变化 | 猜测作用 |
|---|---|---|
| q | 否 | 业务参数 |
| page | 否 | 业务参数 |
| ts | 是 | 时间戳 |
| nonce | 是 | 随机数 |
| sign | 是 | 签名结果 |
第二步:全局搜索关键词
在 Sources 里优先搜索:
signnoncets- 请求路径关键字,如
/api/search md5、sha1、sha256CryptoJSdigestHmac
如果搜 sign 命中太多,可以改搜请求路径,通常能更快定位到请求封装处。
第三步:在 fetch/XHR 断住
一旦断住,重点看:
- 当前发送的 URL 是在哪里被拼出来的
sign是提前挂到params上的,还是在请求拦截器里统一追加的- axios 场景下常出现在:
- request interceptor
- transformRequest
- 单独的
sign()工具函数
第四步:回溯调用栈
这一阶段我常做的事是:
- 从当前栈帧往上点
- 找到第一次出现“可读变量名”的地方
- 观察这个函数的输入和输出
例如你可能看到类似逻辑:
const params = {
q: keyword,
page,
ts: Date.now(),
nonce: genNonce()
}
params.sign = makeSign(params)
这时候基本已经进入核心区域了。
实战代码(可运行)
下面我构造一个“典型前端签名”的示例,模拟网页中的生成逻辑,然后在 Node.js 中复现。你可以直接运行,帮助建立“定位后该怎么验证”的感觉。
示例签名规则
假设站点前端的规则是:
- 取业务参数对象
- 过滤掉值为
undefined/null/''的字段 - 按 key 字典序排序
- 拼成
k=v&k=v... - 末尾拼接固定盐值
&secret=demo_secret - 对完整字符串做 MD5
- 得到最终
sign
浏览器中的前端代码示意
function normalizeParams(params) {
const clean = {};
Object.keys(params).forEach((key) => {
const value = params[key];
if (value !== undefined && value !== null && value !== '') {
clean[key] = String(value);
}
});
return clean;
}
function buildSignString(params) {
const clean = normalizeParams(params);
const sortedKeys = Object.keys(clean).sort();
const pairs = sortedKeys.map((key) => `${key}=${clean[key]}`);
return `${pairs.join('&')}&secret=demo_secret`;
}
假设最终签名调用的是一个 MD5 实现:
function makeSign(params) {
const raw = buildSignString(params);
return md5(raw);
}
Node.js 复现版本
新建 sign-demo.js:
const crypto = require('crypto');
function normalizeParams(params) {
const clean = {};
Object.keys(params).forEach((key) => {
const value = params[key];
if (value !== undefined && value !== null && value !== '') {
clean[key] = String(value);
}
});
return clean;
}
function buildSignString(params) {
const clean = normalizeParams(params);
const sortedKeys = Object.keys(clean).sort();
const pairs = sortedKeys.map((key) => `${key}=${clean[key]}`);
return `${pairs.join('&')}&secret=demo_secret`;
}
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function makeSign(params) {
const raw = buildSignString(params);
return md5(raw);
}
function main() {
const params = {
q: 'phone',
page: 1,
ts: 1710000000,
nonce: '8f3a2c',
};
const signString = buildSignString(params);
const sign = makeSign(params);
console.log('signString =', signString);
console.log('sign =', sign);
}
main();
运行:
node sign-demo.js
为什么这段代码重要
因为它对应了逆向中的两个关键验证动作:
- 还原签名前原始字符串
- 确认摘要结果是否与浏览器一致
很多时候你以为“算法没问题”,其实只要把 signString 打印出来和浏览器现场值对比,就能马上发现差异。
如何在浏览器里验证你找到的函数
定位到疑似函数后,不要急着抄代码。先做现场验证。
方法一:在函数入口打断点
如果你找到 makeSign(params),就在函数第一行断住,观察:
- 入参
params是什么 - 有没有额外上下文参与,比如
token - 有没有对值做字符串化
方法二:在函数返回前打印结果
你可以在 Console 中临时调用:
copy(buildSignString(params))
或者:
console.log(buildSignString(params))
console.log(makeSign(params))
如果页面作用域里访问不到该函数,可以在断点暂停时用当前作用域直接执行表达式。
方法三:hook 摘要函数
如果全局能接触到 CryptoJS 或某个 md5 函数,可以临时 hook:
const oldMd5 = window.md5;
window.md5 = function(input) {
console.log('[md5 input]', input);
const result = oldMd5(input);
console.log('[md5 output]', result);
return result;
};
如果页面没暴露全局 md5,而是模块作用域内部函数,那就需要在具体调用点断住看参数。
更复杂一点:请求拦截器中的统一签名
很多现代前端项目并不会在业务页面里显式写 params.sign = ...,而是放在统一请求封装层。
比如 axios 场景:
service.interceptors.request.use((config) => {
const ts = Date.now();
const nonce = randomString(6);
const params = {
...(config.params || {}),
ts,
nonce
};
const sign = makeSign(params);
config.params = {
...params,
sign
};
return config;
});
这种情况下,你在业务代码搜索 sign 可能搜不到,或者只有少量命中。
这时搜请求实例名、搜 interceptor、搜 config.params 会更有效。
下面这张类图能帮助你把“业务层、签名层、请求层”的关系看清楚:
classDiagram
class PageLogic {
+search(keyword, page)
}
class RequestClient {
+request(config)
+useRequestInterceptor(fn)
}
class SignService {
+normalizeParams(params)
+buildSignString(params)
+makeSign(params)
}
PageLogic --> RequestClient : 调用请求
RequestClient --> SignService : 请求前生成签名
逐步验证清单
做复现时,我建议你按下面顺序验证,不要一上来就只盯着最终 sign:
1. 参数全集是否一致
检查:
- query 参数
- body 参数
- header 中是否有参与签名的字段
- cookie / localStorage / sessionStorage 中是否有 token 被读取
2. 参数值类型是否一致
比如:
1和'1'true和'true'null是否被过滤- 空字符串是否被保留
3. 排序规则是否一致
常见情况:
- ASCII 字典序
- 大小写敏感
- 只排序业务参数,不排序系统参数
- body JSON 内部字段也可能要排序
4. 编码方式是否一致
比如:
encodeURIComponent是在拼接前还是拼接后- 空格是
%20还是+ - 中文是否先 UTF-8 编码
5. 时间戳单位是否一致
经常踩坑:
- 前端用毫秒:
Date.now() - 服务端要求秒:
Math.floor(Date.now() / 1000)
6. 摘要函数是否一致
常见误判:
- 以为是 MD5,实际是 HMAC-MD5
- 以为是 SHA256,实际是
sha256(secret + text) - 结果输出是 hex 还是 base64
常见坑与排查
这一节很关键,我把实战里最常见的问题集中说一下。
坑一:只看最终请求,不看发送前变量
有些参数在 Network 中已经是处理后的结果,比如:
- body 被序列化了
- header 被统一注入了
- URL 被重新编码了
所以只看最终报文是不够的。
一定要在“发送前一刻”断住。
坑二:忽略了隐藏输入
签名可能依赖这些你一开始没想到的东西:
navigator.userAgent- 当前 URL path
- 环境标识,如
appId - 登录 token
- 某个固定版本号
- body 的摘要值
排查办法:
在疑似签名函数中观察闭包变量、模块常量、调用方上下文。
坑三:参数顺序搞错
这是最常见的失败原因之一。
例如你以为前端是按对象插入顺序拼接,实际是:
Object.keys(params).sort()
或者你以为连 sign 自己也参与签名,结果前端签名时明确排除了它。
坑四:误把“加密”当“签名”
很多前端字段看起来像一串乱码,但实际上可能只是:
- Base64 编码
- URL 编码
- JSON 字符串压缩
- AES 加密后的密文
而真正用于校验的 sign 是另一个字段。
不要把“看不懂”直接等同于“签名”。
坑五:浏览器环境依赖导致 Node 复现失败
前端函数可能用了:
windowdocumentbtoa/atobTextEncodercrypto.subtle
如果你直接把函数拷到 Node 里运行,很可能报错。
这时建议:
- 先把纯算法部分抽离
- 需要时做最小 polyfill
- 或者直接在浏览器控制台先复现一版
坑六:被反调试干扰
部分站点会做这些动作:
- 无限
debugger - 控制台检测
- Sources 格式化后仍极难读
- 关键逻辑动态拼接执行
我的建议是:
- 先用 XHR/fetch 断点抓调用链
- 少从“完整读懂全站代码”入手
- 必要时对关键函数做局部 hook,而不是硬解所有混淆
一个完整的排查示例
假设你本地复现出来的 sign 不对,可以按下面顺序排查:
flowchart TD
A[sign 不一致] --> B{原始拼接串一致吗?}
B -- 否 --> C[排查参数过滤/排序/编码/类型]
B -- 是 --> D{摘要算法一致吗?}
D -- 否 --> E[确认 md5/sha256/hmac/输出编码]
D -- 是 --> F{是否有隐藏输入?}
F -- 是 --> G[检查 token/path/header/body 摘要]
F -- 否 --> H[检查时间戳单位与随机串生成]
这套顺序能帮你避免“全都怀疑”的混乱状态。
安全/性能最佳实践
虽然本文讲的是“如何定位并复现”,但从工程和安全角度,也有几个很值得记住的点。
1. 不要高估前端签名的安全性
前端代码运行在用户浏览器里,逻辑最终可被观察、调试、hook。
所以前端签名更适合:
- 提高滥用门槛
- 限制脚本小子直接重放
- 做请求完整性校验的补充
它不适合作为核心安全边界。
真正关键的风控、权限、数据校验,仍应放在服务端。
2. 签名材料尽量最小化
如果你是业务开发者,设计签名时建议:
- 明确参与字段
- 固定排序规则
- 保持序列化稳定
- 避免把不必要的大对象都纳入签名
这样不仅减少性能开销,也方便联调排障。
3. 避免在高频路径重复做重计算
例如列表滚动时每个请求都做大型 JSON 深排序再 SHA256,大概率会影响前端性能。
实践中可考虑:
- 只签关键字段
- 预计算固定片段
- 在请求封装层统一处理,避免重复实现
4. 调试时注意脱敏
你在 Console、日志、抓包工具中打印签名输入时,可能会包含:
- token
- uid
- secret
- 业务敏感参数
建议:
- 本地临时调试后及时清除
- 不要把敏感日志直接提交仓库
- 团队共享样例时做脱敏处理
5. 复现代码要和线上隔离
如果你是为了联调、测试、研究而写复现脚本,建议:
- 放在单独目录
- 不混入业务生产代码
- 用环境变量管理敏感配置
- 记录脚本适用版本和时间点
因为前端签名逻辑很可能会变,混在正式项目里后续会很难维护。
给中级开发者的实用建议
如果你已经不是新手,但还没形成稳定的方法论,我建议你把下面几条变成习惯:
先抓“发送前现场”,再读源码
这是效率最高的路线。
不要一上来就对着压缩代码海量搜索。
先还原“原始签名串”,再算 hash
只比最终 sign 很难定位问题,
先把进入 hash 前的字符串搞对,成功率会高很多。
优先怀疑“排序、过滤、编码、时间戳”
这四类问题比“算法本身猜错”常见得多。
遇到混淆,不求全懂,只求打穿链路
你不需要还原整个工程,只需要知道:
- 请求在哪发
- 参数在哪补
- sign 在哪算
- 输入是什么
- 输出是什么
做到这一步,绝大多数联调和分析任务已经够用了。
总结
前端签名逆向,真正可复制的方法不是“背某种算法”,而是掌握一条稳定路径:
- 从 Network 找到目标请求
- 用 XHR/fetch 断点卡在发送前
- 回溯调用栈,定位参数补充与签名函数
- 搞清楚参与字段、过滤规则、排序方式、编码时机
- 在本地先复现原始签名串,再复现最终摘要
- 若失败,优先排查类型、顺序、时间戳、隐藏输入
如果你把这套流程练熟,面对大多数中等复杂度的前端签名场景,都会比“全局盲猜”快很多。
最后给一个边界条件判断:
- 如果站点只是普通打包混淆、统一请求封装,这套方法通常足够
- 如果站点叠加了强反调试、WASM、动态环境校验、设备指纹深度绑定,那就需要更进一步的动态 hook 与环境模拟能力
但无论复杂度怎么变,“先抓发送前现场,再回溯签名输入” 这个思路,几乎一直都有效。