跳转到内容
123xiao | 无名键客

《从浏览器指纹到请求签名:Web逆向中前端加密参数定位与复现实战》

字数: 0 阅读时长: 1 分钟

从浏览器指纹到请求签名:Web逆向中前端加密参数定位与复现实战

很多人刚接触 Web 逆向时,最容易卡在一个点:接口明明抓到了,参数也看到了,但就是复现不出来
尤其是遇到下面这些字段时,挫败感会非常强:

  • sign
  • token
  • t
  • nonce
  • deviceId
  • fp
  • X-Signature
  • X-Timestamp

看起来只是几个字符串,背后却可能串着一整条前端加密链路:浏览器指纹采集 → 参数归一化 → 排序拼接 → 摘要/加密 → 请求头注入

这篇文章我不打算只讲概念,而是按一条比较真实的逆向路径,带你从浏览器指纹定位到请求签名生成,最后完成可运行复现。
如果你已经会抓包,但总在 JS 混淆、动态注入、签名校验上翻车,这篇会比较对路。


背景与问题

在现代 Web 应用里,前端越来越少只是“渲染页面”的角色。很多站点会把一部分风控与签名逻辑下沉到浏览器端,常见目的有:

  1. 防止接口被直接脚本化调用
  2. 绑定设备环境,降低重放成功率
  3. 增加参数伪造成本
  4. 将关键密钥切碎后藏在前端运行时里

于是我们在抓一个请求时,经常会看到这类现象:

  • 请求体里多了一个 sign
  • 请求头里有 X-TokenX-FpX-Trace
  • sign 每次都变,且和时间戳相关
  • 同一个接口,在浏览器里能成功,在 Python 里就 401/403
  • 补齐 Cookie 也没用,说明问题不只是会话态

这时候如果只盯着网络面板,很容易陷入“抓到参数但不知道怎么来的”这个死循环。

本文要解决的核心问题

我们重点解决三件事:

  • 如何定位前端加密参数的生成位置
  • 如何识别浏览器指纹参与了哪些签名输入
  • 如何在浏览器外复现这条签名链路

前置知识

建议你至少熟悉以下内容:

  • Chrome DevTools 基础调试
  • JavaScript 基础语法
  • 抓包与请求重放
  • Python 或 Node.js 任一种脚本语言

如果你问我“必须会 AST 还原和大规模脱混淆吗”,答案是不一定。
中级阶段最有价值的能力,不是上来就做全量还原,而是先把参数生成路径跑通


环境准备

本文示例使用以下工具:

  • Chrome / Edge DevTools
  • Node.js 18+
  • Python 3.10+
  • 可选:
    • mitmproxy
    • Charles
    • Fiddler
    • js-beautify

安装 Node 依赖:

npm init -y
npm install crypto-js express body-parser

核心原理

从逆向角度看,请求签名通常不是孤立的,它经常由四层组成:

  1. 基础业务参数
    如页码、关键字、商品 ID、时间范围

  2. 环境参数 / 指纹参数
    如 UA、屏幕分辨率、时区、语言、Canvas 指纹、WebGL 信息、平台信息

  3. 动态扰动参数
    如时间戳、随机数、nonce、traceId

  4. 签名算法
    排序、拼接、摘要、HMAC、AES 包装、Base64 等

可以先记住一句经验话:

99% 的“前端加密参数”问题,本质上是“找输入 + 找顺序 + 找算法 + 找注入点”。

一个典型的签名链路

flowchart LR
    A[业务参数] --> E[参数归一化]
    B[浏览器指纹] --> E
    C[时间戳/随机数] --> E
    E --> F[排序与拼接]
    F --> G[摘要或加密]
    G --> H[写入请求头/请求体]
    H --> I[发起请求]

浏览器指纹为什么关键

很多同学只盯着 sign,但忽略了 sign 的输入。
实际中,签名失败不一定是算法错了,更常见是少了一个环境输入

