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

《Web逆向实战:中级开发者如何定位并复现前端请求签名算法》

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

Web逆向实战:中级开发者如何定位并复现前端请求签名算法

很多中级开发者第一次接触 Web 逆向时,最容易卡住的不是“代码看不懂”,而是“知道它在签名,但不知道从哪一层下手”。页面里混着打包代码、混淆函数、动态参数、时间戳、环境检测,抓到请求之后看着 signtokenvt 这些字段,脑子里只有一个问题:它到底怎么算出来的?

这篇文章我不讲“神秘技巧”,而是按一套更稳定的实战路径,带你做一遍:

  1. 先定位签名生成点
  2. 再判断签名依赖哪些输入
  3. 最后在本地复现可运行版本

文章面向中级开发者,默认你会用浏览器开发者工具,会看一点 JavaScript,也知道基础哈希算法是什么。

说明:本文讨论的是学习前端安全机制、接口调试、自动化测试兼容性分析等合法场景。请在授权范围内操作,避免触碰业务和法律边界。


背景与问题

前端请求签名的目标,本质上是让服务端判断:这个请求是不是按预期客户端生成的。常见用途包括:

  • 防止接口被随意构造
  • 增加爬虫和脚本调用门槛
  • 校验参数是否被篡改
  • 配合时间戳、nonce 避免重放

一个典型请求可能长这样:

POST /api/order/list
Content-Type: application/json

{
  "page": 1,
  "pageSize": 20,
  "timestamp": 1719999999999,
  "nonce": "ab12cd34",
  "sign": "7b2d2d8e6d7f..."
}

问题在于,sign 通常不是单纯 md5(body) 这么简单,它可能结合:

  • 请求路径
  • 请求方法
  • 请求体排序结果
  • 时间戳
  • nonce
  • 固定盐值
  • 用户 token
  • 浏览器环境指纹

而且这些逻辑往往分散在多个文件里,甚至经过 webpack 打包、代码压缩、变量混淆。
所以真正的难点不是“会不会写 MD5”,而是:

  • 去哪找签名入口?
  • 怎么确认参与签名的原始字符串?
  • 怎样在脱离浏览器页面后复现?

前置知识

在开始之前,建议你至少熟悉以下内容:

  • Chrome DevTools 的 NetworkSourcesConsole
  • JavaScript 基础语法
  • 常见哈希算法:MD5 / SHA1 / SHA256 / HMAC
  • JSON 序列化与对象排序
  • Node.js 基本运行方式

如果你对混淆代码有点陌生,也没关系。实战里更多依赖的是定位能力,不是一次性读懂全部源码。


环境准备

推荐准备下面这些工具:

  • Chrome 浏览器
  • Node.js 18+
  • VS Code
  • 一个抓包/调试环境
  • 可选:mitmproxyFiddlerCharles

本地新建一个目录:

mkdir sign-reverse-demo
cd sign-reverse-demo
npm init -y
npm install crypto-js axios

核心原理

前端请求签名,通常可以抽象成这样一条流水线:

flowchart LR
A[收集输入参数] --> B[参数标准化]
B --> C[按规则拼接原文]
C --> D[哈希/加密处理]
D --> E[附加到请求中]
E --> F[服务端校验]

这里最关键的是中间三步:

  1. 参数标准化
    比如 key 排序、去除空值、统一布尔值和数字格式

  2. 原文拼接
    比如 method + path + timestamp + body + salt

  3. 哈希/加密处理
    比如 md5(raw)sha256(raw)hmac_sha256(raw, secret)

很多人逆向失败,不是因为算法太难,而是因为拼接前的标准化规则没还原对

常见签名输入模型

下面是实战中最常见的一类:

sign = md5(
  method.toUpperCase()
  + "|" + path
  + "|" + timestamp
  + "|" + nonce
  + "|" + canonical_json(body)
  + "|" + secret
)

还有一类是 HMAC:

sign = hmac_sha256(canonical_query + "\n" + canonical_body, secret)

