从浏览器指纹到请求签名:Web逆向中前端加密参数定位与复现实战
很多人刚接触 Web 逆向时,最容易卡在一个点:接口明明抓到了,参数也看到了,但就是复现不出来。
尤其是遇到下面这些字段时,挫败感会非常强:
signtokentnoncedeviceIdfpX-SignatureX-Timestamp
看起来只是几个字符串,背后却可能串着一整条前端加密链路:浏览器指纹采集 → 参数归一化 → 排序拼接 → 摘要/加密 → 请求头注入。
这篇文章我不打算只讲概念,而是按一条比较真实的逆向路径,带你从浏览器指纹定位到请求签名生成,最后完成可运行复现。
如果你已经会抓包,但总在 JS 混淆、动态注入、签名校验上翻车,这篇会比较对路。
背景与问题
在现代 Web 应用里,前端越来越少只是“渲染页面”的角色。很多站点会把一部分风控与签名逻辑下沉到浏览器端,常见目的有:
- 防止接口被直接脚本化调用
- 绑定设备环境,降低重放成功率
- 增加参数伪造成本
- 将关键密钥切碎后藏在前端运行时里
于是我们在抓一个请求时,经常会看到这类现象:
- 请求体里多了一个
sign - 请求头里有
X-Token、X-Fp、X-Trace sign每次都变,且和时间戳相关- 同一个接口,在浏览器里能成功,在 Python 里就 401/403
- 补齐 Cookie 也没用,说明问题不只是会话态
这时候如果只盯着网络面板,很容易陷入“抓到参数但不知道怎么来的”这个死循环。
本文要解决的核心问题
我们重点解决三件事:
- 如何定位前端加密参数的生成位置
- 如何识别浏览器指纹参与了哪些签名输入
- 如何在浏览器外复现这条签名链路
前置知识
建议你至少熟悉以下内容:
- Chrome DevTools 基础调试
- JavaScript 基础语法
- 抓包与请求重放
- Python 或 Node.js 任一种脚本语言
如果你问我“必须会 AST 还原和大规模脱混淆吗”,答案是不一定。
中级阶段最有价值的能力,不是上来就做全量还原,而是先把参数生成路径跑通。
环境准备
本文示例使用以下工具:
- Chrome / Edge DevTools
- Node.js 18+
- Python 3.10+
- 可选:
mitmproxyCharlesFiddlerjs-beautify
安装 Node 依赖:
npm init -y
npm install crypto-js express body-parser
核心原理
从逆向角度看,请求签名通常不是孤立的,它经常由四层组成:
-
基础业务参数
如页码、关键字、商品 ID、时间范围 -
环境参数 / 指纹参数
如 UA、屏幕分辨率、时区、语言、Canvas 指纹、WebGL 信息、平台信息 -
动态扰动参数
如时间戳、随机数、nonce、traceId -
签名算法
排序、拼接、摘要、HMAC、AES 包装、Base64 等
可以先记住一句经验话:
99% 的“前端加密参数”问题,本质上是“找输入 + 找顺序 + 找算法 + 找注入点”。
一个典型的签名链路
flowchart LR
A[业务参数] --> E[参数归一化]
B[浏览器指纹] --> E
C[时间戳/随机数] --> E
E --> F[排序与拼接]
F --> G[摘要或加密]
G --> H[写入请求头/请求体]
H --> I[发起请求]
浏览器指纹为什么关键
很多同学只盯着 sign,但忽略了 sign 的输入。
实际中,签名失败不一定是算法错了,更常见是少了一个环境输入。
常见指纹维度包括:
navigator.userAgentnavigator.languagenavigator.platformscreen.width / heightdevicePixelRatioIntl.DateTimeFormat().resolvedOptions().timeZone- Canvas 绘制结果
- WebGL 渲染器
- 音频上下文特征
- 插件列表
- 字体特征
站点不一定全用,但只要其中某几项参与签名,而你脚本里没补齐,就会出现:
- 签名格式对,但验签失败
- 首次请求成功,后续失败
- 同账号同 Cookie,换机器就失效
先建立定位思路:别一上来就搜 sign
我自己刚开始做这类题时,也喜欢全局搜 sign、md5、sha256。
说实话,这个方法有时有用,但在现代打包工程里,命中率并不稳定。更可靠的方式是:
1. 先从“请求发起点”反推
在 Network 面板里找到目标请求,重点看:
- Query String Parameters
- Form Data / Request Payload
- Request Headers
如果 sign 在请求体或请求头中,下一步不是搜字符串,而是:
- 在 Sources 里对
fetch/XMLHttpRequest.send下断点 - 使用 XHR/fetch Breakpoints
- 或者对请求 URL 关键字下断点
2. 在“最终发送前”看参数长什么样
你关心的不是原始源码里叫不叫 sign,而是:
- 这个字段是发送前什么时候被塞进去的
- 塞进去之前依赖了哪些值
- 这些值里有没有指纹和时间戳
3. 沿调用栈回溯
通常请求参数的生成路径是:
sequenceDiagram
participant U as 用户操作
participant B as 页面业务代码
participant S as 签名函数
participant F as 指纹采集函数
participant N as 网络请求层
U->>B: 点击搜索/翻页
B->>F: 获取设备指纹
B->>S: 传入业务参数+指纹+时间戳
S-->>B: 返回 sign/token
B->>N: 组装 headers/body
N-->>U: 发起 HTTP 请求
这个时候,调用栈比全局搜索更值钱。
因为混淆后的函数名可能是 _0x3ab1、n、r、a,但调用关系骗不了人。
实战案例设计
为了让过程完整可运行,下面我用一个简化但贴近真实项目的场景:
前端请求 /api/search 时,发送如下数据:
keywordpagetsfpsign
签名规则假设为:
-
采集基础指纹:
- UA
- 语言
- 屏幕尺寸
- 时区
-
生成
fp:- 将指纹字段按固定顺序拼接
- 做一次 MD5
-
生成
sign:- 对
keyword/page/ts/fp排序 - 拼接成查询串
- 末尾加私有盐值
- SHA256 输出十六进制
- 对
这类结构在实际站点里非常常见,区别只在于:
- 算法可能换成 HMAC/AES
fp可能更复杂- 盐值可能拆散在多个模块里
- 最终可能走请求拦截器注入
实战代码(可运行)
下面分前端、服务端、复现脚本三部分。
一、模拟前端:采集指纹并生成签名
创建 frontend-sign.js:
const CryptoJS = require('crypto-js');
function collectFingerprint(env) {
const ua = env.ua || '';
const lang = env.lang || '';
const screen = `${env.width || 0}x${env.height || 0}`;
const tz = env.timezone || '';
const raw = [ua, lang, screen, tz].join('|');
return CryptoJS.MD5(raw).toString();
}
function normalizeParams(params) {
const keys = Object.keys(params).sort();
return keys.map(key => `${key}=${params[key]}`).join('&');
}
function buildSign({ keyword, page, env }) {
const ts = Date.now().toString();
const fp = collectFingerprint(env);
const payload = {
keyword,
page,
ts,
fp,
};
const normalized = normalizeParams(payload);
const salt = 'demo_private_salt_v1';
const sign = CryptoJS.SHA256(`${normalized}|${salt}`).toString();
return {
...payload,
sign,
};
}
module.exports = {
collectFingerprint,
normalizeParams,
buildSign,
};
测试一下:
const { buildSign } = require('./frontend-sign');
const result = buildSign({
keyword: 'laptop',
page: 1,
env: {
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
lang: 'zh-CN',
width: 1920,
height: 1080,
timezone: 'Asia/Shanghai',
},
});
console.log(result);
运行:
node test.js
二、模拟服务端:验签逻辑
创建 server.js:
const express = require('express');
const bodyParser = require('body-parser');
const CryptoJS = require('crypto-js');
const app = express();
app.use(bodyParser.json());
function normalizeParams(params) {
const keys = Object.keys(params).sort();
return keys.map(key => `${key}=${params[key]}`).join('&');
}
function verifySign(body) {
const { keyword, page, ts, fp, sign } = body;
if (!keyword || !page || !ts || !fp || !sign) {
return { ok: false, reason: 'missing fields' };
}
const payload = { keyword, page, ts, fp };
const normalized = normalizeParams(payload);
const salt = 'demo_private_salt_v1';
const expected = CryptoJS.SHA256(`${normalized}|${salt}`).toString();
if (expected !== sign) {
return { ok: false, reason: 'bad sign', expected };
}
const now = Date.now();
if (Math.abs(now - Number(ts)) > 60 * 1000) {
return { ok: false, reason: 'timestamp expired' };
}
return { ok: true };
}
app.post('/api/search', (req, res) => {
const result = verifySign(req.body);
if (!result.ok) {
return res.status(403).json({
code: 403,
message: result.reason,
expected: result.expected || null,
});
}
res.json({
code: 0,
data: {
list: [
{ id: 1, title: `Result for ${req.body.keyword}` },
],
},
});
});
app.listen(3000, () => {
console.log('server running at http://localhost:3000');
});
启动服务:
node server.js
三、复现请求:Node 版本
创建 client.js:
const { buildSign } = require('./frontend-sign');
async function main() {
const params = buildSign({
keyword: 'laptop',
page: 1,
env: {
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
lang: 'zh-CN',
width: 1920,
height: 1080,
timezone: 'Asia/Shanghai',
},
});
const resp = await fetch('http://localhost:3000/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': params.ua || 'NodeClient',
},
body: JSON.stringify(params),
});
const data = await resp.json();
console.log(data);
}
main().catch(console.error);
运行:
node client.js
四、复现请求:Python 版本
如果你更常用 Python,可以直接把规则搬过去。创建 client.py:
import hashlib
import time
import requests
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def collect_fingerprint(env: dict) -> str:
ua = env.get("ua", "")
lang = env.get("lang", "")
screen = f'{env.get("width", 0)}x{env.get("height", 0)}'
tz = env.get("timezone", "")
raw = "|".join([ua, lang, screen, tz])
return md5_hex(raw)
def normalize_params(params: dict) -> str:
keys = sorted(params.keys())
return "&".join([f"{k}={params[k]}" for k in keys])
def build_sign(keyword: str, page: int, env: dict) -> dict:
ts = str(int(time.time() * 1000))
fp = collect_fingerprint(env)
payload = {
"keyword": keyword,
"page": page,
"ts": ts,
"fp": fp,
}
normalized = normalize_params(payload)
salt = "demo_private_salt_v1"
sign = sha256_hex(f"{normalized}|{salt}")
payload["sign"] = sign
return payload
if __name__ == "__main__":
env = {
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36",
"lang": "zh-CN",
"width": 1920,
"height": 1080,
"timezone": "Asia/Shanghai",
}
data = build_sign("laptop", 1, env)
resp = requests.post("http://localhost:3000/api/search", json=data)
print(resp.status_code)
print(resp.text)
运行:
python client.py
真实站点里怎么定位:一步步来
上面的代码是为了把规则讲清楚。真正到线上站点时,建议按下面这个顺序做,不容易乱。
步骤 1:先判断签名是在请求前生成,还是响应后更新
有些站点的 token/sign 不是当次实时算的,而是:
- 首屏加载时下发
- 某个配置接口返回
- 登录后写入内存变量
- 某个 SDK 初始化后刷新
所以你第一步要先确认:
- 这个值是每次请求前现算
- 还是先拿到,再复用
步骤 2:对请求入口下断点
最稳妥的方式:
- 在
fetch上下断点 - 在
XMLHttpRequest.prototype.send上下断点 - 或对目标 URL 设 XHR Breakpoint
你要看的不是“这个请求发了”,而是“发之前参数对象长什么样”。
步骤 3:观察请求拦截器
现代前端经常用:
- Axios request interceptor
- 自定义 request wrapper
- SDK 封装层
很多签名注入都发生在这里。典型伪代码如下:
axios.interceptors.request.use((config) => {
const ts = Date.now();
const fp = getFingerprint();
const sign = makeSign(config.data, ts, fp);
config.headers['X-Timestamp'] = ts;
config.headers['X-Fp'] = fp;
config.headers['X-Sign'] = sign;
return config;
});
这类代码是逆向中的“黄金地带”,因为:
- 输入参数全
- 输出结果明确
- 很容易顺着调用栈往上找
步骤 4:确认参数归一化规则
很多人算法找对了,结果还是错,问题往往出在拼接规则。
重点确认:
- 是否按 key 排序
- 是否过滤空值
- 数字是否转字符串
- 是否 URL 编码
- 是否大小写敏感
- 数组/对象如何序列化
- 拼接符是
&、,、|还是 JSON 字符串
例如下面三个结果完全不同:
a=1&b=2
b=2&a=1
{"a":1,"b":2}
步骤 5:确认指纹参与位置
有的站点把指纹单独放在 fp 字段里;
有的站点更隐蔽,直接把指纹值拼进签名原串,却不单独发送。
所以你要确认:
- 指纹是作为独立字段发送
- 还是仅作为签名输入
- 还是两者都参与
步骤 6:确认是否有二次包装
常见的“二次包装”包括:
- 先摘要,后 AES 加密
- 先 JSON 序列化,再 Base64
- 先 gzip,再编码
- 使用 WebAssembly 计算中间值
看到长得像密文的字段时,不要急着判断“这是 AES”。
先看输入输出关系,再看长度、字符集和调用来源。
一张更贴近实战的定位图
flowchart TD
A[抓到目标请求] --> B{sign 在哪}
B -->|请求头| C[定位请求拦截器]
B -->|请求体| D[定位 payload 组装点]
B -->|query 参数| E[定位 URL 拼接函数]
C --> F[下断点看调用栈]
D --> F
E --> F
F --> G[找时间戳/随机数来源]
G --> H[找指纹采集函数]
H --> I[找排序与拼接规则]
I --> J[识别摘要/加密算法]
J --> K[浏览器外复现]
逐步验证清单
真正复现时,我建议你不要一次性写完脚本,而是按下面的顺序逐项验证。
第一层:字段是否齐全
确认你能拿到:
- 业务参数
- 时间戳
- nonce
- 指纹
- sign
- 必要请求头
- Cookie / token
第二层:原串是否一致
把浏览器里真正参与签名的字符串打印出来,再与你脚本输出对比。
这是最关键的一步。
如果原串不同,后面都白搭。
第三层:算法输出是否一致
同样输入下,比较:
- 浏览器输出 sign
- 脚本输出 sign
第四层:注入位置是否一致
有时你算对了 sign,但服务端还是拒绝,因为它还校验:
- Header 中的
X-Fp - Header 中的
Origin/Referer - 请求方法
Content-Type- Cookie 中的会话标识
第五层:时间窗口是否有效
很多站点签名只允许 30 秒或 60 秒有效。
你抓包后隔几分钟再重放,失败是正常的。
常见坑与排查
这部分很重要,我挑几个最常见、最容易浪费时间的坑。
1. 以为是算法错了,其实是参数顺序错了
这是最高频坑。
尤其是对象序列化时,不同语言的默认顺序可能不同。
排查建议:
console.log('browser raw:', rawString);
console.log('script raw:', rawStringFromYourCode);
直接比原串,不要只比最终 sign。
2. 指纹字段看到了,但实际用的是“处理后的指纹”
有些站点不是直接拿 navigator.userAgent 参与签名,而是:
- 截断
- 小写化
- 哈希
- 混合多个字段
- 做映射表替换
例如:
const miniUa = md5(navigator.userAgent).slice(8, 24);
你如果直接传原始 UA,验签就会错。
3. 调试时断点改了时序,导致签名失效
这个坑我踩过。
有些接口时间窗口很短,你在断点处停太久,恢复运行时 ts 已经过期。
排查建议:
- 尽量在签名函数入口断,而不是在发送前停太久
- 必要时临时 patch
Date.now() - 或记录原始
ts后快速复算
4. Webpack 打包后函数名全没了,不知道谁是谁
这是常态,不是例外。
不要被 _0xabc123 吓住。看三个东西更有效:
- 调用栈
- 入参结构
- 返回值用途
比如一个函数入参是 {page, keyword},返回值被塞到 X-Sign,那它大概率就是关键链路。
5. Axios 拦截器里改了数据,但你只盯着业务代码
很多同学看到页面里:
search({ keyword, page })
就以为请求体只有这两个字段。
实际发送前,拦截器可能已经加了:
tsnoncesigntraceId
所以一定要看最终出站请求对象。
6. 浏览器能过,脚本不能过,其实是环境不一致
最常见的不一致包括:
- UA 不同
- 时区不同
- 语言不同
- 屏幕尺寸不同
- 是否带上 Cookie
- 请求头大小写/值差异
- HTTP/2 与 HTTP/1.1 行为差异
尤其是指纹参与验签时,环境差异会直接导致失败。
7. 密钥不在明文里,而是在运行时拼出来
很多站点不会直接写:
const salt = "secret";
而是拆成:
const a = "sec";
const b = "ret";
const salt = a + b;
甚至:
- 从数组映射表取字符
- 从 wasm 返回一段字节
- 从配置接口拿一半,本地再拼一半
排查建议:
- 盯“最终入参”
- 盯“摘要函数调用前的最后一跳”
- 不一定要先还原所有混淆
常用定位技巧
Hook 摘要函数
如果你怀疑站点用了 MD5/SHA/HMAC,可以直接在浏览器控制台做轻量 Hook。
例如针对 CryptoJS.SHA256:
(function () {
const origin = CryptoJS.SHA256;
CryptoJS.SHA256 = function (...args) {
console.log('[Hook SHA256 input]:', args[0]);
const result = origin.apply(this, args);
console.log('[Hook SHA256 output]:', result.toString());
return result;
};
})();
这样你能很快看到:
- 输入原串是什么
- 输出签名是什么
- 哪次调用和目标请求对应
Hook 请求发送层
如果目标站点没明显用 CryptoJS,也可以先 Hook 网络层:
(function () {
const originFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch url]:', args[0]);
console.log('[fetch options]:', args[1]);
return originFetch.apply(this, args);
};
})();
Hook XHR:
(function () {
const originSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
console.log('[xhr body]:', body);
return originSend.call(this, body);
};
})();
断在 Date.now / Math.random
很多签名依赖这两个值。你可以搜索:
Date.nownew Date().getTime()Math.randomcrypto.getRandomValues
这些点经常能串出 nonce 和 sign 的生成链。
安全/性能最佳实践
这部分从“复现”视角讲,也顺便说说边界。
1. 优先做“最小复现”,不要一上来全量模拟浏览器
如果站点只用了:
- UA
- 语言
- 时区
那你没必要把 Canvas、WebGL、AudioContext 全补一遍。
先跑最小闭环,再按失败点增量补环境。
2. 将“参数收集”和“签名计算”分层
不管用 Python 还是 Node,都建议拆成:
collect_env()normalize_params()build_sign()send_request()
这样后面站点升级时,你只需要替换局部逻辑。
3. 固化中间结果,方便回归验证
把下面这些值打印或保存下来:
- 原始业务参数
- 指纹原串
- 指纹结果
fp - 签名原串
- 最终
sign
这样下次接口变了,你能快速判断是:
- 指纹层变了
- 排序规则变了
- 盐值变了
- 发送层变了
4. 注意并发与时间漂移
如果签名过期窗口很短,脚本高并发时要注意:
- 每个请求单独生成
ts - 不要复用已过期 sign
- 注意客户端与服务端时间差
- 必要时做 NTP 校时
5. 不要过度依赖“复制浏览器 headers”
一些字段可以补,一些字段没必要硬造。
例如真实场景中:
sec-ch-uasec-fetch-modepriority
这些字段不一定参与校验,盲目复制有时反而增加异常概率。
建议先从最关键字段开始补:
User-AgentRefererOriginContent-Type- Cookie
- 自定义签名头
6. 合法合规是前提
这一点必须明确:
Web 逆向技术可用于接口调试、安全研究、兼容性分析、自动化测试等正当场景,但不得用于未授权的数据获取、绕过访问控制或破坏服务稳定性。
边界条件很重要:
- 仅在授权范围内测试
- 避免高频请求造成服务压力
- 不处理敏感个人信息
- 不传播真实站点私钥、绕过细节和可滥用脚本
一个更贴近项目的代码组织建议
如果你准备长期维护某个站点的复现逻辑,我建议这样拆目录:
project/
├─ sign/
│ ├─ fingerprint.js
│ ├─ normalize.js
│ └─ signer.js
├─ clients/
│ ├─ node-client.js
│ └─ python-client.py
├─ fixtures/
│ └─ sample-payload.json
├─ tests/
│ └─ signer.test.js
└─ README.md
这样做的好处是:
- 站点改版后容易 diff
- 中间结果容易测试
- 不会把“请求发送”和“签名算法”耦死
一次完整排查示例
假设你遇到的问题是:
浏览器请求成功,Python 重放 403,提示 invalid sign。
排查顺序我建议这样:
-
抓浏览器成功请求,记录:
- body
- headers
- cookie
- 响应结果
-
断在请求发送前,记录:
tsfpsign- 签名原串
-
用脚本复现时,先不发请求,只打印:
tsfp- 原串
sign
-
比对:
- 原串是否一致
fp是否一致sign是否一致
-
如果
sign一致但仍失败,再查:- header 是否缺失
- cookie 是否缺失
- token 是否过期
- 时区/UA 是否变了
这个顺序比“瞎猜算法”节省很多时间。
总结
把这篇的重点压缩成几句:
- 前端加密参数不是只看
sign,而是看整条生成链路。 - 浏览器指纹往往不是独立问题,而是签名输入的一部分。
- 定位时优先从请求发送点反推,而不是盲搜源码关键字。
- 复现成败的关键,通常是:输入是否完整、顺序是否一致、环境是否对齐。
- 先最小复现,再逐步补齐环境,是最省时间的路径。
如果你现在就要上手,我给你一个最可执行的建议:
- 先抓一个成功请求
- 在发送前断住
- 打印签名原串
- 把原串在浏览器外先复算一致
- 最后再考虑批量化、自动化和环境模拟
别急着“全站脱混淆”,先把一条请求链打通。
一旦你能稳定复现一个接口,后面的 token、设备 ID、风控字段,其实都只是同一种分析套路的扩展。
希望这篇能帮你把“看得到 sign,但复现不出来”的那层窗户纸捅破。