常见指纹维度包括:

  • navigator.userAgent
  • navigator.language
  • navigator.platform
  • screen.width / height
  • devicePixelRatio
  • Intl.DateTimeFormat().resolvedOptions().timeZone
  • Canvas 绘制结果
  • WebGL 渲染器
  • 音频上下文特征
  • 插件列表
  • 字体特征

站点不一定全用,但只要其中某几项参与签名,而你脚本里没补齐,就会出现:

  • 签名格式对,但验签失败
  • 首次请求成功,后续失败
  • 同账号同 Cookie,换机器就失效

先建立定位思路:别一上来就搜 sign

我自己刚开始做这类题时,也喜欢全局搜 signmd5sha256
说实话,这个方法有时有用,但在现代打包工程里,命中率并不稳定。更可靠的方式是:

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 请求

这个时候,调用栈比全局搜索更值钱
因为混淆后的函数名可能是 _0x3ab1nra,但调用关系骗不了人。


实战案例设计

为了让过程完整可运行,下面我用一个简化但贴近真实项目的场景:

前端请求 /api/search 时,发送如下数据:

  • keyword
  • page
  • ts
  • fp
  • sign

签名规则假设为:

  1. 采集基础指纹:

    • UA
    • 语言
    • 屏幕尺寸
    • 时区
  2. 生成 fp

    • 将指纹字段按固定顺序拼接
    • 做一次 MD5
  3. 生成 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 })

就以为请求体只有这两个字段。
实际发送前,拦截器可能已经加了:

  • ts
  • nonce
  • sign
  • traceId

所以一定要看最终出站请求对象。


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.now
  • new Date().getTime()
  • Math.random
  • crypto.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-ua
  • sec-fetch-mode
  • priority

这些字段不一定参与校验,盲目复制有时反而增加异常概率。
建议先从最关键字段开始补:

  • User-Agent
  • Referer
  • Origin
  • Content-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。

排查顺序我建议这样:

  1. 抓浏览器成功请求,记录:

    • body
    • headers
    • cookie
    • 响应结果
  2. 断在请求发送前,记录:

    • ts
    • fp
    • sign
    • 签名原串
  3. 用脚本复现时,先不发请求,只打印:

    • ts
    • fp
    • 原串
    • sign
  4. 比对:

    • 原串是否一致
    • fp 是否一致
    • sign 是否一致
  5. 如果 sign 一致但仍失败,再查:

    • header 是否缺失
    • cookie 是否缺失
    • token 是否过期
    • 时区/UA 是否变了

这个顺序比“瞎猜算法”节省很多时间。


总结

把这篇的重点压缩成几句:

  1. 前端加密参数不是只看 sign,而是看整条生成链路。
  2. 浏览器指纹往往不是独立问题,而是签名输入的一部分。
  3. 定位时优先从请求发送点反推,而不是盲搜源码关键字。
  4. 复现成败的关键,通常是:输入是否完整、顺序是否一致、环境是否对齐。
  5. 先最小复现,再逐步补齐环境,是最省时间的路径。

如果你现在就要上手,我给你一个最可执行的建议:

  • 先抓一个成功请求
  • 在发送前断住
  • 打印签名原串
  • 把原串在浏览器外先复算一致
  • 最后再考虑批量化、自动化和环境模拟

别急着“全站脱混淆”,先把一条请求链打通
一旦你能稳定复现一个接口,后面的 token、设备 ID、风控字段,其实都只是同一种分析套路的扩展。

希望这篇能帮你把“看得到 sign,但复现不出来”的那层窗户纸捅破。


分享到:

上一篇
《Spring Boot + MyBatis 在 Java Web 开发中的实战:基于 RBAC 的后台权限系统设计与接口安全落地》
下一篇
《Java Web开发实战:基于Spring Boot与JWT实现权限认证、接口防刷与统一异常处理》