还有更复杂的版本,会引入:

  • token 派生密钥
  • 浏览器指纹参与签名
  • 服务端下发动态盐值
  • wasm 实现哈希过程
  • native bridge / worker 内计算

一张图看懂定位思路

我平时更推荐“从请求反推代码”,而不是“从源码盲猜请求”。

sequenceDiagram
participant U as 用户操作
participant B as 浏览器页面
participant S as 签名函数
participant N as 网络请求
participant API as 服务端

U->>B: 点击按钮/触发接口
B->>S: 组装参数并计算 sign
S-->>B: 返回 sign
B->>N: 发送带 sign 的请求
N->>API: 请求到达服务端
API-->>N: 校验 sign 并返回结果

这张图的启发很直接:
签名一定发生在“发送请求之前”。所以你不需要先理解整个项目,只需要先抓到“请求发送前的那一段”。


背景与问题:到底从哪开始找?

真实项目里,我一般按这个顺序排查:

1. 先在 Network 面板看请求

重点关注:

  • 请求 URL
  • Query 参数
  • Request Payload
  • Headers
  • 发起时机

你要先确认:签名字段到底在哪?

可能在:

  • Query:?sign=xxx&t=xxx
  • Body:{"sign":"xxx"}
  • Header:X-Sign: xxx

2. 用全局搜索找字段名

Sources 里全局搜这些关键词:

  • sign
  • signature
  • token
  • nonce
  • timestamp
  • sha256
  • md5
  • CryptoJS
  • sort
  • stringify

如果字段名被混淆了,也不要慌。可以换思路搜:

  • 请求 URL 片段,比如 /api/order/list
  • axios/fetch 调用点
  • 请求拦截器,如 axios.interceptors.request.use

3. 卡在发送前打断点

这是实战里最有效的一步。

如果项目用 axios,可以先在请求拦截器附近打断点;
如果用 fetch/XHR,可以在 DevTools 的 XHR/fetch Breakpoints 里对 URL 关键字断住。

断住之后看三件事:

  • 当前请求对象里有哪些字段
  • sign 是现成的还是刚算出来的
  • 调用栈里谁生成了它

实战案例:复现一个常见签名算法

下面我们模拟一个很典型的场景。假设页面请求逻辑如下:

  • 请求方法:POST
  • 路径:/api/order/list
  • 请求体:业务 JSON
  • 时间戳:毫秒级
  • nonce:随机字符串
  • 签名算法:对规范化后的字符串做 MD5

签名规则

raw = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + CANONICAL_BODY + "\n" + SECRET
sign = md5(raw)

其中 CANONICAL_BODY 规则为:

  • 对象 key 按字典序排序
  • 递归处理嵌套对象
  • 数组保持原顺序
  • 去掉 undefined
  • 最终输出 JSON 字符串

逐步验证清单

建议你每次逆向都按这个清单来,不容易漏:

  • 确认 sign 在 query、body 还是 header
  • 确认算法是 hash 还是加密
  • 确认是否有时间戳和 nonce
  • 确认 body 是否排序
  • 确认 path 是否参与签名
  • 确认 method 是否大写
  • 确认是否用了固定盐值或动态密钥
  • 确认 JSON.stringify 前是否做了预处理
  • 确认浏览器环境值是否参与
  • 用同一组输入在页面和本地比对结果

实战代码(可运行)

下面给出一个可直接运行的 Node.js 示例。它做两件事:

  1. 生成与前端一致的签名
  2. 用 axios 发起请求

1)签名复现代码

新建 sign.js

const CryptoJS = require('crypto-js');

function sortObject(value) {
  if (Array.isArray(value)) {
    return value.map(sortObject);
  }

  if (value && typeof value === 'object') {
    const sorted = {};
    Object.keys(value)
      .filter((key) => value[key] !== undefined)
      .sort()
      .forEach((key) => {
        sorted[key] = sortObject(value[key]);
      });
    return sorted;
  }

  return value;
}

