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

《Web逆向实战:基于浏览器开发者工具定位并还原前端加密签名生成流程》

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

Web逆向实战:基于浏览器开发者工具定位并还原前端加密签名生成流程

很多同学第一次做 Web 逆向时,卡住的点并不是“不会写代码”,而是不知道从哪里下手:页面请求里多了个 signtokenx-ssignature 参数,服务端一校验就报错;明明参数长得像加密值,但前端代码被混淆、压缩、拆包后,想定位生成逻辑像在草堆里找针。

这篇文章我不讲“玄学猜测”,而是带你走一条可复现、可验证、可落地的路径:基于浏览器开发者工具,定位签名生成入口,观察参与参数,还原签名算法,并在本地用代码复现。文章偏实战,适合已经懂一点 JavaScript、浏览器调试和抓包的中级读者。

说明:本文讨论的是学习前端安全机制、接口调试与合法授权下的分析方法。请仅在授权测试、个人学习、企业自有系统排障等合法场景下使用。


背景与问题

我们先抽象一下常见场景:

前端发起请求时,请求头或 Query 参数里会带上一段签名,比如:

  • sign=4c8f...
  • x-signature: abcdef...
  • token: eyJ...
  • t=时间戳
  • nonce=随机串

很多接口不是简单校验某个固定值,而是将:

  • 请求路径
  • 请求方法
  • 业务参数
  • 时间戳
  • nonce
  • 用户态 token
  • 某个内置 secret 或盐值

按约定顺序拼接后,再经过:

  • MD5
  • SHA1/SHA256
  • HmacSHA256
  • AES/RSA 混合
  • 自定义字符变换 / Base64 / URLSafe 编码

最终生成签名。

问题是:你拿到的是最终请求,不知道中间过程。而 Web 逆向的关键恰恰不是“猜出结果”,而是还原生成流程


前置知识与环境准备

开始前,建议你准备这些工具:

  • Chrome / Edge 浏览器
  • 开发者工具(DevTools)
  • 一个支持格式化 JS 的编辑器,如 VS Code
  • Node.js 18+
  • 可选:Charles / Fiddler / mitmproxy(辅助观察请求)
  • 可选:SourceMap Explorer 或 AST 工具(处理复杂混淆)

你至少需要掌握:

  • DevTools 的 Network / Sources / Console
  • JS 基本语法
  • Promise、XHR、Fetch 的调用方式
  • 基础哈希概念:MD5、SHA 系列、HMAC

背景与问题:为什么优先从浏览器开发者工具切入

不少人一上来就把整个站点 JS 全部下载下来“静态看代码”。这不是不行,但成本很高。实际项目里,前端代码往往:

  • 打包为多个 chunk
  • 文件名哈希化
  • 混淆变量名
  • 有 sourcemap 但生产环境关了
  • 动态加载模块
  • 使用 Webpack runtime 包装

这时,浏览器开发者工具的优势是你能看到“运行时”

  1. 哪个请求带了签名
  2. 请求发起栈在哪
  3. 哪段代码在真正执行
  4. 运行时变量是什么
  5. 调用顺序是什么

换句话说,DevTools 让你少走很多弯路。


核心原理

要还原前端签名,核心不是盯着“加密算法”四个字,而是拆成 4 个问题:

  1. 签名在哪生成?
  2. 参与签名的数据有哪些?
  3. 这些数据按什么顺序、什么格式拼接?
  4. 最后用了什么算法输出?

把这 4 个问题搞清楚,复现就不难了。

一个典型签名链路

flowchart TD
    A[用户触发请求] --> B[业务参数收集]
    B --> C[补充时间戳/nonce]
    C --> D[参数排序/序列化]
    D --> E[拼接盐值或密钥]
    E --> F[哈希或加密]
    F --> G[写入请求头/Query]
    G --> H[发送到服务端]

常见签名参与项

实际中最常见的是以下几类:

  • 业务参数:如 page=1&size=20
  • 时间戳:防重放
  • 随机串 nonce:增加请求唯一性
  • 请求路径:例如 /api/user/list
  • HTTP 方法:GET / POST
  • body 摘要:POST JSON 场景常见
  • 固定盐值:写死在前端,或者拆散后再拼
  • 用户态信息:token、uid、session

