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

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

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

背景与问题

做接口联调、自动化测试、数据采集,很多人都会遇到一个典型问题:接口参数明明都对,但服务端总返回签名错误

这类场景往往不是简单的“多传了一个字段”,而是前端在发请求前,做了额外处理,比如:

  • 对参数按特定顺序排序
  • 拼接时间戳、随机串、设备指纹
  • 做一层或多层编码
  • md5/sha256/hmac/aes/rsa 等方式生成签名
  • 把签名逻辑藏进 webpack 打包后的混淆代码里

中级工程师常见的卡点,不是“不懂加密算法”,而是:

  1. 找不到签名逻辑入口
  2. 找到后看不懂混淆代码
  3. 能看懂局部,但无法完整复现
  4. 本地跑通了,线上接口还是不认

这篇文章我会按“真实排查路径”带你走一遍:如何定位前端签名参数生成逻辑,并在本地稳定复现

先说边界:本文用于合法的接口调试、安全研究、自动化测试和自有系统分析,不讨论绕过鉴权、攻击他人系统等内容。


前置知识与环境准备

建议先准备这些工具:

  • 浏览器:Chrome
  • 抓包/调试:DevTools、Charles/Fiddler(二选一)
  • JS 运行环境:Node.js 14+
  • 代码格式化:Prettier、在线 JS Beautify
  • 调试辅助:mitmproxy 或本地代理(可选)

你至少要熟悉:

  • 浏览器 Network / Sources / Console 的基本使用
  • JS 基础:对象、数组、闭包、Promise
  • 常见摘要算法:MD5 / SHA 系列 / HMAC 的使用方式
  • Node.js 中如何执行浏览器抽出来的 JS

核心原理

前端签名逻辑,本质上通常是这几个阶段:

  1. 收集原始参数
  2. 标准化参数
    • 排序
    • 过滤空值
    • 序列化
  3. 拼接上下文信息
    • 时间戳
    • nonce
    • appKey
    • token
  4. 签名运算
    • hash / hmac / aes / rsa
  5. 写回请求
    • header
    • query
    • body

很多站点看起来“很复杂”,实际拆开后就两件事:

  • 参数如何被整理
  • 最终用什么算法算摘要

一个通用识别框架

flowchart TD
    A[抓到目标请求] --> B[观察请求中的 sign ts nonce]
    B --> C[全局搜索关键字段]
    C --> D[定位请求发起点]
    D --> E[向上追踪参数组装函数]
    E --> F[定位加密/摘要调用]
    F --> G[抽离最小可运行代码]
    G --> H[对照浏览器结果逐步验证]

常见签名模式

1. 排序后拼接再 MD5

例如:

a=1&b=2&ts=1626490800&key=secret

然后做:

md5(上面的字符串)

2. HMAC

例如:

HMAC-SHA256(payload, secret)

3. 先 JSON 序列化再加密

例如:

  • JSON.stringify(data)
  • AES.encrypt(...)
  • 再把密文作为 data

4. 混合方案

真实项目里很常见:

  • 业务参数 JSON
  • 时间戳 + nonce
  • 参与签名字符串排序
  • sha256(...)+自定义编码

一套实战定位方法

这里我不走“只讲概念”的路线,而是按照实际逆向顺序来。

第一步:先在 Network 里确认“谁是签名参数”

打开 Chrome DevTools,进入 Network,筛选目标接口。

重点看:

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

通常这些字段最值得怀疑:

  • sign
  • signature
  • token
  • ts
  • timestamp
  • nonce
  • auth
  • x-sign
  • x-token

如果你看到:

{
  "page": 1,
  "size": 20,
  "ts": 1720000000,
  "nonce": "8f3a2c",
  "sign": "e4d909c290d0fb1ca068ffaddf22cbd0"
}

那基本可以判断:sign 大概率由前面的字段和某个密钥共同生成。

第二步:全局搜索关键字,不要一上来就硬读混淆代码

在 Sources 里全局搜索:

  • 请求路径的一部分
  • sign
  • timestamp
  • nonce
  • 请求方法名,比如 axios.postfetch
  • header 名,比如 x-sign

很多人一开始就盯着几十万行压缩代码看,这很容易陷进去。我更推荐从请求发起点反推

第三步:给可疑位置打断点

常用断点位置:

  • XMLHttpRequest.prototype.send
  • window.fetch
  • axios 请求拦截器
  • 设置 header 的地方
  • 给 body 赋值的地方

