从抓包到还原签名流程:一次典型 Web 逆向中前端加密参数生成的实战分析
很多人第一次做 Web 逆向时,都会遇到一个非常典型的问题:
明明接口地址找到了,参数格式也看懂了,但请求一发出去就是“签名错误”或者“非法请求”。
这类问题的核心,往往不在接口本身,而在前端生成的加密参数或签名参数。页面里看起来只是多了一个 sign、token、nonce、ts,但它背后可能串着一整条前端逻辑链:取时间戳、拼接参数、排序、加盐、哈希、编码,甚至还会混入动态变量或环境检测。
这篇文章我会用一种“带你走一遍”的方式,完整演示一次典型的分析过程:从抓包定位、到前端代码追踪、到还原签名算法、再到写出可运行代码复现请求。重点不是某个网站的具体实现,而是掌握这一类问题的通用方法。
背景与问题
先设定一个常见场景。
某个站点的接口请求如下:
POST /api/search HTTP/1.1
Content-Type: application/json
{
"keyword": "phone",
"page": 1,
"ts": 1723980000123,
"nonce": "a8f3d2c1",
"sign": "9c7b1e7d0f2c..."
}
直接改参数重放时,服务端返回:
{
"code": 403,
"message": "invalid sign"
}
这说明接口防护不只校验业务参数,还校验了一套前端生成逻辑。
我们要解决的核心问题
sign是怎么生成的?- 它依赖哪些字段?
- 参数顺序是否影响签名?
- 是否有固定盐值、动态 token、环境变量参与?
- 能否脱离浏览器,在本地脚本中复现?
前置知识
如果你已经熟悉以下内容,阅读会很顺:
- Chrome DevTools 基本使用
- 抓包工具基本概念
- JavaScript 基础语法
- 常见摘要算法:MD5 / SHA1 / SHA256
- 编码形式:Hex / Base64 / URL Encode
如果不熟也没关系,本文会尽量按“观察现象 -> 推测逻辑 -> 验证结果”的节奏来讲。
环境准备
这类分析我通常会准备下面这些工具:
- 浏览器:Chrome
- 开发者工具:Network / Sources / Console
- 可选抓包:Fiddler / Charles / mitmproxy
- Node.js:用于本地复现签名
- 文本搜索工具:全局搜关键字
- JS 美化工具:浏览器格式化或本地 prettier / js-beautify
建议先确保本机有 Node.js 18+。
整体分析路线
先别急着抠混淆代码。经验上,逆向签名最省时间的路线通常是:
- 抓到真实请求
- 定位可疑参数
- 在前端代码中追踪参数生成位置
- 抽离签名核心逻辑
- 写脚本复现
- 对比浏览器结果,逐步修正
下面这张图能帮助你建立整体路径感。
flowchart TD
A[抓包定位目标接口] --> B[识别动态参数 ts/nonce/sign]
B --> C[前端全局搜索 sign 或接口路径]
C --> D[定位请求发送函数]
D --> E[向上追踪签名生成逻辑]
E --> F[抽离排序/拼接/加盐/哈希]
F --> G[Node.js 本地复现]
G --> H[对比浏览器请求结果]
H --> I[修正细节并完成还原]
核心原理
前端签名本质上通常是这几步的组合:
- 收集参数
- 按规则排序
- 拼接成字符串
- 附加密钥/盐值
- 执行哈希或加密
- 输出固定编码格式
一个很典型的例子:
keyword=phone&page=1&ts=1723980000123&nonce=a8f3d2c1 + secret
然后执行:
SHA256(拼接结果)
最后得到:
9c7b1e7d0f2c...
常见签名要素
| 要素 | 作用 | 常见表现 |
|---|---|---|
| ts | 防重放 | 毫秒/秒时间戳 |
| nonce | 防重复 | 随机字符串 |
| appKey | 标识客户端 | 固定公开值 |
| secret | 签名密钥 | 前端硬编码或拆分隐藏 |
| token | 用户态绑定 | 来自 cookie/localStorage |
| body 摘要 | 防篡改 | 请求体先做一次 hash |
你需要特别注意的“细节杀手”
很多人卡住,不是算法不会,而是漏了这些细节:
- 参数是否按 ASCII 字典序 排序
- 空值字段是否参与签名
- 数字是否转成字符串
- 布尔值是
true还是"true" - 请求体是原始 JSON 还是压缩后字符串
- 是否在末尾拼接固定盐值
- 是否先
JSON.stringify再哈希 - 哈希输出是小写 hex 还是大写 hex
- 是否对参数值做了 URL 编码
这些问题我基本都踩过。尤其是排序规则和序列化方式,最容易让你“看起来只差一点,但永远不对”。
一次典型实战:从请求到签名还原
为了让过程可运行、可验证,下面我用一个“典型前端签名模型”来完整演示。
第一步:抓包确认动态参数
先在 Network 中找到目标接口,重点看 Request Payload 或 Form Data。
假设抓到如下请求:
{
"keyword": "phone",
"page": 1,
"ts": 1723980000123,
"nonce": "a8f3d2c1",
"sign": "0c4f2d9f7c5a6a0b6fd9a0c1241d4c7f46d2bb0a2d3f8f658d3c6a13aa8c1f31"
}
这时可以初步判断:
keyword、page是业务参数ts、nonce、sign是安全参数sign很可能依赖前面几项
第二步:搜索请求发起位置
在 Sources 全局搜索:
/api/searchsignnoncetsinvalid sign
通常能定位到类似这样的代码:
function requestSearch(data) {
const ts = Date.now();
const nonce = randomString(8);
const payload = {
...data,
ts,
nonce
};
payload.sign = makeSign(payload);
return http.post("/api/search", payload);
}
到这里,方向就明确了:重点跟进 makeSign(payload)。
第三步:进入签名函数
继续追进去,可能会看到压缩混淆后的代码。美化后,常见结构如下:
function makeSign(params) {
const secret = "webapp_secret_2024";
const keys = Object.keys(params).sort();
const str = keys.map(k => `${k}=${params[k]}`).join("&");
return sha256(str + secret);
}
如果站点不复杂,到这里其实已经还原完成了。
但真实环境中,经常还会再包一层,比如:
function makeSign(params) {
const secret = getSecret();
const normalized = normalize(params);
const bodyStr = serialize(normalized);
return encodeHex(hash(bodyStr + "|" + secret)).toLowerCase();
}
这时候就不要只看函数名,要看实际的数据流。
用时序图理解一次签名调用链
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant A as 接口服务端
U->>P: 输入 keyword=phone
P->>P: 组装业务参数
P->>P: 生成 ts / nonce
P->>S: makeSign(payload)
S->>S: 排序、拼接、加盐、哈希
S-->>P: 返回 sign
P->>A: 发送完整请求
A->>A: 用相同规则验签
A-->>P: 返回业务数据
这张图里最关键的一点是:
服务端不会“理解你的前端写法”,它只会验证最终规则。
所以我们在逆向时的目标不是“完全复刻源代码结构”,而是“提取出等价规则”。
实战代码:本地复现签名流程
下面我们写一套可运行代码,用 Node.js 复现上面的签名逻辑。
示例签名规则
假设通过分析确认签名规则为:
- 收集参数:
keyword、page、ts、nonce - 去掉空值和
sign字段 - 按 key 升序排序
- 拼接为
k=v&k=v - 末尾追加密钥:
webapp_secret_2024 - 做 SHA256,输出小写 hex
可运行代码
import crypto from "crypto";
function randomString(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 normalizeParams(params) {
const out = {};
for (const [key, value] of Object.entries(params)) {
if (key === "sign") continue;
if (value === undefined || value === null || value === "") continue;
out[key] = String(value);
}
return out;
}
function buildSignString(params) {
const normalized = normalizeParams(params);
const keys = Object.keys(normalized).sort();
return keys.map(key => `${key}=${normalized[key]}`).join("&");
}
function sha256Hex(input) {
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
}
function makeSign(params) {
const secret = "webapp_secret_2024";
const signStr = buildSignString(params);
return sha256Hex(signStr + secret).toLowerCase();
}
function buildPayload(keyword, page = 1) {
const payload = {
keyword,
page,
ts: Date.now(),
nonce: randomString(8)
};
payload.sign = makeSign(payload);
return payload;
}
const payload = buildPayload("phone", 1);
console.log("payload =", payload);
console.log("sign source =", buildSignString(payload));
运行方式
node demo.js
输出大概像这样:
payload = {
keyword: 'phone',
page: 1,
ts: 1723980000123,
nonce: 'a8f3d2c1',
sign: '0c4f2d9f7c5a6a0b6fd9a0c1241d4c7f46d2bb0a2d3f8f658d3c6a13aa8c1f31'
}
sign source = keyword=phone&nonce=a8f3d2c1&page=1&ts=1723980000123
加一步:直接发请求验证
如果你已经确认接口和请求头,也可以直接用 fetch 或 axios 发请求。
下面给出一个 fetch 版示例:
import crypto from "crypto";
function randomString(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 normalizeParams(params) {
const out = {};
for (const [key, value] of Object.entries(params)) {
if (key === "sign") continue;
if (value === undefined || value === null || value === "") continue;
out[key] = String(value);
}
return out;
}
function buildSignString(params) {
const normalized = normalizeParams(params);
return Object.keys(normalized)
.sort()
.map(key => `${key}=${normalized[key]}`)
.join("&");
}
function makeSign(params) {
const secret = "webapp_secret_2024";
const source = buildSignString(params) + secret;
return crypto.createHash("sha256").update(source, "utf8").digest("hex");
}
async function main() {
const payload = {
keyword: "phone",
page: 1,
ts: Date.now(),
nonce: randomString(8)
};
payload.sign = makeSign(payload);
const res = await fetch("https://example.com/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
},
body: JSON.stringify(payload)
});
const text = await res.text();
console.log(text);
}
main().catch(console.error);
如果代码被混淆了,怎么追?
实际项目里,签名函数往往不是这么“裸奔”的。你更常见到的是:
- 变量名全是
a、b、c - 函数层层嵌套
- 字符串被拆分进数组
- 哈希库被 webpack 打包
- 请求逻辑统一走拦截器
这时我的建议是:不要试图一眼看懂全部代码,而是只追“值从哪里来、到哪里去”。
一个简单的数据流排查思路
flowchart LR
A[Network 中的 sign 值] --> B[搜索请求函数]
B --> C[定位 payload 组装点]
C --> D[找到 sign 赋值语句]
D --> E[进入 makeSign]
E --> F[识别排序逻辑]
F --> G[识别拼接字符串]
G --> H[识别哈希函数]
H --> I[抽出最小可复现代码]
实用技巧
1. 在赋值点打断点
看到:
payload.sign = makeSign(payload);
直接在这一行打断点,然后在 Console 里看:
payload
makeSign(payload)
Object.keys(payload).sort()
这样比在一堆混淆代码里硬读要高效得多。
2. Hook 哈希函数
如果你怀疑最终用了 SHA256 / MD5,可以在控制台临时改写相关函数,打印输入内容。
例如浏览器中如果站点用了某个 sha256 方法:
const rawSha256 = window.sha256;
window.sha256 = function(input) {
console.log("sha256 input =>", input);
return rawSha256.apply(this, arguments);
};
这样一发请求,你就能直接看到哈希前的原文。
3. 观察固定字符串
很多时候真正的“秘密”不在算法,而在那个盐值:
"webapp_secret_2024"
混淆代码也许把它拆成:
["web", "app", "_secret", "_2024"].join("")
你只要把最终结果拼出来就行,不需要过度纠结它原本怎么写的。
逐步验证清单
这部分很重要。签名还原不是“写完就完”,而是要一项一项验。
验证顺序建议
- 固定浏览器中的一个请求样本
- 记录原始参数与
sign - 本地脚本使用完全相同的参数
- 计算本地
sign - 对比结果是否一致
- 一致后再改成动态时间戳和随机数
- 最后再接上自动请求
最小验证样本
例如先固定:
{
"keyword": "phone",
"page": 1,
"ts": 1723980000123,
"nonce": "a8f3d2c1"
}
然后你的本地结果必须和浏览器 sign 完全一致。
注意,是完全一致,不是“看着差不多”。
如果不一致,就回头排查:
- 拼接顺序
- 是否转字符串
- 是否漏字段
- 是否多拼了
sign - 是否大小写不一致
- 是否盐值位置错了
常见坑与排查
这部分我尽量写得接地气一点,因为很多坑真的是反复出现。
1. 参数顺序错了
你以为是对象原始顺序,实际上前端做了排序。
错误示例:
keyword=phone&page=1&ts=1723980000123&nonce=a8f3d2c1
正确可能是:
keyword=phone&nonce=a8f3d2c1&page=1&ts=1723980000123
排查方法:
console.log(Object.keys(params).sort());
2. 把数字当数字,前端却转成了字符串
浏览器里最终拼接的是:
page=1
而不是某种二进制意义上的数字。
如果你在其他语言中复现,务必显式转字符串。
3. JSON 序列化不一致
有些站点不是按 k=v&k=v 拼接,而是:
JSON.stringify(obj)
这时对象字段顺序会直接影响签名。
排查思路:
- 看签名前传入的是对象还是字符串
- 看是否调用了
JSON.stringify - 看是否做了自定义 serializer
4. 漏掉隐藏依赖
例如某些签名还会拼:
- cookie 中的 token
- localStorage 中的 deviceId
- 请求头中的 x-client-id
- 页面注入的 version
这种情况最容易让人误判为“算法没还原”。其实算法对了,只是少了输入。
排查方式:
- 看
makeSign的参数来源 - 向上追到调用栈
- 观察是否读取了
document.cookie、localStorage、sessionStorage
5. 哈希前先做编码
有的网站会这样:
sha256(encodeURIComponent(str) + secret)
或:
md5(btoa(str))
如果你只看到最终调用 sha256,但没注意前面的编码层,就会一直算不出来。
6. 时间戳精度不一致
前端可能是:
Date.now() // 毫秒
也可能是:
Math.floor(Date.now() / 1000) // 秒
两者只差 3 位,但签名会完全不同。
7. 随机数生成规则不同
你本地随便生成一个 nonce 看似没问题,但有的站点要求:
- 固定长度
- 指定字符集
- 必须小写
- 必须以某个前缀开头
这种情况如果服务端也校验 nonce 规则,请求同样会失败。
安全/性能最佳实践
这里分成两个视角:分析者视角 和 开发者视角。
对分析者来说
1. 先做最小复现,不要一上来写大脚本
我的经验是,先用一个固定样本把 sign 算对,比一开始就写完整爬虫更重要。
否则你会同时调试“签名问题、请求头问题、cookie 问题、频率问题”,非常乱。
2. 保留中间结果
建议把这些都打印出来:
- 原始参数
- 排序后的 key 列表
- 拼接字符串
- 加盐后的最终输入
- 哈希输出
这样一旦算错,能迅速定位哪一层出了问题。
3. 关注浏览器环境依赖
有些签名函数依赖:
windownavigatordocument- Canvas/WebGL 指纹
- WebAssembly
这时候纯 Node.js 可能跑不起来,需要补环境或者直接在浏览器里执行。
对开发者来说
如果你是从防护角度看这件事,需要明白一个现实:
只要签名逻辑在前端可执行,就存在被分析和复现的可能。
所以前端签名的正确定位应该是:
- 提高滥用成本
- 防低门槛重放
- 配合服务端风控
- 而不是把它当作绝对安全边界
更合理的安全实践
- 服务端持有真正密钥
- 签名结合用户态、设备态、时效性
- 加入重放保护
- 对异常频率做风控
- 不要把核心安全完全交给前端混淆
性能方面的建议
- 避免在主线程执行过重加密逻辑
- 签名字段尽量简洁,不要重复计算大型 payload
- 对大请求体可先摘要再参与签名
- 高频请求可考虑 nonce/ts 复用窗口,但要平衡安全性
一个更贴近真实项目的扩展判断
很多时候你最终会发现,站点并不是“加密”了参数,而只是“签名”了参数。
这两者要分清:
- 加密:目标是让别人看不懂内容
- 签名:目标是让服务端确认内容未被篡改
在 Web 场景中,前端所谓“加密参数”大多数其实属于:
- 哈希签名
- 简单编码
- 对称加密但密钥在前端可见
- 请求摘要校验
也正因为如此,分析重点不该放在“它看起来很神秘”,而应该放在:
- 输入是什么
- 顺序是什么
- 中间变换是什么
- 最终输出格式是什么
一旦把这个链路拆开,很多“神秘参数”都会变得很普通。
总结
做 Web 逆向里的签名还原,最重要的不是某个具体算法,而是方法论:
- 先抓包,确认动态参数
- 定位请求发起点
- 顺着数据流找到签名函数
- 拆出排序、拼接、加盐、哈希规则
- 用固定样本做本地校验
- 确认一致后再自动化请求
如果你只记住一句话,我建议是这句:
不要试图一次看懂全部混淆代码,只要把“sign 是怎么从参数变出来的”这条链追清楚就够了。
最后给几个可执行建议:
- 第一次分析时,优先找
payload.sign = xxx(...) - 永远保留一个浏览器真实样本做对照
- 本地脚本必须打印中间字符串
- 算不对时,优先怀疑排序、序列化、隐藏输入
- 遇到环境依赖,再考虑补浏览器环境,而不是先硬抄代码
当然,本文演示的是典型流程,不适用于所有场景。
如果目标站点用了更强的防护,比如:
- 动态下发密钥
- 服务端挑战响应
- WebAssembly 混淆
- 强环境绑定
- 风控联动校验
那分析成本会明显上升,这时就不能只靠“找个哈希函数”解决问题了。
但对于大多数常见 Web 接口签名场景,这套思路已经足够实用。只要你肯耐心做“抓包 -> 定位 -> 抽离 -> 验证”这四步,签名流程大概率是能还原出来的。