常见输出形式

  • 32 位 hex:通常让人联想到 MD5
  • 40 位 hex:常见 SHA1
  • 64 位 hex:常见 SHA256/HmacSHA256
  • Base64:常见对二进制摘要做编码
  • URLSafe Base64:把 + / = 做替换
  • 长串 JSON / JWE 样式:可能是更复杂的 token 结构

一套稳定的定位思路

这里给你一套我实战里经常用的流程,尤其适合“看起来很乱”的前端项目。

flowchart LR
    A[Network 观察异常参数] --> B[定位请求发起位置]
    B --> C[在 XHR/Fetch 断点处拦截]
    C --> D[回溯调用栈]
    D --> E[锁定签名函数]
    E --> F[观察入参与中间值]
    F --> G[本地复现]
    G --> H[对比线上结果校验]

实战示例:一步步定位并还原签名

下面我们用一个可运行的简化案例来演示完整思路。示例不是某个真实站点,而是把真实项目里常见特征抽出来,方便你练手。

目标请求

前端发出的请求是:

GET /api/user/list?page=1&size=20&t=1710000000000&nonce=ab12cd34&sign=xxxx

我们要搞清楚 sign 是怎么来的。


第一步:在 Network 面板确认异常参数

打开浏览器开发者工具,进入 Network 面板,执行页面操作后找到目标请求。

重点观察:

  • Query String Parameters
  • Request Headers
  • Form Data / Payload
  • Initiator

你会发现这个请求里多了:

  • t
  • nonce
  • sign

这说明签名大概率依赖时间戳和随机串,而不是一个固定值。

观察重点

  1. sign 是否每次请求都变化?
  2. 只改一个业务参数时,sign 是否同步变化?
  3. 刷新页面后,nonce 是否变化?
  4. 相同参数、不同时间下,是否只有 t/sign 变化?

如果答案是“是”,那么基本可以判断:
签名依赖请求参数 + 时间戳 + 随机值。


第二步:从请求发起点反查代码

在 Network 里点开该请求,查看 Initiator。很多时候能看到:

  • 某个打包后的 js 文件
  • 某个函数调用栈
  • fetch
  • XMLHttpRequest.send

如果看不到足够信息,建议直接在 Sources 面板中开启以下断点:

  • XHR/fetch Breakpoints
  • 关键字可填:/api/user/list
  • 或者直接断在所有 XHR / fetch 上

这样请求发起前,代码会停住。

断住后看什么?

停住时,不要急着“单步到底”,先看:

  • Call Stack
  • Scope
  • 当前函数入参
  • 局部变量里是否已有 signnoncetimestamp

很多情况下,你会看到类似这样的调用链:

requestUserList -> buildParams -> makeSign -> fetch

如果变量名被混淆了,也没关系。你只关心一件事:哪个函数返回的值最终塞进了 sign 字段。


第三步:锁定签名函数

假设你在断点里看到这样一段逻辑(这里用可读代码模拟):

function requestUserList(page, size) {
  const params = {
    page,
    size,
    t: Date.now(),
    nonce: randomString(8)
  };
  params.sign = makeSign("/api/user/list", params);
  return fetch("/api/user/list?" + new URLSearchParams(params));
}

那显然,关键就在 makeSign

继续进入 makeSign,看到:

function makeSign(path, params) {
  const keys = Object.keys(params).sort();
  const query = keys.map(k => `${k}=${params[k]}`).join("&");
  const raw = `${path}?${query}#k9JmVqP3`;
  return md5(raw);
}

到这里,签名链路就很清晰了:

  1. 参数对象取 key
  2. 按字典序排序
  3. 拼成 k=v&k=v
  4. 加上路径
  5. 末尾拼 secret:#k9JmVqP3
  6. 对整个字符串做 md5

用时序图理解一次完整请求

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant N as Network请求
    participant B as 服务端

    U->>P: 点击“查询”
    P->>P: 组装 page/size
    P->>P: 生成 t 和 nonce
    P->>S: makeSign(path, params)
    S->>S: 排序/拼接/MD5
    S-->>P: 返回 sign
    P->>N: 发起 fetch/XHR
    N->>B: 携带 sign 请求
    B-->>N: 校验通过并响应
    N-->>P: 返回业务数据

第四步:验证“不是看起来像”,而是真的对

做逆向最怕“看起来像对了,其实只抄到表面”。所以必须做逐步验证

验证清单