你可以直接在 Console 里先挂钩,快速观察请求参数。

const rawFetch = window.fetch;
window.fetch = async function (...args) {
  console.log("fetch args =>", args);
  debugger;
  return rawFetch.apply(this, args);
};

如果页面走的是 XHR:

const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
  console.log("xhr body =>", body);
  debugger;
  return rawSend.call(this, body);
};

这一步的目的不是“改页面”,而是让请求发出前停住,看清楚签名是何时被塞进去的。


Mermaid:签名定位过程时序图

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant S as 签名函数
    participant R as 请求模块
    participant API as 服务端接口

    U->>P: 点击查询/翻页
    P->>P: 组装业务参数
    P->>S: 传入 data + ts + nonce
    S-->>P: 返回 sign
    P->>R: 写入 header/body/query
    R->>API: 发起请求
    API-->>R: 返回结果
    R-->>P: 渲染页面

实战演示:从页面逻辑中抽离签名函数

下面用一个可运行的简化案例演示完整过程。真实站点会更绕,但定位思路是一致的。

假设我们在浏览器里观察到请求体长这样:

{
  "page": 1,
  "size": 20,
  "keyword": "phone",
  "ts": 1720000000,
  "nonce": "abc123",
  "sign": "待生成"
}

经过调试,找到页面里有一段逻辑:

function buildSign(params, secret) {
  const keys = Object.keys(params)
    .filter(k => params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort();

  const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
  return md5(plain);
}

这个例子故意保持简单,因为我们重点是“如何抽离并复现”。


实战代码(可运行)

方案一:Node.js 中复现排序 + MD5 签名

新建 sign.js

const crypto = require("crypto");

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

function buildSign(params, secret) {
  const keys = Object.keys(params)
    .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort();

  const plain = keys.map((k) => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
  return md5(plain);
}

function buildPayload(data, secret) {
  const payload = {
    ...data,
    ts: 1720000000,
    nonce: "abc123",
  };

  payload.sign = buildSign(payload, secret);
  return payload;
}

const payload = buildPayload(
  {
    page: 1,
    size: 20,
    keyword: "phone",
  },
  "my_secret_key"
);

console.log("payload =>", payload);

运行:

node sign.js

方案二:直接发请求验证

新建 request.js

const crypto = require("crypto");
const https = require("https");

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

function buildSign(params, secret) {
  const keys = Object.keys(params)
    .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort();

  const plain = keys.map((k) => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
  return md5(plain);
}

function postJson(url, data) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const body = JSON.stringify(data);

    const req = https.request(
      {
        hostname: u.hostname,
        path: u.pathname + u.search,
        method: "POST",
        port: 443,
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(body),
        },
      },
      (res) => {
        let buf = "";
        res.on("data", (chunk) => (buf += chunk));
        res.on("end", () => resolve({ status: res.statusCode, body: buf }));
      }
    );

    req.on("error", reject);
    req.write(body);
    req.end();
  });
}

async function main() {
  const payload = {
    page: 1,
    size: 20,
    keyword: "phone",
    ts: 1720000000,
    nonce: "abc123",
  };

  payload.sign = buildSign(payload, "my_secret_key");

  const result = await postJson("https://example.com/api/search", payload);
  console.log(result);
}

main().catch(console.error);

如果目标站点还校验 Cookie、Header、Referer、设备指纹,这段代码还需要补全对应上下文,否则签名即使对,服务端也可能拒绝。


更贴近真实项目的抽离技巧

实际逆向时,签名函数通常不会这么清爽。更常见的是:

  • webpack 模块包装
  • 变量名被压缩成 a,b,c,d
  • 核心算法被拆到多个函数
  • 用了浏览器环境对象,如 window, document, navigator

这时建议按下面方式抽离。

1. 先找“最小依赖闭包”

不要把整份打包文件复制到 Node.js 里跑。优先提取:

  • 参数排序函数
  • 编码函数
  • hash 函数
  • 入口函数

例如浏览器里是:

function n(e) {
  return Object.keys(e).sort().map(function(t) {
    return t + "=" + e[t];
  }).join("&");
}

function r(e) {
  return window.btoa(e);
}

function s(e) {
  return md5(r(n(e)) + "secret");
}

抽离到 Node.js 时,如果只依赖 window.btoa,那就替换成 Buffer:

function btoaNode(text) {
  return Buffer.from(text, "utf8").toString("base64");
}