function canonicalize(obj) {
  return JSON.stringify(sortObject(obj));
}

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

function generateNonce(length = 8) {
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += chars[Math.floor(Math.random() * chars.length)];
  }
  return result;
}

function generateSign({ method, path, timestamp, nonce, body, secret }) {
  const canonicalBody = canonicalize(body);
  const raw = [
    method.toUpperCase(),
    path,
    String(timestamp),
    nonce,
    canonicalBody,
    secret,
  ].join('\n');

  return {
    raw,
    sign: md5(raw),
  };
}

module.exports = {
  canonicalize,
  generateNonce,
  generateSign,
};

2)请求调用代码

新建 request.js

const axios = require('axios');
const { generateNonce, generateSign } = require('./sign');

async function main() {
  const method = 'POST';
  const path = '/api/order/list';
  const timestamp = Date.now();
  const nonce = generateNonce(8);
  const secret = 'demo_secret_2026';

  const body = {
    page: 1,
    pageSize: 20,
    filters: {
      status: 'paid',
      keyword: 'book',
    },
    items: [3, 2, 1],
  };

  const { raw, sign } = generateSign({
    method,
    path,
    timestamp,
    nonce,
    body,
    secret,
  });

  console.log('签名原文:\n', raw);
  console.log('sign:', sign);

  try {
    const response = await axios({
      url: `https://example.com${path}`,
      method,
      data: {
        ...body,
        timestamp,
        nonce,
        sign,
      },
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    console.log('响应状态:', response.status);
    console.log('响应数据:', response.data);
  } catch (error) {
    if (error.response) {
      console.error('请求失败:', error.response.status, error.response.data);
    } else {
      console.error('请求异常:', error.message);
    }
  }
}

main();

运行:

node request.js

如何在真实页面里定位这个算法

上面的代码只是“复现结果”,但真正关键的是:你怎么找到这些规则的?

下面给你一个更接近真实工作的定位路径。

方法一:从 axios/fetch 入口逆推

如果页面用了 axios,请优先看这几个点:

axios.interceptors.request.use((config) => {
  // 很多签名逻辑在这里
  return config;
});

你要观察:

  • config.url
  • config.method
  • config.params
  • config.data
  • config.headers

然后找有没有类似:

config.headers['X-Sign'] = makeSign(config);

或者:

config.data.sign = signPayload(config.data);

方法二:直接 Hook 哈希函数

如果你怀疑用了 CryptoJS.MD5CryptoJS.SHA256,可以在控制台临时 hook。

Hook MD5

(function () {
  if (!window.CryptoJS || !CryptoJS.MD5) {
    console.log('CryptoJS.MD5 不存在');
    return;
  }

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

  console.log('MD5 hook 已安装');
})();

这样做的好处是:
你不一定要先读懂所有业务代码,只要页面一旦签名,就会把签名前原文暴露出来。

我自己踩过一个坑:一开始盯着混淆函数反复看,结果看了半小时都没看明白。后来直接 hook SHA256,一分钟就抓到了原文拼接格式。

方法三:Hook JSON.stringify

有些项目在 JSON.stringify 前会先做排序,所以你看到的 body 可能和最终签名原文不一样。
这时可以 hook 一下:

(function () {
  const original = JSON.stringify;
  JSON.stringify = function (...args) {
    const result = original.apply(this, args);
    console.log('[HOOK stringify] input:', args[0]);
    console.log('[HOOK stringify] output:', result);
    return result;
  };
  console.log('JSON.stringify hook 已安装');
})();

方法四:查看调用栈而不是死盯变量名

现在很多项目变量名都被压缩成 a, b, c
这时不要执着于“函数名是什么”,而要看:

  • 调用栈
  • 哪一层接收了请求参数
  • 哪一层把 sign 塞回请求对象

复杂场景下的判断分支

并不是所有签名都能“一眼看出来”。下面是一个判断图,帮你快速缩小范围。

flowchart TD
A[发现请求含 sign] --> B{能搜到 sign 关键词吗}
B -->|能| C[定位赋值位置]
B -->|不能| D[从请求拦截器/XHR断点入手]

C --> E{调用了 CryptoJS / SubtleCrypto / wasm 吗}
D --> E

E -->|CryptoJS| F[Hook MD5/SHA/HMAC]
E -->|SubtleCrypto| G[Hook crypto.subtle.digest/sign]
E -->|wasm| H[关注 wasm 导出函数与内存读写]
E -->|都没有| I[检查自定义算法与字符变换]

F --> J[提取原文与参数规则]
G --> J
H --> J
I --> J

J --> K[本地最小复现]
K --> L[与页面结果逐项比对]

常见坑与排查

这一部分非常重要。很多“算法复现失败”,最后都不是算法本身错了,而是细节没对齐。

1. JSON 排序规则不一致

最常见问题之一。

比如页面里实际处理的是:

  • 先删除空字段
  • 再递归排序
  • 再 stringify

而你本地只是直接 JSON.stringify(body),结果当然不一样。

排查建议:

  • 打印页面签名前原文
  • 打印本地签名前原文
  • 一行一行对比,不要只比最终 sign

2. 时间戳单位错了

可能是:

  • 秒:1719999999
  • 毫秒:1719999999999

只差三个零,签名就全错。

3. 路径参与签名,但你用了完整 URL

有些页面签的是:

/api/order/list

不是:

https://example.com/api/order/list

这一点很容易漏。

4. 请求体字段顺序变了

有些后端会严格按前端排序规则验签。
如果你在本地重新构造对象时字段顺序不同,虽然 JSON 语义一样,但签名结果会不同。

5. HMAC 和普通哈希搞混

这两者很像,但完全不是一回事。

错误示例:

sha256(raw + secret)

正确示例可能是:

hmac_sha256(raw, secret)

6. 编码问题

常见差异包括:

  • UTF-8
  • Base64
  • Hex
  • URL 编码前后顺序
  • Unicode 转义

尤其是中文、空格、特殊字符,很容易把结果搞偏。

7. 动态盐值没拿到

有些 secret 不是写死在前端,而是:

  • 登录后服务端下发
  • 页面初始化接口返回
  • 保存在 localStorage / sessionStorage / cookie
  • 由 token 二次派生

这时你如果只看静态 JS 文件,会误判“算法不完整”。

8. 签名算法在 WebAssembly 里

如果你看到页面加载了 .wasm,要留意:

  • wasm 导出函数名
  • JS 如何把参数写入内存
  • 返回值是 hex、base64 还是二进制

这时不要一上来就反编译 wasm,先看 JS 包装层,很多关键信息其实都在那一层。


一个简单的排查脚本:对比原文

当你本地算不对时,最有效的是写个“差异对比脚本”。

新建 diff-raw.js

function diffStrings(a, b) {
  const maxLen = Math.max(a.length, b.length);
  for (let i = 0; i < maxLen; i++) {
    if (a[i] !== b[i]) {
      console.log('首次差异位置:', i);
      console.log('a 字符:', JSON.stringify(a[i]));
      console.log('b 字符:', JSON.stringify(b[i]));
      console.log('a 前后片段:', JSON.stringify(a.slice(Math.max(0, i - 20), i + 20)));
      console.log('b 前后片段:', JSON.stringify(b.slice(Math.max(0, i - 20), i + 20)));
      return;
    }
  }
  console.log('两个字符串完全一致');
}

const pageRaw = `POST
/api/order/list
1719999999999
ab12cd34
{"filters":{"keyword":"book","status":"paid"},"items":[3,2,1],"page":1,"pageSize":20}
demo_secret_2026`;

const localRaw = `POST
/api/order/list
1719999999999
ab12cd34
{"page":1,"pageSize":20,"filters":{"status":"paid","keyword":"book"},"items":[3,2,1]}
demo_secret_2026`;

diffStrings(pageRaw, localRaw);

运行:

node diff-raw.js

这个小工具很朴素,但在实战里真的很好用。
很多时候你不是“算法不会”,而是“原文只差一个字符”。


安全/性能最佳实践

这里分两部分说:一部分给逆向分析者,一部分给前端/后端开发者。

站在分析与调试角度

1. 先复现最小闭环,不要一开始追求完整工程化

建议先做到:

  • 固定输入
  • 固定时间戳
  • 固定 nonce
  • 算出和页面一致的 sign

只要闭环跑通,再去封装自动化脚本。
我见过不少人一上来就写整套并发采集程序,结果基础签名都还没对齐,浪费很多时间。

2. 记录“原文”和“结果”两层日志

最少保留:

  • 原始参与签名字符串
  • 最终 sign
  • 时间戳
  • nonce
  • 请求路径

这样一旦失败,能快速回放。

3. 把环境依赖剥离出来

比如:

  • localStorage 中的 token
  • cookie 中的 session
  • 页面初始化接口返回的动态密钥

最好显式注入,而不是写死在代码里。


站在系统防护角度

如果你是业务开发者,也要知道:前端签名只能提高门槛,不能单独作为安全边界。

1. 不要把“静态前端密钥”当成真正秘密

只要密钥在前端可达,理论上就能被提取。
所以更合理的做法是:

  • 使用短期动态密钥
  • 与用户会话、设备信息、时间窗口绑定
  • 服务端做频控、风控、行为校验

2. 服务端校验要覆盖完整上下文

不要只验一个 sign 字段,还要校验:

  • 时间窗口
  • nonce 是否重复
  • token 是否有效
  • 参数是否越权
  • 来源行为是否异常

3. 性能上避免过重签名流程

如果每个请求都做特别重的前端加密,可能导致:

  • 首屏卡顿
  • 移动端耗电
  • 低端设备掉帧

比较稳妥的实践是:

  • 高频接口用轻量签名
  • 敏感接口再叠加强校验
  • 复杂算法放服务端,不把全部逻辑前移

一个更贴近真实项目的建议流程

如果你要在工作里稳定处理这类问题,我建议固定成下面这套 SOP:

第一步:抓包确认请求形态

记录:

  • URL
  • Method
  • Headers
  • Query
  • Body
  • 签名字段位置

第二步:找发送入口

优先找:

  • axios request interceptor
  • fetch 包装函数
  • XHR send 前逻辑

第三步:锁定签名前原文

通过:

  • hook hash 函数
  • 断点
  • console 注入
  • 调用栈追踪

第四步:最小复现

先不要管登录态、复杂代理、批量调度。
只要能在 Node 里把 sign 算出来,就已经成功了 80%。

第五步:联调验证

逐项核对:

  • 时间戳
  • nonce
  • body 排序
  • 编码
  • 路径
  • method
  • secret 来源

总结

前端请求签名逆向,难点从来不只是“某个加密算法”,而是算法上下文的完整还原
你真正需要掌握的是一套稳定的定位方法:

  1. 先看请求里签名出现在哪
  2. 再从请求发送前的代码断住
  3. 优先抓“签名前原文”而不是直接猜算法
  4. 本地先做最小复现,再逐项补环境依赖
  5. 一旦失败,先比原文,不要只比最终 sign

如果你已经是中级开发者,这件事的门槛其实不在数学,而在耐心和路径。
别一上来就被混淆代码吓住——多数场景里,真正有价值的信息就藏在请求发出前那几步。

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

  • 第一次做时,优先挑 axios + CryptoJS 的目标练手
  • 任何复现都先固定 timestampnonce
  • 一定要保存“页面原文”和“本地原文”
  • 如果静态分析很痛苦,优先 hook 哈希函数
  • 遇到 wasm 或动态密钥时,不要急着全盘反编译,先看 JS 包装层

只要你能稳定把“原文拼接规则”抓出来,后面的算法复现通常就只是时间问题。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》