你可以按下面顺序验证:

  • 固定 page=1,size=20,t=1710000000000,nonce=ab12cd34
  • 观察浏览器里最终 sign
  • 在 Console 里手动调用 makeSign
  • 在本地 Node.js 里复现
  • 比较结果是否完全一致
  • 改变 page 再试一次
  • 调换参数顺序再试一次
  • 去掉 secret 再试一次,确认结果会不同

浏览器 Console 验证

如果页面作用域里还能直接访问到函数,可以在 Console 中尝试:

makeSign("/api/user/list", {
  page: 1,
  size: 20,
  t: 1710000000000,
  nonce: "ab12cd34"
});

如果页面里拿不到这个函数,也可以把断点里看到的关键逻辑手抄出来执行。


实战代码(可运行)

下面给出一个完整的 Node.js 版本复现代码。你可以直接保存为 sign_demo.js 运行。

1)签名生成代码

const crypto = require("crypto");

function md5(text) {
  return crypto.createHash("md5").update(text, "utf8").digest("hex");
}

function buildSign(path, params, secret = "#k9JmVqP3") {
  const keys = Object.keys(params).sort();
  const query = keys.map(k => `${k}=${params[k]}`).join("&");
  const raw = `${path}?${query}${secret}`;
  return md5(raw);
}

function buildRequestParams(page, size) {
  const params = {
    page,
    size,
    t: 1710000000000,
    nonce: "ab12cd34"
  };
  params.sign = buildSign("/api/user/list", params);
  return params;
}

const params = buildRequestParams(1, 20);
console.log("params =", params);
console.log("query =", new URLSearchParams(params).toString());

运行:

node sign_demo.js

2)如果站点用的是 HMAC-SHA256

不少站点并不是单纯 md5(raw),而是:

const crypto = require("crypto");

function hmacSha256(text, secret) {
  return crypto
    .createHmac("sha256", secret)
    .update(text, "utf8")
    .digest("hex");
}

function buildSign(path, params, secret = "my_secret_key") {
  const keys = Object.keys(params).sort();
  const query = keys.map(k => `${k}=${params[k]}`).join("&");
  const raw = `${path}|${query}`;
  return hmacSha256(raw, secret);
}

const params = {
  page: 1,
  size: 20,
  t: 1710000000000,
  nonce: "ab12cd34"
};

console.log(buildSign("/api/user/list", params));

3)浏览器端等价实现

如果你想在浏览器 Console 中快速验证,可用 Web Crypto API 实现 SHA-256(注意:Web Crypto 不直接提供 MD5)。

async function sha256Hex(text) {
  const encoder = new TextEncoder();
  const data = encoder.encode(text);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}

async function buildSign(path, params) {
  const secret = "#k9JmVqP3";
  const keys = Object.keys(params).sort();
  const query = keys.map(k => `${k}=${params[k]}`).join("&");
  const raw = `${path}?${query}${secret}`;
  return await sha256Hex(raw);
}

(async () => {
  const params = {
    page: 1,
    size: 20,
    t: 1710000000000,
    nonce: "ab12cd34"
  };
  console.log(await buildSign("/api/user/list", params));
})();

一个更贴近真实项目的定位技巧

真实项目往往不会把 makeSign 这样明晃晃地写出来。更常见的是:

  • 函数名被混淆成 a, b1, _0x3f2a
  • md5 被封装多层
  • secret 被拆开保存在数组中
  • 请求经 Axios 拦截器统一处理

这时建议你换一个入口:从请求框架层向回追

Axios 场景的典型位置

你可以优先搜索这些关键词:

  • interceptors.request.use
  • headers
  • transformRequest
  • URLSearchParams
  • crypto
  • md5
  • sha
  • sign
  • nonce
  • timestamp

如果全局搜索不到明文关键词,就在请求断点停住后,看配置对象:

config.url
config.params
config.data
config.headers

签名很多时候就是在这里被挂进去的。

一个简化版 Axios 拦截器示例

import axios from "axios";
import crypto from "crypto";

function md5(text) {
  return crypto.createHash("md5").update(text, "utf8").digest("hex");
}

function signParams(url, params) {
  const keys = Object.keys(params).sort();
  const query = keys.map(k => `${k}=${params[k]}`).join("&");
  return md5(`${url}?${query}#k9JmVqP3`);
}

const client = axios.create();

client.interceptors.request.use(config => {
  const params = {
    ...(config.params || {}),
    t: Date.now(),
    nonce: Math.random().toString(36).slice(2, 10)
  };

  params.sign = signParams(config.url, params);
  config.params = params;
  return config;
});

如果你在断点中看到类似结构,基本就接近答案了。


常见坑与排查

这部分很重要。我自己踩过不少坑,很多时候不是算法没看懂,而是细节没对齐。

1. 参数顺序不一致

签名最常见的问题就是顺序

浏览器里是:

Object.keys(params).sort()

你本地却按对象原始顺序拼接,那结果必然不一样。

排查建议

把用于签名的原始字符串直接打印出来,逐字符对比:

console.log(raw);

不要只对比最终 sign


2. URL 编码差异

有些站点签名前用的是原始值,有些用的是 encodeURIComponent 后的值。

比如空格可能是:

  • %20
  • +

中文、特殊字符更容易出问题。

排查建议

确认这几点:

  • 是先拼接再编码,还是先编码再拼接
  • body JSON 是否做了 JSON.stringify
  • 数组是否用逗号拼接
  • 对象是否先序列化

3. 时间戳单位搞错

常见有两种:

  • 秒级:1710000000
  • 毫秒级:1710000000000

差 1000 倍,签名必错。

排查建议

在浏览器里直接打印参与签名的时间戳,别靠猜。


4. secret 不是明文常量

有些代码会把 secret 拆成多段,例如:

const s = ["k9", "Jm", "Vq", "P3"].join("");

甚至做字符位移、数组倒序、Base64 解码后再拼。

排查建议

不要只看静态字符串,要看运行时最终值
断点停在哈希调用前,查看传入的 rawsecret 最可靠。


5. 哈希算法判断错了

很多人看到 32 位 hex 就先入为主认定是 MD5,但也可能是:

  • 截断后的 SHA
  • 自定义编码结果
  • 多次 hash 后截位

排查建议

优先看实际调用的 API:

  • CryptoJS.MD5
  • CryptoJS.SHA256
  • crypto.subtle.digest
  • createHash("md5")
  • createHmac("sha256", key)

别只靠长度猜。


6. 请求体参与签名,但你漏了

POST 请求特别容易踩这个坑。尤其是 JSON body:

{"page":1,"size":20}

签名可能是对整个 body 字符串求摘要,再和其他字段一起拼接。

排查建议

重点看:

  • config.data
  • JSON.stringify(data)
  • transformRequest
  • Content-Type

如果 body 参与签名,字段顺序、空格、转义都可能影响结果。


7. 代码有反调试或动态改写

一些前端会:

  • 检测 DevTools 打开
  • 重写 Function.prototype.toString
  • 动态生成函数
  • 通过 evalnew Function 执行代码

排查建议

  • 在关键 API 上打断点,而不是只看源码
  • 观察运行时变量
  • 必要时重写关键函数做日志输出
  • 把混淆代码“跑起来再抓中间值”

一种很实用的“插桩”办法

当代码太绕、不好单步时,我常用的方法是对关键函数做插桩
比如页面用了 CryptoJS.MD5,你可以在 Console 里临时包一层日志。

浏览器插桩示例

(function () {
  if (!window.CryptoJS || !CryptoJS.MD5) {
    console.log("CryptoJS.MD5 not found");
    return;
  }

  const original = CryptoJS.MD5;
  CryptoJS.MD5 = function (...args) {
    console.log("[MD5 input]:", args[0] && args[0].toString ? args[0].toString() : args[0]);
    const result = original.apply(this, args);
    console.log("[MD5 output]:", result.toString());
    return result;
  };

  console.log("MD5 hooked");
})();

这样下次请求触发时,你就能在 Console 看到哈希输入和输出。

如果不是 CryptoJS,也可以 hook:

  • window.fetch
  • XMLHttpRequest.prototype.send
  • JSON.stringify
  • 某个局部导出的工具函数

安全/性能最佳实践

这一节从“分析者”和“开发者”两个角度都说一下。

对分析者:保持最小化、可验证、可复现

  1. 先定位,再复现,不要盲目抄整站代码

    • 只还原签名链路需要的最小逻辑
    • 这样更稳定,也更容易排错
  2. 保存中间值

    • 原始参数
    • 排序后参数
    • 拼接字符串
    • 最终签名
  3. 对照实验

    • 每次只改一个变量
    • 看签名如何变化
  4. 注意授权边界

    • 学习与调试可以
    • 不要在未授权系统做批量调用或绕过风控

对前端/服务端开发者:不要把“前端签名”当作真正安全边界

这一点非常关键。前端签名的本质是:

  • 增加滥用门槛
  • 提高脚本模拟成本
  • 辅助风控
  • 提供一定的完整性校验