2. 先保证“中间结果一致”

很多人一上来就追求最终 sign 一致,结果调半天找不到差异。

更稳的做法是逐层打印:

  • 排序后的 key 列表
  • 拼接后的明文串
  • 编码结果
  • 最终摘要

例如:

const keys = Object.keys(params).sort();
console.log("keys =>", keys);

const plain = keys.map(k => `${k}=${params[k]}`).join("&");
console.log("plain =>", plain);

const encoded = Buffer.from(plain, "utf8").toString("base64");
console.log("encoded =>", encoded);

const sign = md5(encoded + "secret");
console.log("sign =>", sign);

只要某一步和浏览器不一致,问题就缩小了。


逐步验证清单

我自己做这类问题时,通常会按这个清单过一遍:

  • 请求方法、路径、参数位置是否一致
  • sign 参与计算的字段是否完整
  • 字段顺序是否一致
  • 空值、nullundefined 是否被过滤
  • 数字和字符串是否发生隐式转换
  • 时间戳单位是秒还是毫秒
  • nonce 是否每次重新生成
  • JSON 序列化是否稳定
  • URL 编码是否在签名前还是签名后
  • 大小写是否一致(尤其十六进制摘要)
  • 服务端是否还校验 Header/Cookie/UA/Referer

这个清单看起来普通,但非常实用。很多签名不一致,最后都死在这些细节上。


常见坑与排查

坑一:你复现的是“算法”,但漏了“上下文”

这是最常见的。

有些站点的签名并不只依赖业务参数,还依赖:

  • 登录态 Cookie
  • 本地存储 token
  • 用户代理
  • 页面路径
  • 指纹值
  • 某个初始化时下发的动态盐值

如果你只把 sign 算法抠出来,很可能得到“形式正确但服务端不认”的结果。

排查方法

在浏览器断点时,重点看签名函数入参是否只有业务对象。若不是,要把这些外部依赖一起记录下来。


坑二:时间戳单位搞错

常见三种:

  • 秒:1626490800
  • 毫秒:1626490800123
  • 微秒/字符串形式的变体

排查方法

观察浏览器真实请求值,不要猜。


坑三:参数顺序不一致

很多签名算法强依赖顺序,哪怕字段完全一样,只要顺序不同,结果就不同。

排查方法

确认是:

  • 按字典序排序
  • 按原始插入顺序
  • 按服务端约定顺序

坑四:对象序列化不稳定

如果签名字段里包含嵌套对象,比如:

{
  "filter": {
    "price": 100,
    "brand": "xx"
  }
}

那么你得确认前端是否用了:

  • JSON.stringify
  • 自定义排序后再 stringify
  • QueryString 风格拍平

排查方法

在浏览器里打印参与签名的最终明文串,而不是只看原始对象。


坑五:浏览器环境函数在 Node 中缺失

例如代码依赖:

  • window
  • document
  • navigator
  • atob / btoa
  • crypto.subtle

排查方法

优先做最小替换,不要急着上 jsdom。很多时候只需要补几个函数。


坑六:混淆后函数被动态调用

有些代码会写成:

var x = ["md5", "sha256"];
a[x[0]](...)

或者字符串被解码后再调用。

排查方法

在关键函数处打断点,结合 Call Stack 看真实调用链,比静态硬读快得多。


Mermaid:排查分支图

flowchart TD
    A[签名不通过] --> B{浏览器与本地参数一致吗}
    B -- 否 --> C[先对齐请求参数/顺序/编码]
    B -- 是 --> D{中间明文串一致吗}
    D -- 否 --> E[检查排序 过滤 序列化]
    D -- 是 --> F{摘要算法结果一致吗}
    F -- 否 --> G[检查编码算法 密钥 大小写]
    F -- 是 --> H[检查Cookie Header 指纹 时间窗口]

安全/性能最佳实践

这部分我想多说一点。因为很多同学学会“复现签名”后,容易只盯着跑通,忽略工程质量。

1. 把“定位逻辑”和“业务调用”分离

建议把代码拆成两层:

  • sign-core.js:只管签名算法
  • client.js:只管请求发送

这样后面算法变更时,你只需要改一个文件。

// sign-core.js
const crypto = require("crypto");

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

function buildSign(params, secret) {
  const keys = Object.keys(params).filter(k => params[k] !== "").sort();
  const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;
  return md5(plain);
}

module.exports = { buildSign };

