从前端加密到接口还原:中级开发者实战 Web 逆向中的请求签名分析与自动化复现
很多中级开发者在做接口联调、自动化测试、数据采集或安全研究时,都会碰到一个现实问题:
接口明明已经抓到了,但一请求就 401 / 403 / 参数非法。
这通常不是接口“坏了”,而是前端在发请求之前,额外做了一层或多层处理,比如:
- 时间戳拼接
- 参数排序
- 摘要签名(MD5 / SHA 系)
- 对称加密(AES)
- 混淆后的 JS 逻辑
- 动态 token、nonce、deviceId
- 环境校验(浏览器指纹、Header、Cookie 联动)
我自己刚开始做这类分析时,最容易掉进两个坑:
- 只盯接口返回,不盯前端调用链
- 拿到一个签名函数就急着抄,结果忽略上下文依赖
这篇文章我会按“先看现象,再拆原理,最后自动化复现”的路径,带你完整走一遍。目标不是讲玄学,而是让你能把一个“前端加密接口”从浏览器里还原到可运行脚本里。
说明:本文内容仅用于合法测试、接口联调、安全研究与教学演示。请勿用于未授权目标。
背景与问题
我们先定义一个典型场景。
前端发起请求:
POST /api/order/list
Content-Type: application/json
X-Timestamp: 1710000000
X-Nonce: abc123
X-Sign: 9f8a...
Cookie: session=...
请求体:
{
"page": 1,
"pageSize": 20,
"keyword": "phone"
}
浏览器里请求成功,但你用 Python 或 Node 直接复现时却失败。常见报错包括:
invalid signtimestamp expirednonce duplicatedunauthorizedbad request
这时问题的本质通常不是“你没抓全包”,而是:
- 签名依赖多个字段
- 字段参与顺序有要求
- 前端代码做了加密或序列化变换
- 服务端还校验 Header / Cookie / Referer / User-Agent
- 有动态逻辑,比如时间窗口、一次性 nonce
一个中级开发者常见误区
很多人会直接在 Sources 里搜 sign、md5、sha256。这当然有用,但不够稳。
因为真实项目里,签名逻辑常常长这样:
- 函数名被混淆成
_0x3f12a - 字符串被数组映射
- 最终签名不是简单
md5(params),而是
sha256(sort(params)+timestamp+nonce+secret) - 有时请求体还先 AES 加密,再参与签名
也就是说,你分析的不是一个函数,而是一条数据变换链。
前置知识
如果你已经熟悉这些,可以直接跳到实战。
建议掌握
- 浏览器开发者工具:Network、Sources、Console、Application
- 基本 HTTP 请求结构:Header、Cookie、Body、Query
- JavaScript 基础:对象、字符串、数组、JSON、Promise
- 常见摘要算法:MD5、SHA1、SHA256
- 一点点 Node.js 使用经验
环境准备
本文示例使用:
- Chrome DevTools
- Node.js 18+
- Python 3.10+(用于可选复现)
- 一个本地模拟服务端
- 一个前端签名脚本
核心原理
我们先把问题抽象出来。
一个“带签名的前端请求”,常见处理链大致如下:
flowchart LR
A[原始业务参数] --> B[参数标准化]
B --> C[排序/拼接]
C --> D[加入时间戳 nonce token]
D --> E[摘要签名或加密]
E --> F[组装 Header/Body]
F --> G[发送请求]
G --> H[服务端按同样规则验签]
1. 参数标准化
所谓标准化,指的是前端发送前对参数进行统一处理,比如:
- 去掉
null/undefined - 布尔值转
true/false或1/0 - 对象序列化为 JSON 字符串
- 数组按固定格式拼接
- URL 编码
这里很容易出错。你以为自己传的是同样的参数,但实际上服务端参与签名的字符串已经变了。
2. 排序与拼接
最常见的签名规则之一:
- 取所有参与签名的字段
- 按 key 的字典序排序
- 拼成
k1=v1&k2=v2... - 末尾再拼 secret
- 计算摘要
例如:
keyword=phone&page=1&pageSize=20×tamp=1710000000&nonce=abc123&secret=demo_secret
再对它做 sha256。
3. 摘要签名 vs 加密
这两个概念很容易混。
- 签名:主要用于校验请求是否被篡改。通常是摘要,不可逆。
- 加密:主要用于隐藏明文内容。通常可逆,比如 AES。
很多接口同时做两件事:
- body 先 AES 加密
- 再对密文 + 时间戳做签名
4. 前端混淆并不等于高强度安全
我见过不少项目,代码混淆得很花,但核心逻辑还是:
sign = md5(sortedParams + secret)
混淆只是在提高阅读门槛,不是在改变算法本身。对于逆向分析来说,关注“输入是什么、输出是什么、中间变换是什么”,比关注函数名更重要。
一个可操作的分析路径
建议你按下面顺序做,而不是一上来就扣代码。
sequenceDiagram
participant U as 分析者
participant B as 浏览器前端
participant J as 签名JS逻辑
participant S as 服务端
U->>B: 触发一次真实请求
B->>J: 处理参数、时间戳、nonce
J-->>B: 返回 sign / encryptedBody
B->>S: 发送最终请求
S-->>B: 验签并返回结果
U->>B: 对比原始参数与最终请求
U->>J: 断点/Hook提取签名输入输出
U->>S: 用脚本自动化复现
第一步:抓“最终请求”
在 DevTools 的 Network 中拿到:
- URL
- Method
- Query
- Header
- Cookie
- Body
- Response
重点关注这些字段:
signtimestampnoncetokendeviceIdx-*自定义头
第二步:回看 Initiator / 调用栈
Chrome 里可以看请求由哪个 JS 发起。顺着调用栈往上追,你常能找到:
- 封装后的 request 方法
- axios/fetch 拦截器
- 请求前统一加签逻辑
第三步:定位“签名输入”
这是关键一步。你要回答:
- 签名用到了哪些字段?
- 是 body 参与,还是 query 参与?
- 参数排序了吗?
- 签名前是否 JSON.stringify?
- timestamp / nonce 是不是一起参与?
第四步:验证最小闭环
当你怀疑签名公式是:
sign = sha256(sortedParams + timestamp + nonce + secret)
不要立刻写完整项目脚本。先用 Console 或 Node 验证一个固定输入,看输出能否与浏览器一致。
这一步能帮你排除很多“差一个空格、少一个字段、顺序不对”的低级问题。
实战代码(可运行)
下面我构造一个完整但简化的示例,模拟常见 Web 请求签名流程。
示例规则
假设服务端验签规则如下:
- 请求参数为 JSON
- 取
page、pageSize、keyword - 加入 Header 中的
x-timestamp、x-nonce - 所有字段按 key 升序排序
- 拼接成查询串
- 末尾拼接
&secret=demo_secret - 做
sha256 - 放入
x-sign
示例一:前端签名实现(Node 可直接运行)
// sign.js
const crypto = require('crypto');
function normalizeParams(obj) {
const result = {};
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (value !== undefined && value !== null) {
result[key] = String(value);
}
});
return result;
}
function buildSignString(params, timestamp, nonce, secret) {
const merged = {
...normalizeParams(params),
timestamp: String(timestamp),
nonce: String(nonce),
};
const sortedKeys = Object.keys(merged).sort();
const query = sortedKeys
.map((key) => `${key}=${merged[key]}`)
.join('&');
return `${query}&secret=${secret}`;
}
function sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function signRequest(params, timestamp, nonce) {
const secret = 'demo_secret';
const signString = buildSignString(params, timestamp, nonce, secret);
const sign = sha256(signString);
return {
signString,
sign,
};
}
if (require.main === module) {
const params = {
page: 1,
pageSize: 20,
keyword: 'phone',
};
const timestamp = 1710000000;
const nonce = 'abc123';
const result = signRequest(params, timestamp, nonce);
console.log(result);
}
module.exports = {
normalizeParams,
buildSignString,
signRequest,
};
运行:
node sign.js
示例二:本地模拟服务端
// server.js
const express = require('express');
const { signRequest } = require('./sign');
const app = express();
app.use(express.json());
app.post('/api/order/list', (req, res) => {
const timestamp = req.header('x-timestamp');
const nonce = req.header('x-nonce');
const sign = req.header('x-sign');
if (!timestamp || !nonce || !sign) {
return res.status(400).json({
code: 4001,
message: 'missing headers',
});
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return res.status(401).json({
code: 4002,
message: 'timestamp expired',
});
}
const expected = signRequest(req.body, timestamp, nonce).sign;
if (expected !== sign) {
return res.status(403).json({
code: 4003,
message: 'invalid sign',
expected,
actual: sign,
});
}
return res.json({
code: 0,
message: 'ok',
data: {
list: [
{ id: 1, name: 'phone A' },
{ id: 2, name: 'phone B' },
],
},
});
});
app.listen(3000, () => {
console.log('server running at http://localhost:3000');
});
安装依赖:
npm init -y
npm install express
启动服务:
node server.js
示例三:自动化复现客户端
// client.js
const axios = require('axios');
const { signRequest } = require('./sign');
async function main() {
const url = 'http://localhost:3000/api/order/list';
const data = {
page: 1,
pageSize: 20,
keyword: 'phone',
};
const timestamp = Math.floor(Date.now() / 1000);
const nonce = Math.random().toString(36).slice(2, 10);
const { signString, sign } = signRequest(data, timestamp, nonce);
console.log('signString:', signString);
console.log('sign:', sign);
const response = await axios.post(url, data, {
headers: {
'Content-Type': 'application/json',
'x-timestamp': String(timestamp),
'x-nonce': nonce,
'x-sign': sign,
},
});
console.log(response.data);
}
main().catch((err) => {
if (err.response) {
console.error(err.response.status, err.response.data);
} else {
console.error(err.message);
}
});
安装依赖:
npm install axios
运行:
node client.js
示例四:Python 版本复现
很多实际工作流还是 Python 更顺手,这里给一个可运行版本。
# client.py
import time
import random
import string
import hashlib
import requests
SECRET = "demo_secret"
def normalize_params(params):
result = {}
for k, v in params.items():
if v is not None:
result[k] = str(v)
return result
def build_sign_string(params, timestamp, nonce, secret):
merged = normalize_params(params)
merged["timestamp"] = str(timestamp)
merged["nonce"] = str(nonce)
parts = []
for key in sorted(merged.keys()):
parts.append(f"{key}={merged[key]}")
query = "&".join(parts)
return f"{query}&secret={secret}"
def sha256_hex(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def random_nonce(n=8):
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
def main():
url = "http://localhost:3000/api/order/list"
data = {
"page": 1,
"pageSize": 20,
"keyword": "phone"
}
timestamp = int(time.time())
nonce = random_nonce()
sign_string = build_sign_string(data, timestamp, nonce, SECRET)
sign = sha256_hex(sign_string)
print("sign_string:", sign_string)
print("sign:", sign)
headers = {
"Content-Type": "application/json",
"x-timestamp": str(timestamp),
"x-nonce": nonce,
"x-sign": sign
}
resp = requests.post(url, json=data, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
if __name__ == "__main__":
main()
安装依赖:
pip install requests
运行:
python client.py
进阶:如何在真实前端中定位签名逻辑
上面是我们自己构造的规则。真实项目里,关键在于把浏览器中的逻辑提取出来。
方法一:全局搜索关键词
优先搜:
signsignaturetokennoncemd5sha1sha256CryptoJSencryptdecrypt
但如果代码被混淆,这招可能效果一般。
方法二:从请求库入口打断点
如果项目使用 axios / fetch,可以优先盯这两个位置:
- 请求拦截器
- 请求发送前的统一封装函数
因为大多数项目都会在这里统一加:
- 时间戳
- 公共参数
- 签名 Header
方法三:Hook 关键函数
如果你怀疑项目用 CryptoJS.SHA256 或 JSON.stringify 来生成签名输入,可以临时 Hook。
Hook JSON.stringify
// 在浏览器 Console 中执行
(function () {
const raw = JSON.stringify;
JSON.stringify = function (...args) {
const result = raw.apply(this, args);
console.log('[hook stringify] input:', args[0]);
console.log('[hook stringify] output:', result);
return result;
};
})();
Hook 摘要函数
如果页面里暴露了 CryptoJS:
(function () {
if (!window.CryptoJS || !CryptoJS.SHA256) return;
const raw = CryptoJS.SHA256;
CryptoJS.SHA256 = function (msg) {
console.log('[hook sha256] input:', msg);
const result = raw.call(this, msg);
console.log('[hook sha256] output:', result.toString());
return result;
};
})();
这类 Hook 的核心价值,不是“偷算法”,而是抓到签名前的原始字符串。一旦输入串拿到了,复现难度会大幅下降。
逐步验证清单
这是我平时最常用的一套检查顺序,建议你照着过一遍。
第 1 层:请求结构对齐
- URL 是否完全一致
- GET/POST 是否一致
- Query 参数是否一致
- Body 格式是否一致
application/json还是x-www-form-urlencoded
第 2 层:参与签名字段对齐
- 是否包含公共参数
- 是否包含时间戳
- 是否包含 nonce
- body 字段是否全部参与
- 空值字段是否被过滤
第 3 层:字符串构造对齐
- 是否排序
- 排序规则是否区分大小写
- 值是否转字符串
- JSON 是否压缩成单行
- 拼接分隔符是
&、,还是空串 - secret 是前置还是后置
第 4 层:算法对齐
- MD5 / SHA1 / SHA256
- 小写 hex / 大写 hex
- Base64 还是 hex
- AES 模式是 CBC / ECB
- padding 是否一致
第 5 层:环境依赖对齐
- Cookie 是否必须
- User-Agent 是否被校验
- Referer / Origin 是否必须
- 是否需要登录态
- 是否依赖 localStorage/sessionStorage 的 token
常见坑与排查
这一节很重要,因为真实项目里,失败大多不是“完全不会”,而是“差一点点”。
坑一:参数顺序不一致
比如浏览器里签名串是:
keyword=phone&nonce=abc123&page=1&pageSize=20×tamp=1710000000&secret=demo_secret
你脚本里却写成:
page=1&pageSize=20&keyword=phone×tamp=1710000000&nonce=abc123&secret=demo_secret
很多签名算法对顺序极其敏感。
排查建议: 把浏览器中的签名前字符串打印出来,与脚本生成的逐字符对比。
坑二:数字、布尔、null 处理不一致
比如:
- 前端把
1转成"1" - 布尔值
true转成"true" null字段直接剔除
而你在 Python 中直接拿原始对象参与签名,结果自然不一致。
排查建议: 显式做参数标准化,不要“相信默认序列化”。
坑三:JSON.stringify 的细节差异
对象一旦嵌套,你很可能遇到:
- 键顺序不同
- 空格不同
- Unicode 转义不同
尤其是前端可能拿整个 JSON 字符串参与签名,而不是逐字段拼接。
排查建议:
直接 Hook JSON.stringify,抓浏览器里的最终字符串。
坑四:时间戳过期
有些接口只允许 60 秒或 300 秒窗口。
你抓到一个成功请求后,过几分钟再拿来重放,就会失败。
排查建议: 签名必须实时生成,不要直接重放旧包。
坑五:nonce 一次性使用
有些服务端会缓存 nonce,防重放。你第一次成功,第二次同包发过去就失败。
排查建议: 每次请求都生成新的 nonce,并重新签名。
坑六:Header 参与签名但你没发现
有些项目把这些也纳入签名:
User-AgentappVersiondeviceIdplatformAuthorization
排查建议: 从请求构造入口看“传给签名函数的完整对象”,不要只盯 body。
坑七:前端代码能跑,Node 里却跑不起来
因为前端代码依赖:
windowdocumentnavigatoratob/btoalocalStorage
排查建议:
先抽出纯算法部分;若确实依赖浏览器环境,可用 jsdom、补 polyfill,或直接在无头浏览器中执行。
安全/性能最佳实践
这部分我想从“复现者”和“系统设计者”两个角度都说一下。
1. 对于复现脚本:不要把签名逻辑写死成不可维护的脚本
很多人第一次复现成功后,会把逻辑散落在多个文件里,过几天自己都看不懂。
建议把流程拆成:
- 参数标准化
- 签名串构造
- 摘要/加密
- 请求发送
- 重试与日志
这样接口规则一改,你只需要调整某一层。
可以参考这个结构:
classDiagram
class ParamNormalizer {
+normalize(params)
}
class SignBuilder {
+build(params, timestamp, nonce)
}
class CryptoEngine {
+sha256(text)
+encrypt(text)
}
class ApiClient {
+send(data)
}
ApiClient --> ParamNormalizer
ApiClient --> SignBuilder
ApiClient --> CryptoEngine
2. 记录“签名前字符串”
这是排障效率最高的做法之一。
请求失败时,至少日志里打印:
- 原始参数
- 标准化参数
- sign string
- 最终 sign
- timestamp / nonce
只要这几项有了,问题一般都能定位。
3. 不要滥用高频重试
签名错误不是网络抖动。你一旦签错,重试 10 次还是错。
正确做法:
- 先打印签名串
- 再检查字段和排序
- 最后才考虑重试机制
4. 对于服务端设计:不要把“前端加密”当作真正安全边界
这是一个很现实的问题。
如果 secret 下发到了前端,或者签名逻辑完全跑在前端,那么从安全设计角度看:
- 它能提高调用门槛
- 但不能作为强安全手段
更可靠的措施应该包括:
- 服务端签发短时令牌
- 登录态绑定
- 频控与风控
- 设备指纹校验
- nonce 防重放
- 时间窗口校验
- 行为审计
5. 性能上避免重复计算
如果你的自动化脚本有批量请求需求:
- 公共参数可以复用
- 仅变化字段重新计算签名
- 注意连接池复用
- 控制并发,避免触发风控
尤其是某些接口还会做昂贵的加密逻辑,频繁初始化会拖慢吞吐。
一套通用的实战模板
如果你面对的是陌生站点,我建议按这套最小模板推进。
模板步骤
- 抓一条成功请求
- 标记所有动态字段
- 定位请求发起函数
- 找到签名函数输入
- 固定参数做单次验证
- 抽出纯 JS 算法
- 在 Node 跑通
- 再迁移到 Python 或自动化框架
- 增加日志和异常处理
- 最后再考虑批量化
最小复现原则
先只复现一个成功请求,不要一开始就做:
- 批量任务
- 多线程并发
- 代理轮换
- 复杂调度
因为一旦基础签名都没完全对齐,这些复杂度只会把问题藏起来。
边界条件:什么时候不适合纯脚本复现
这点也要说清楚,不然容易误判。
如果目标站点有下面这些机制,纯 requests/axios 不一定够:
- 强依赖浏览器环境检测
- WebAssembly 中生成关键参数
- 滑块/验证码联动
- token 与页面生命周期绑定
- 某些签名在原生 App 或小程序容器中生成
- TLS 指纹/JA3 参与风控
这时更现实的方案是:
- 无头浏览器执行原始前端逻辑
- 注入 Hook 提取参数
- 浏览器自动化与请求脚本混合使用
也就是说,不要迷信“必须纯接口复现”。工程上能稳定跑通,才是最优解。
总结
从前端加密到接口还原,真正要掌握的不是某个具体算法,而是一套分析方法:
- 先抓最终请求
- 再追请求构造链
- 定位签名输入输出
- 做最小闭环验证
- 抽离可维护的自动化代码
你可以把整个过程理解为一句话:
不是“找 sign 函数”,而是“还原请求数据从业务参数到最终报文的变换过程”。
如果你是中级开发者,我很建议把注意力放在这三个能力上:
- 对比能力:浏览器请求与脚本请求逐项对比
- 抽象能力:把混乱代码抽象成参数、排序、拼接、摘要几个阶段
- 工程能力:把一次性复现变成可维护的自动化模块
最后给你一个非常实用的建议:
每次分析到签名逻辑时,务必保存“签名前字符串 + 最终 sign + 请求样本”。
这份样本会在后续排障时救你很多次。我自己踩坑后,几乎把它当成固定动作了。
如果你能把本文示例真正跑一遍,再去看真实项目里的签名逻辑,很多“看起来很复杂”的东西,其实就没那么吓人了。