但它不能替代服务端安全。原因很简单:前端代码最终运行在用户环境里,理论上都能被分析。

更合理的做法

  • 核心鉴权放服务端
  • 短期 token + 服务端校验
  • 配合限流、风控、设备指纹
  • nonce + timestamp 防重放
  • 服务端验证请求来源与行为模式
  • 敏感 secret 不下发前端

性能方面的建议

如果你是开发者,在前端做签名时要注意:

  • 不要在大对象上频繁深拷贝排序
  • 避免每次请求都做重型加密
  • 尽量复用序列化逻辑
  • 明确 body 规范,减少前后端不一致
  • 对大文件上传不要做同步阻塞型计算

逐步验证清单

如果你准备把本文的方法用于真实页面,这份清单可以直接照着做:

1. 在 Network 中锁定目标请求
2. 记录 sign/timestamp/nonce 等字段
3. 看 Initiator,判断来自 fetch/XHR/axios 哪一层
4. 在 Sources 添加 XHR/fetch 断点
5. 请求停住后,看 Call Stack
6. 找到参数被写入 sign 的那行代码
7. 继续进入签名函数
8. 记录:
   - 入参
   - 排序规则
   - 拼接字符串
   - secret
   - hash/encrypt 算法
9. 在 Console 手动复算一次
10. 在 Node.js 本地写最小复现脚本
11. 与浏览器结果逐字符对比
12. 修改单一参数,确认算法稳定成立

一个最小完整示例:前后端验证思维

为了让整个流程更完整,这里再给一个“小闭环”示例:前端生成签名,服务端验证签名。

前端生成

const crypto = require("crypto");

function md5(text) {
  return crypto.createHash("md5").update(text, "utf8").digest("hex");
}

function makeClientSign(path, params) {
  const secret = "#k9JmVqP3";
  const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join("&");
  return md5(`${path}?${sorted}${secret}`);
}

const path = "/api/user/list";
const params = {
  page: 1,
  size: 20,
  t: 1710000000000,
  nonce: "ab12cd34"
};

const sign = makeClientSign(path, params);
console.log(sign);

服务端验证

const crypto = require("crypto");

function md5(text) {
  return crypto.createHash("md5").update(text, "utf8").digest("hex");
}

function verifySign(path, params, clientSign) {
  const secret = "#k9JmVqP3";
  const payload = { ...params };
  delete payload.sign;

  const sorted = Object.keys(payload).sort().map(k => `${k}=${payload[k]}`).join("&");
  const expected = md5(`${path}?${sorted}${secret}`);

  return expected === clientSign;
}

const ok = verifySign(
  "/api/user/list",
  {
    page: 1,
    size: 20,
    t: 1710000000000,
    nonce: "ab12cd34"
  },
  "这里替换成客户端算出来的 sign"
);

console.log("verify =", ok);

这类最小示例的价值在于:你会更清楚自己到底在还原什么,而不是“把浏览器里的某段代码搬出来运行”。


总结

基于浏览器开发者工具做 Web 逆向,还原前端签名生成流程,最重要的不是背多少算法,而是掌握一条稳定方法:

  1. 先在 Network 找到异常签名字段
  2. 用 XHR/fetch 断点拦住请求发起
  3. 沿调用栈回溯,锁定签名函数
  4. 记录参与项、排序规则、拼接格式、算法类型
  5. 在 Console 和本地脚本中双重验证
  6. 通过单变量实验确认还原结果正确

如果你只记住一句话,那就是:

别直接猜签名,先抓“签名前的原始字符串”。

因为只要原始字符串和算法都对了,最终结果自然会对;反过来,只盯着最终 sign 去蒙,往往越猜越乱。

最后给你几个可执行建议:

  • 遇到复杂站点,优先从请求断点切入,不要先看全量混淆代码
  • 每次都保存中间值,尤其是拼接前后的字符串
  • 先做最小复现,再考虑自动化
  • 如果目标站点有明显反调试,优先 hook 关键 API 抓运行时数据
  • 永远在合法授权的边界内分析与测试

只要你把“定位入口—观察变量—还原规则—本地验证”这条链路走熟,绝大多数前端签名场景都能拆开来看。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache 与 Redis 实现多级缓存的实战方案与性能调优》
下一篇
《自动化测试中的稳定性治理实践:从脆弱用例定位到持续集成中的误报降噪》