2. 记录中间结果,便于回归验证

前端签名经常会改。建议保留调试日志开关:

function buildSign(params, secret, debug = false) {
  const keys = Object.keys(params).sort();
  const plain = keys.map(k => `${k}=${params[k]}`).join("&") + `&key=${secret}`;

  if (debug) {
    console.log("[debug] keys:", keys);
    console.log("[debug] plain:", plain);
  }

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

这样站点一升级,你能很快比较差异。

3. 不要把敏感密钥硬编码进仓库

如果你是在做自有系统联调,签名用到的密钥不要直接提交到 Git。

更好的方式:

  • 环境变量
  • 配置中心
  • 本地 .env 文件(不提交)

4. 控制请求频率

即使是合法测试,也要避免高频压测到线上服务。尤其签名逻辑里如果包含昂贵计算,多线程并发要评估 CPU 开销。

5. 优先使用官方或合规方式

如果目标系统有:

  • OpenAPI
  • SDK
  • 正式测试环境
  • 文档化签名规则

优先走官方方式。逆向应该是排障手段,不是第一选择。


一个更完整的本地复现模板

如果你已经确认逻辑是“排序 + 时间戳 + nonce + md5”,可以直接套这个模板改:

const crypto = require("crypto");

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

function normalizeParams(params) {
  return Object.keys(params)
    .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort()
    .reduce((acc, k) => {
      acc[k] = params[k];
      return acc;
    }, {});
}

function buildPlainText(params, secret) {
  const normalized = normalizeParams(params);
  const plain = Object.keys(normalized)
    .map((k) => `${k}=${normalized[k]}`)
    .join("&");
  return `${plain}&key=${secret}`;
}

function buildSign(params, secret) {
  const plain = buildPlainText(params, secret);
  return md5(plain);
}

function buildRequestData(data, options = {}) {
  const ts = options.ts || Math.floor(Date.now() / 1000);
  const nonce = options.nonce || Math.random().toString(16).slice(2, 10);
  const secret = options.secret || "my_secret_key";

  const payload = {
    ...data,
    ts,
    nonce,
  };

  const plain = buildPlainText(payload, secret);
  const sign = buildSign(payload, secret);

  return {
    payload: {
      ...payload,
      sign,
    },
    debug: {
      plain,
      sign,
    },
  };
}

// demo
const result = buildRequestData(
  { page: 1, size: 20, keyword: "phone" },
  { ts: 1720000000, nonce: "abc123", secret: "my_secret_key" }
);

console.log(JSON.stringify(result, null, 2));

这份模板的好处是:能同时输出 payload 和中间明文,非常适合对照浏览器结果。


实战建议:如何判断“该继续逆向”还是“该换思路”

中级工程师最需要的,不只是技术动作,还有判断力。

以下情况,继续逆向是合理的:

  • 你在做自有系统联调
  • 你在定位接口异常,且只有前端能跑通
  • 你在验证安全方案是否合理
  • 你已获得合法授权进行测试

以下情况,建议换成官方/合规方案:

  • 站点签名依赖频繁更新的动态脚本
  • 强绑定指纹、会话、风控设备信息
  • 签名之外还有行为校验
  • 已有开放接口或 SDK 可用

因为这时候,问题已经不是“把 sign 算出来”,而是整套风控上下文。继续硬抠,投入产出比会很差。


总结

复现前端签名参数生成逻辑,核心不是“会几种加密算法”,而是掌握一套稳定的定位路径:

  1. 先在 Network 里识别签名字段
  2. 从请求发起点反推,而不是死读混淆代码
  3. 通过断点和 hook 抓到签名生成时刻
  4. 抽离最小可运行代码到 Node.js
  5. 逐层比对中间结果,而不是只看最终 sign
  6. 别忽略 Cookie、Header、时间戳、nonce、指纹等上下文

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

签名逆向的关键,不在“算法有多复杂”,而在“你有没有把生成链路拆开验证”。

这是我自己踩坑之后总结出来最有效的方法。很多看起来很“玄学”的前端签名,拆到最后其实就几步:排序、拼接、编码、摘要。难的是定位,不是计算。

只要你把“请求入口 -> 参数组装 -> 签名函数 -> 中间结果验证”这条链跑通,大多数中等复杂度的前端签名问题,都能比较稳地复现。


分享到:

上一篇
《Spring Boot 中基于 Redis 与 AOP 实现接口幂等性的实战方案》
下一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑》