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

《Web逆向实战:中级开发者如何定位并复现前端签名参数生成逻辑》

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

背景与问题

很多中级开发者第一次做 Web 逆向时,卡住的并不是“不会写代码”,而是不知道该从哪里下手

你抓到一个请求,发现参数里多了这些东西:

  • sign
  • token
  • timestamp
  • nonce
  • m
  • t
  • 一串看起来像摘要的十六进制字符串

这时候最常见的误区有两个:

  1. 上来就全局搜 sign,结果搜出几百处调用,越看越乱。
  2. 拿到请求就直接猜算法,MD5、SHA1、SHA256 轮着试,试到怀疑人生。

我自己早期也踩过这个坑:抓到包以后,以为只要把参数拼一拼哈希一下就行,结果折腾半天才发现,真正参与签名的并不只是接口参数,还包括了固定盐值、时间戳、字段排序规则,甚至还有一次编码转换。

这篇文章的目标不是讨论“某个站点怎么破”,而是讲一套可复用的定位方法
如何从浏览器抓包出发,逐步定位前端签名参数生成逻辑,并在本地完整复现。

适用场景:学习接口调试、理解前端签名流程、排查自动化请求失败原因
不适用场景:绕过权限、攻击未授权系统


前置知识与环境准备

如果你已经会下面这些,可以直接跳到实战部分:

  • 会用浏览器开发者工具(Chrome DevTools)
  • 知道 Network / Sources / Console
  • 能看懂基础 JavaScript
  • 会用 Node.js 跑脚本

建议准备:

  • Chrome 或 Edge 浏览器
  • Node.js 16+
  • 一个抓包代理工具(可选)
  • 格式化/反混淆工具(可选)

本文示例用一个教学化的签名模型来演示,流程和真实业务很接近:

  1. 前端收集请求参数
  2. 加入 timestampnonce
  3. 对参数按 key 排序
  4. 拼接固定盐值 appSecret
  5. 使用 MD5 生成 sign
  6. 发起请求

虽然示例简单,但定位思路是通用的。


背景分析:签名到底在解决什么问题

先别急着逆向,先理解为什么前端会有“签名”。

常见目的有:

  • 防止接口被随便拼参数重放
  • 防止关键参数被篡改
  • 做请求来源校验
  • 给风控系统提供额外判定信息

但这里有个现实问题:

只要签名逻辑在前端执行,浏览器最终就必须拿到算法和参与签名的数据。
所以对开发者来说,难点通常不是“算法不存在”,而是:

  • 代码被压缩混淆了
  • 函数链路很长
  • 调用入口很多
  • 参数在发送前被二次加工
  • 签名逻辑可能塞在 webpack 模块、闭包、hook 过的请求库里

所以我们真正要做的不是“猜算法”,而是:

定位调用链 → 识别关键输入 → 还原拼接规则 → 在本地复现


核心原理

1. 签名生成的典型结构

前端签名通常由下面几类输入组成:

  • 业务参数:如 page=1&keyword=phone
  • 动态参数:时间戳、随机串、设备标识
  • 静态参数:appId、版本号、渠道号
  • 密钥材料:盐值、固定 token、内部常量

生成流程通常像这样:

flowchart TD
    A[采集请求参数] --> B[补充 timestamp/nonce]
    B --> C[字段排序]
    C --> D[拼接字符串]
    D --> E[加入固定盐值]
    E --> F[摘要计算 MD5/SHA/HMAC]
    F --> G[写入 sign]
    G --> H[发送请求]

2. 你真正需要找的不是“sign”,而是“发送前最后一步”

很多人会在源码里搜:

  • sign
  • md5
  • sha
  • crypto

这样有时候能命中,但不稳定。更稳的思路是:

  1. 先找到目标请求
  2. 定位请求发起点
  3. 在发起点往上追参数加工过程
  4. 找到 sign 写入时刻

换句话说,不是“先找算法”,而是“先找请求是怎么发出去的”。

3. 常见签名算法线索

从经验看,可以先观察签名值的形态:

  • 32 位十六进制:常见 MD5
  • 40 位十六进制:常见 SHA1
  • 64 位十六进制:常见 SHA256
  • base64 风格:可能是 HMAC、AES、或摘要后二次编码
  • 很短但变化频繁:可能做了截断、位运算、字符映射

但注意:长度只能作为线索,不能直接下结论。


定位思路:从请求出发反推签名逻辑

这一段是全文最重要的部分。

步骤 1:在 Network 锁定目标请求

打开浏览器开发者工具,进入 Network

  • 勾选 Preserve log
  • 触发目标接口
  • 观察请求方法、URL、QueryString、Request Payload、Headers

重点看:

  • 哪些参数每次都变
  • 哪些参数看起来像签名
  • 请求发起时机是点击、滚动、页面加载还是定时器

可以先记录一份样本,比如:

page=1
keyword=phone
timestamp=1710000000
nonce=ab12cd34
sign=5f4dcc3b5aa765d61d8327deb882cf99

步骤 2:看 Initiator 和调用栈

在目标请求详情里看:

  • Initiator
  • Call Stack

这里经常能直接看到:

  • fetch
  • 还是 XMLHttpRequest
  • 还是 axios 封装层

如果运气好,你会直接跳到发请求的源码位置。

步骤 3:在 Sources 里下断点

建议在这些点下断:

  • fetch 调用前
  • XMLHttpRequest.prototype.send
  • axios 请求拦截器
  • 请求公共封装函数

如果页面比较复杂,可以直接在 Console 注入 hook。


实战代码(可运行)

下面我用一个完整的小型示例来演示“签名生成”和“本地复现”。

示例签名规则

规则如下:

  1. 业务参数:page, keyword
  2. 动态参数:timestamp, nonce
  3. 所有参数按 key 升序排序
  4. 拼接为 key=value&key=value...
  5. 在末尾追加 &secret=demo_secret_123
  6. 对结果做 MD5,得到 sign

前端页面里的示例代码

这是一个浏览器侧示例,模拟真实项目中发请求前的签名逻辑:

function md5(text) {
  return CryptoJS.MD5(text).toString();
}

function buildSign(params) {
  const secret = "demo_secret_123";
  const sortedKeys = Object.keys(params).sort();
  const query = sortedKeys
    .map((key) => `${key}=${params[key]}`)
    .join("&");
  const raw = `${query}&secret=${secret}`;
  return md5(raw);
}

async function sendRequest() {
  const params = {
    page: 1,
    keyword: "phone",
    timestamp: Math.floor(Date.now() / 1000),
    nonce: Math.random().toString(16).slice(2, 10),
  };

  params.sign = buildSign(params);

  return fetch("/api/search", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(params),
  });
}

如何在浏览器里 hook 请求

如果你还没找到请求封装位置,可以先 hook fetch

const rawFetch = window.fetch;

window.fetch = async function (...args) {
  console.log("[fetch args]", args);

  const [url, config] = args;
  if (config && config.body) {
    try {
      console.log("[request body]", JSON.parse(config.body));
    } catch (e) {
      console.log("[request body raw]", config.body);
    }
  }

  debugger;
  return rawFetch.apply(this, args);
};

这段代码的作用很直接:

  • 打印请求参数
  • 在发送前暂停
  • 让你顺着调用栈往上看是谁生成了 sign

这一步在真实站点里非常有效,因为你不用先读完混淆代码,先把请求截在门口

更进一步:hook 摘要函数

如果怀疑用了 CryptoJS.MD5,可以继续 hook:

const rawMD5 = CryptoJS.MD5;

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

你会立刻看到:

  • 被哈希的原始字符串是什么
  • 输出值是什么
  • 调用点在哪里

很多时候,看到 MD5 input 的那一刻,问题就已经解决了。


逐步复现:Node.js 本地实现签名

定位出规则后,下一步是在本地复现。下面给出可运行版本。

Node.js 版本签名函数

const crypto = require("crypto");

function buildSign(params) {
  const secret = "demo_secret_123";
  const sortedKeys = Object.keys(params).sort();
  const query = sortedKeys
    .map((key) => `${key}=${params[key]}`)
    .join("&");
  const raw = `${query}&secret=${secret}`;

  return crypto.createHash("md5").update(raw, "utf8").digest("hex");
}

function buildRequestData(page, keyword) {
  const data = {
    page,
    keyword,
    timestamp: Math.floor(Date.now() / 1000),
    nonce: Math.random().toString(16).slice(2, 10),
  };

  data.sign = buildSign(data);
  return data;
}

const reqData = buildRequestData(1, "phone");
console.log(reqData);

运行:

node sign-demo.js

输出类似:

{
  page: 1,
  keyword: 'phone',
  timestamp: 1710000000,
  nonce: 'ab12cd34',
  sign: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
}

发起真实请求的示例

如果你要模拟请求,可以继续这样写:

const crypto = require("crypto");

async function main() {
  const data = {
    page: 1,
    keyword: "phone",
    timestamp: Math.floor(Date.now() / 1000),
    nonce: Math.random().toString(16).slice(2, 10),
  };

  data.sign = buildSign(data);

  const res = await fetch("http://localhost:3000/api/search", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "User-Agent": "Mozilla/5.0",
    },
    body: JSON.stringify(data),
  });

  const text = await res.text();
  console.log(text);
}

function buildSign(params) {
  const secret = "demo_secret_123";
  const sortedKeys = Object.keys(params).sort();
  const query = sortedKeys
    .map((key) => `${key}=${params[key]}`)
    .join("&");
  const raw = `${query}&secret=${secret}`;
  return crypto.createHash("md5").update(raw, "utf8").digest("hex");
}

main().catch(console.error);

定位链路示意图

实际工作中,我通常会按下面这个顺序定位:

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant R as 请求封装层
    participant A as 接口服务

    U->>P: 点击搜索
    P->>P: 组装业务参数
    P->>S: 生成 timestamp/nonce/sign
    S-->>P: 返回 sign
    P->>R: 注入完整请求参数
    R->>A: 发送 HTTP 请求
    A-->>R: 返回结果
    R-->>P: 渲染页面

这张图想表达的重点是:

签名函数通常不直接发请求,请求层也通常不直接计算签名。
你要做的是把这两层接起来。


如何判断自己已经“复现成功”

很多人以为“本地算出了一个 sign”就叫成功,其实不够。

真正的复现成功,至少要满足下面几点:

  • 同一组输入,本地 sign 与浏览器一致
  • 同步请求后,服务端不报签名错误
  • 时间戳窗口内请求可通过
  • 参数顺序、编码、空值处理都一致

验证清单

你可以按这个清单逐项确认:

  • 参数名完全一致
  • 参数值类型一致(字符串/数字)
  • 排序规则一致
  • 拼接分隔符一致
  • URL 编码时机一致
  • 哈希算法一致
  • 输出格式一致(hex/base64/大写/小写)
  • 时间戳位数一致(秒/毫秒)
  • nonce 规则一致
  • 请求头中是否还有额外参与签名字段

这份清单看着普通,但真能帮你省很多时间。我自己排查签名失败时,最常见就是编码时机字段类型出了问题。


常见坑与排查

1. 看起来是 MD5,其实参与签名的字符串不对

最常见的误判是:

  • 算法找对了
  • 结果却对不上

这通常不是算法错,而是原始字符串错了。重点排查:

  • 有没有漏字段
  • 排序规则是否一致
  • 是否拼了额外盐值
  • 参数值是否做过 encodeURIComponent
  • 是否把 sign 自己也放进了签名串

排查建议

直接打印浏览器里摘要函数的输入值,再和本地拼接结果逐字符比对。


2. 时间戳单位错了

常见有三种:

  • 秒:1710000000
  • 毫秒:1710000000000
  • 自定义格式:如 "1710000000.123"

如果你发现除了 timestamp 以外都没问题,优先检查这个。


3. 参数顺序不是“对象遍历顺序”,而是“显式排序”

很多代码表面上看像这样:

JSON.stringify(params)

但真正参与签名的,可能是另外一个经过排序的新对象。
不要被表象迷惑,一定要看最终进入摘要函数的字符串


4. 请求体和签名串不是同一份数据

这也是一个很隐蔽的坑。

比如:

  • 请求体是 JSON
  • 但签名用的是 query string 形式
  • 或者某些字段只参与签名,不参与提交
  • 或者请求头里的 x-token 也参与签名

典型现象

  • 你用抓包里的 body 复现,始终失败
  • 浏览器发出去能成功,本地却总是“签名错误”

排查方法

发送前断点看最终 payload 和 sign 输入,别只看 Network 面板表面信息。


5. 混淆后函数名完全不可读

例如你会看到这种代码:

a = b(c(d(e(f)))))

别硬啃。中级开发者最该学会的是动态调试优先

  • 下断点
  • hook 函数
  • 看调用栈
  • 看入参和返回值

真实项目里,静态阅读常常只是辅助手段。


6. 摘要函数被二次封装

有些项目不会直接写 CryptoJS.MD5(str),而是这样:

function x(input) {
  return y(z(input));
}

这时你可以:

  • 全局搜索 createHash
  • digest
  • hex
  • toString()
  • WordArray

如果是 webpack 打包项目,还可以在格式化后看模块导出关系。


一张排查状态图

当你遇到“签名总是不对”时,可以按这个状态流走:

stateDiagram-v2
    [*] --> 抓到目标请求
    抓到目标请求 --> 找到发起点
    找到发起点 --> 观察发送前参数
    观察发送前参数 --> 定位签名函数
    定位签名函数 --> 记录摘要输入
    记录摘要输入 --> 本地复现
    本地复现 --> 结果一致: 成功
    本地复现 --> 结果不一致: 检查排序编码时间戳
    检查排序编码时间戳 --> 记录摘要输入

安全/性能最佳实践

这部分不只是“写给防守方”,对逆向分析者也很重要,因为你能从这里判断系统可能采用了哪些策略。

1. 不要把“前端签名”当成真正密钥保护

如果签名逻辑和密钥都下发到前端,那么它更多是:

  • 提高调用门槛
  • 配合风控做校验
  • 过滤低质量滥用

而不是绝对安全边界。

可执行建议

服务端应该:

  • 校验签名
  • 校验时间窗口
  • 校验 nonce 去重
  • 绑定会话或设备信息
  • 对异常频率做限流

2. 尽量避免重型加密逻辑阻塞主线程

前端如果每次请求都做复杂加密,尤其是大对象签名,会影响交互流畅度。

建议

  • 签名输入尽量简化
  • 避免无意义字段参与签名
  • 必要时用 Web Worker
  • 摘要而非对大体积数据做重复序列化

3. 参数规范必须明确

一个签名方案如果没有统一规范,后续维护会很痛苦。
比如到底是:

  • 空值参与还是不参与?
  • null 转空串还是字符串 "null"
  • 数组怎么拼?
  • 对象嵌套如何展开?

建议

把以下内容写成文档:

  • 字段排序规则
  • 编码规则
  • 时间戳单位
  • 输出格式
  • 版本号策略

这不只是为了开发方便,也是为了避免“前后端各自理解不同”。


4. 为签名方案留版本号

这条是我非常建议的。真实业务里签名算法总会升级,如果没有版本字段,你会在兼容老客户端时非常被动。

例如:

{
  "page": 1,
  "keyword": "phone",
  "timestamp": 1710000000,
  "nonce": "ab12cd34",
  "signVersion": "v2",
  "sign": "xxxx"
}

这样后端可以按版本选择校验逻辑。


实战经验:一个更稳的工作流

如果让我带一个中级开发者从零做,我会建议你用这套流程:

第一步:先抓请求,不碰源码

先确认:

  • 哪个请求是目标请求
  • 哪些参数是动态的
  • 是否存在签名失败提示

第二步:在发送前拦截

优先 hook:

  • fetch
  • XMLHttpRequest.send
  • axios 拦截器

目的不是马上理解所有代码,而是先拿到:

  • 最终请求体
  • 最终请求头
  • 发送前调用栈

第三步:盯摘要函数输入

只要你能看到:

  • MD5 / SHA 的输入串
  • 输入串和输出 sign 的对应关系

基本就进入收尾阶段了。

第四步:本地最小复现

不要一上来写整套自动化脚本。先写一个最小版:

  • 固定参数
  • 固定 timestamp
  • 固定 nonce
  • 算出和浏览器一致的 sign

第五步:再接入真实请求

只有当“同输入同输出”完全一致时,才去发接口。
否则你只是在把错误流程自动化。


总结

前端签名逆向这件事,最关键的不是“会不会某种加密算法”,而是有没有一套稳定的方法论。

你可以记住这条主线:

  1. 先锁定目标请求
  2. 再找到请求发起点
  3. 在发送前拦截参数
  4. 盯住摘要函数输入
  5. 本地最小化复现
  6. 逐项校验排序、编码、时间戳与输出格式

如果你现在就要开始实战,我建议按下面这个最小行动方案来:

  • 打开 Network,找到目标请求
  • 查看 Initiator,定位请求入口
  • hook fetchXHR.send
  • hook CryptoJS.MD5 或摘要函数
  • 记录签名输入串
  • 用 Node.js 复写一份同样的签名逻辑
  • 固定参数做逐步比对

最后提醒一个边界条件:

本文讲的是分析与调试方法,适用于你有权测试、学习或维护的系统。
对未授权目标进行绕过、批量滥用或攻击,不属于正常工程实践。

如果你把“猜算法”的习惯换成“抓发送前现场”,Web 逆向的成功率会高很多。这个转变,往往就是从初学到中级最关键的一步。


分享到:

上一篇
《Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统-345》
下一篇
《分布式架构中基于消息队列的最终一致性实现:订单与库存场景的设计与避坑》