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

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

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

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

很多中级开发者做接口自动化时,最容易卡住的不是抓包,而是抓到了请求却复现不出来。参数、Header、Cookie 看起来都齐了,结果一调用就是:

  • 401 Unauthorized
  • 403 Forbidden
  • sign invalid
  • timestamp expired
  • illegal request

这类问题,本质上往往不是“接口不能调”,而是前端在发请求之前做了一层签名计算,你漏掉了。

这篇文章我想用“实战拆解”的方式,带你完整走一遍:
如何定位前端签名算法、理解它的组成、在 Node.js 中复现,并最终用于接口自动化调用。

先提醒一句:本文仅用于合法授权的安全测试、接口联调、自动化测试与学习研究,不适用于绕过权限、攻击或破坏他人系统。


背景与问题

现代 Web 应用里,前端签名非常常见。它通常出现在以下场景:

  • App/H5 请求业务接口前,对参数做 sign
  • 请求头里有 x-signx-tokenx-auth 等动态字段
  • 请求中带 timestampnoncetraceId
  • 参数可能被排序、拼接、加密、哈希
  • 部分系统还会叠加设备指纹、环境检测、混淆代码

中级开发者经常遇到的典型误区有几个:

  1. 以为抓到请求就等于能复现
  2. 只盯着 Network,不去看 Initiator 和源码调用栈
  3. 看到混淆代码就放弃
  4. 直接复制浏览器里的 sign 值,忽略 sign 是动态生成的
  5. 没有拆分签名输入项,导致排查无从下手

如果你的目标是“自动化调用接口”,那关键不是拿到一次可用请求,而是建立一个稳定可复现的签名生成过程


前置知识与环境准备

建议你先具备这些基础:

  • 会用 Chrome DevTools
  • 了解基本 HTTP 请求结构
  • 能读懂中等复杂度 JavaScript
  • 知道 md5 / sha1 / sha256 / hmac 这类哈希概念
  • 能用 Node.js 写脚本

本文示例环境:

  • Chrome / Edge 最新版
  • Node.js 18+
  • Python 3.10+(可选,用于自动化调用)
  • 抓包工具可选:Charles / Fiddler / mitmproxy
  • JS 格式化工具可选:Prettier、在线 AST 可视化工具

核心原理

前端签名算法,大多数都逃不开下面这几个组成部分:

  1. 原始参数

    • query 参数
    • body 参数
    • 固定 appKey / version
    • 用户态信息(token / uid)
  2. 动态参数

    • 时间戳 timestamp
    • 随机串 nonce
    • 请求路径 path
    • 设备信息 / UA / referer
  3. 规范化处理

    • 按 key 排序
    • 过滤空值
    • URL 编码
    • JSON 序列化
    • 拼接成固定字符串
  4. 摘要或加密

    • md5(str)
    • sha256(str)
    • hmacSHA256(str, secret)
    • AES/RSA 后再编码
  5. 输出格式

    • 小写十六进制
    • 大写十六进制
    • Base64
    • 再次 URL encode

一个很常见的签名过程可以抽象成:

sign = hash(sort(params) + timestamp + nonce + secret)

但真正难点在于:
你得先知道它到底排了什么、拼了什么、用了什么 secret、在哪一层做的 hash。


先建立排查思路:别上来就啃混淆代码

我平时做这类问题,会先按这个顺序来:

flowchart TD
    A[打开页面并抓到目标请求] --> B[确认失败接口的动态字段]
    B --> C[在 Network 中查看 Header/Query/Body]
    C --> D[定位 sign timestamp nonce 等字段]
    D --> E[查看 Initiator 或 Sources 全局搜索字段名]
    E --> F[找到请求封装层 axios/fetch/XHR]
    F --> G[向上追踪签名函数调用]
    G --> H[还原签名输入和算法]
    H --> I[在 Node.js 中独立复现]
    I --> J[自动化脚本验证]

这个顺序的价值在于:

  • 先从“结果”看有哪些动态字段
  • 再从“发起位置”找谁生成了这些字段
  • 最后才分析混淆逻辑

很多时候,签名逻辑并不在业务页面里,而在:

  • axios 请求拦截器
  • 公共 SDK
  • webpack 打包后的工具模块
  • 动态加载 chunk
  • wasm 或第三方风控脚本

实战场景设定

为了让流程清晰,我们假设有这样一个请求:

POST /api/order/list
Content-Type: application/json
x-sign: 9f3f...
x-timestamp: 1711111111111
x-nonce: 6ab2c1d8

{"page":1,"pageSize":20,"status":"paid"}

抓包后你发现:

  • 直接重放请求会失败
  • x-sign 每次都变
  • x-timestamp 过期后就失效
  • body 里参数稍微变动,sign 也跟着变

这时基本就能判断:
签名至少和 body + timestamp + nonce 有关。


第一步:在浏览器里定位签名生成位置

1. 从 Network 面板倒查

在 Chrome DevTools 的 Network 中点开目标请求,优先看:

  • Request Headers:有没有 x-sign
  • Payload:body 是否参与签名
  • Initiator:是谁发起的请求

如果 Initiator 能直接跳源码,这是最快的入口。

2. 全局搜索关键字段

Sources 里全局搜索:

  • x-sign
  • sign
  • timestamp
  • nonce
  • 接口路径 /api/order/list

常见情况:

  • 字段名没混淆,函数名混淆了
  • 字段名也混淆了,但请求封装层还留有痕迹
  • sign 不是直接赋值,而是统一拦截器里计算

3. 优先找请求拦截器

例如常见写法:

axios.interceptors.request.use((config) => {
  const ts = Date.now().toString();
  const nonce = randomString(8);
  const sign = buildSign(config.url, config.data, ts, nonce);

  config.headers["x-timestamp"] = ts;
  config.headers["x-nonce"] = nonce;
  config.headers["x-sign"] = sign;
  return config;
});

如果你找到的是这种地方,恭喜,逆向难度直接下降一大截。


第二步:识别签名输入项

真正的难点不是“看到 buildSign”,而是确认它输入了什么。

一个典型函数可能长这样:

function buildSign(url, data, ts, nonce) {
  const payload = normalize(data);
  const str = `${url}|${payload}|${ts}|${nonce}|appSecret123`;
  return sha256(str);
}

但实际项目里会更绕一点,比如:

function z(a, b, c, d) {
  var s = p(a) + "&" + q(b) + "&" + c + "&" + d + "&" + m();
  return n(s).toUpperCase();
}

这时要做的不是“猜”,而是逐项验证

我的建议:把签名拆成四层来看

  1. 路径是否参与
  2. 参数是否排序
  3. 空值是否过滤
  4. secret 从哪来

可以画成一个更清晰的关系图:

sequenceDiagram
    participant Page as 页面逻辑
    participant Interceptor as 请求拦截器
    participant Sign as 签名函数
    participant Hash as 哈希算法
    participant API as 服务端接口

    Page->>Interceptor: 发起 /api/order/list 请求
    Interceptor->>Sign: 传入 url、body、timestamp、nonce
    Sign->>Sign: 参数排序/序列化/拼接 secret
    Sign->>Hash: 计算 sha256/md5/hmac
    Hash-->>Sign: 返回签名串
    Sign-->>Interceptor: x-sign
    Interceptor->>API: 携带签名后的请求
    API-->>Page: 返回业务数据

第三步:动态调试,而不是静态硬读

如果代码不太好读,我更推荐你直接打断点。

常用断点策略

1. 在请求发送点断住

对这些位置下断点:

  • fetch
  • XMLHttpRequest.prototype.send
  • axios 请求拦截器
  • 设置请求头的位置

2. 对可疑函数下断点

比如你已经找到:

headers["x-sign"] = z(url, data, ts, nonce)

那就直接在 z() 里断住,看:

  • 参数 a/b/c/d 分别是什么
  • 中间变量 s 长什么样
  • 最后调用的 n() 是 md5、sha256 还是 hmac

3. 用 Console 验证中间值

这一步特别重要。
我踩过的坑里,很多不是算法错,而是:

  • 我以为 body 是对象序列化,实际上是压缩后的 JSON 字符串
  • 我以为参数按字母排序,实际上保留原顺序
  • 我以为 hash 输出小写,实际上转成了大写

所以一定要把中间值打印出来。


第四步:识别常见签名实现模式

下面是我在项目里最常见到的几类。

模式一:排序 + 拼接 + MD5

function signByMd5(params, secret) {
  const keys = Object.keys(params).sort();
  const str = keys
    .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== "")
    .map((k) => `${k}=${params[k]}`)
    .join("&");

  return md5(`${str}&secret=${secret}`);
}

模式二:JSON 序列化 + 时间戳 + SHA256

function signBySha256(path, body, ts, secret) {
  const payload = JSON.stringify(body);
  return sha256(`${path}|${payload}|${ts}|${secret}`);
}

模式三:HMAC

function signByHmac(message, secret) {
  return hmacSHA256(message, secret);
}

模式四:先加密再摘要

function complexSign(data, aesKey, hashSecret) {
  const encrypted = aesEncrypt(JSON.stringify(data), aesKey);
  return sha256(encrypted + hashSecret);
}

如果你看到明显的 CryptoJSmd5sha256 字样,算是好消息。
如果看不到,也别慌,可以通过输出长度和格式来猜:

  • 32 位十六进制:多半是 MD5
  • 40 位:可能 SHA1
  • 64 位:多半 SHA256
  • Base64 串:可能是 HMAC 或加密结果

实战代码:从浏览器逻辑复现到 Node.js 自动化

下面给一个可运行示例
我们假设前端签名规则是:

  1. body 参数按 key 排序
  2. 过滤空值
  3. 拼接成 k=v&k2=v2
  4. 再拼上 path|timestamp|nonce|secret
  5. 取 SHA256 小写

1. Node.js 复现签名

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

function normalizeParams(obj) {
  return Object.keys(obj)
    .sort()
    .filter((key) => obj[key] !== undefined && obj[key] !== null && obj[key] !== "")
    .map((key) => `${key}=${formatValue(obj[key])}`)
    .join("&");
}

function formatValue(value) {
  if (typeof value === "object") {
    return JSON.stringify(value);
  }
  return String(value);
}

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

function randomNonce(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 buildSign({ path, body, timestamp, nonce, secret }) {
  const normalized = normalizeParams(body);
  const raw = `${path}|${normalized}|${timestamp}|${nonce}|${secret}`;
  const sign = sha256(raw);
  return { normalized, raw, sign };
}

module.exports = {
  buildSign,
  randomNonce,
};

2. 自动化调用接口

// request.js
const axios = require("axios");
const { buildSign, randomNonce } = require("./sign");

async function main() {
  const path = "/api/order/list";
  const body = {
    page: 1,
    pageSize: 20,
    status: "paid",
  };

  const timestamp = Date.now().toString();
  const nonce = randomNonce(8);
  const secret = "appSecret123";

  const { normalized, raw, sign } = buildSign({
    path,
    body,
    timestamp,
    nonce,
    secret,
  });

  console.log("规范化参数:", normalized);
  console.log("签名原文:", raw);
  console.log("签名结果:", sign);

  const resp = await axios.post(`https://example.com${path}`, body, {
    headers: {
      "content-type": "application/json",
      "x-timestamp": timestamp,
      "x-nonce": nonce,
      "x-sign": sign,
    },
    timeout: 10000,
  });

  console.log(resp.data);
}

main().catch((err) => {
  if (err.response) {
    console.error("状态码:", err.response.status);
    console.error("响应体:", err.response.data);
  } else {
    console.error(err.message);
  }
});

安装依赖:

npm install axios

运行:

node request.js

如果前端用的是 CryptoJS,如何对照复现

很多页面签名会直接写成这样:

const sign = CryptoJS.SHA256(raw).toString();

Node.js 对应写法通常是:

const crypto = require("crypto");
const sign = crypto.createHash("sha256").update(raw, "utf8").digest("hex");

如果是 HMAC:

前端:

const sign = CryptoJS.HmacSHA256(raw, secret).toString();

Node.js:

const crypto = require("crypto");
const sign = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");

这里一个特别容易错的点是:
CryptoJS 的 WordArray、编码方式、toString 输出格式,要和 Node 保持一致。


逐步验证清单

建议你不要一步到位,而是按下面清单逐项验证。

验证 1:时间戳是否一致

console.log(Date.now().toString());

如果服务端要求秒级,而你传了毫秒级,签名一定错。

验证 2:参数顺序是否一致

浏览器里:

console.log(Object.keys(body).sort());

Node 里也打印一遍,确保顺序一样。

验证 3:JSON 序列化结果是否一致

console.log(JSON.stringify(body));

看是否存在:

  • key 顺序不同
  • 空格不同
  • 布尔值/数字被转成字符串
  • 中文编码差异

验证 4:签名原文是否一致

这一步最关键。

浏览器调试时打印:

console.log(raw);

Node 里也打印:

console.log(raw);

如果 raw 完全一致,而签名结果不同,问题就只可能在:

  • 哈希算法不对
  • 编码不对
  • 输出格式不对

验证 5:输出格式是否一致

例如:

  • 浏览器输出大写,你 Node 输出小写
  • 浏览器输出 Base64,你 Node 输出 hex
  • 浏览器 hash 前做了 UTF-8 编码处理

常见坑与排查

这部分我尽量写得接地气一点,因为很多问题真不是“大原理”,就是小细节。

1. 误把“请求参数”当成“签名参数”

有些字段不会真正发给服务端,但会参与签名,比如:

  • 固定 appId
  • 内置版本号
  • 环境标记
  • secret 派生值

排查方式:
看签名函数入参,不要只看最终请求。


2. 时间戳单位错了

有些系统要:

  • 秒:Math.floor(Date.now() / 1000)
  • 毫秒:Date.now()
  • 字符串,不是数字

现象:

  • 签名看起来对,但服务端报过期
  • 同一个 sign 很快失效

3. 排序规则不是你想的那样

不是所有系统都用 Object.keys().sort()

有的规则是:

  • ASCII 排序
  • 忽略大小写排序
  • 只对 query 排序,不对 body 排序
  • 嵌套对象递归排序

可以把这个过程抽象成:

stateDiagram-v2
    [*] --> 收集参数
    收集参数 --> 过滤空值
    过滤空值 --> 排序
    排序 --> 序列化
    序列化 --> 拼接动态字段
    拼接动态字段 --> 哈希
    哈希 --> 输出编码
    输出编码 --> [*]

4. body 实际参与的是“字符串”,不是对象

前端可能这样做:

const payload = JSON.stringify(data);
const sign = sha256(path + payload + ts);
fetch(url, { body: payload });

如果你在 Node 里对对象直接排序拼接,当然对不上。


5. Header 参与了签名

有些系统把这些也算进去:

  • User-Agent
  • Origin
  • Referer
  • Authorization
  • x-device-id

这种场景下,如果你只复现 body 和 query,会始终失败。


6. 混淆后函数看不懂,就硬猜算法

不建议。
更稳的做法是:

  • 找最终设置 Header 的地方
  • 断点看输入输出
  • 识别 hash 长度和调用链
  • 必要时 hook 原生函数

7. Webpack 模块太多,搜不到函数定义

这是前端逆向里很常见的烦躁时刻。
你搜 x-sign 只有一处引用,真正实现藏在模块加载器里。

应对办法:

  • 从调用栈往上找模块 ID
  • 格式化打包文件
  • 观察 __webpack_require__ 依赖
  • 在运行时重写可疑函数做日志输出

进阶技巧:Hook 关键函数快速拿到原文

如果页面比较复杂,最省时间的方法往往不是硬读代码,而是直接 hook。

Hook fetch

// 在 DevTools Console 中执行
const rawFetch = window.fetch;
window.fetch = async function (...args) {
  console.log("fetch args:", args);
  return rawFetch.apply(this, args);
};

Hook XMLHttpRequest

(function () {
  const oldOpen = XMLHttpRequest.prototype.open;
  const oldSend = XMLHttpRequest.prototype.send;
  const oldSetHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._method = method;
    this._url = url;
    return oldOpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
    if (!this._headers) this._headers = {};
    this._headers[key] = value;
    return oldSetHeader.call(this, key, value);
  };

  XMLHttpRequest.prototype.send = function (body) {
    console.log("XHR URL:", this._url);
    console.log("XHR Method:", this._method);
    console.log("XHR Headers:", this._headers);
    console.log("XHR Body:", body);
    return oldSend.call(this, body);
  };
})();

Hook 哈希函数

如果页面使用 CryptoJS,可以尝试:

(function () {
  if (!window.CryptoJS || !CryptoJS.SHA256) return;

  const oldSha256 = CryptoJS.SHA256;
  CryptoJS.SHA256 = function (msg) {
    console.log("SHA256 input:", msg);
    const result = oldSha256.call(this, msg);
    console.log("SHA256 output:", result.toString());
    return result;
  };
})();

这类方法非常适合确认:

  • 签名原文到底是什么
  • 算法是哪个
  • 输出格式是什么

安全/性能最佳实践

这部分不只是“写给开发者”,也写给做自动化的人。

1. 不要把 secret 硬编码进自动化脚本仓库

如果你是内部联调或测试项目:

  • 把 secret 放环境变量
  • 区分测试环境和生产环境
  • 不要提交到 Git

示例:

const secret = process.env.API_SIGN_SECRET;
if (!secret) {
  throw new Error("缺少 API_SIGN_SECRET 环境变量");
}

2. 给签名逻辑做可观测性日志,但避免泄漏敏感信息

建议记录:

  • 请求路径
  • 时间戳
  • nonce
  • 签名前原文的摘要
  • 签名结果前几位

不要完整打印:

  • secret
  • 用户 token
  • 整体敏感 payload

3. 自动化调用要控制并发和重试

签名接口经常有风控限制:

  • 同一 nonce 不可重复
  • 时间窗口很短
  • 高频请求触发限流

建议:

  • 每次请求生成新 nonce
  • 时间戳实时生成
  • 失败后只做有限重试
  • 保持与真实客户端相近的 Header

4. 做好签名函数单元测试

把“逆向复现”沉淀成可回归验证的代码,而不是一次性脚本。

// sign.test.js
const { buildSign } = require("./sign");

const fixed = {
  path: "/api/order/list",
  body: { page: 1, pageSize: 20, status: "paid" },
  timestamp: "1711111111111",
  nonce: "6ab2c1d8",
  secret: "appSecret123",
};

const result = buildSign(fixed);
console.log(result);

只要前端版本一更新,你就能快速发现:

  • 排序规则变了
  • secret 派生变了
  • 拼接格式变了

5. 注意法律、授权与边界

这是必须强调的边界:

  • 只能对自己拥有、获授权、用于测试/联调的系统做分析
  • 不要绕过身份认证、计费、频控、访问控制
  • 不要将签名复现用于批量抓取、攻击、数据窃取等行为

技术上能做到,不代表业务上可以做。


一个更稳的落地方法:先“复现签名”,再“封装调用”

我很建议把代码拆成两层:

  1. 签名层

    • 纯函数
    • 输入固定,输出确定
    • 方便测试和比对
  2. 请求层

    • 负责发 HTTP
    • 注入 timestamp / nonce / sign
    • 负责重试、超时、日志

这样你后面维护起来会轻松很多。结构可以像这样:

classDiagram
    class SignBuilder {
      +normalizeParams(obj)
      +buildRaw(path, body, ts, nonce, secret)
      +sign(raw)
    }

    class ApiClient {
      +genTimestamp()
      +genNonce()
      +post(path, body)
    }

    ApiClient --> SignBuilder

这比把所有逻辑揉在一个脚本里强得多。
尤其当你后面要接多个接口时,收益会非常明显。


总结

前端签名逆向这件事,说复杂也复杂,说简单也简单。真正决定成败的,往往不是你会不会某个哈希算法,而是你有没有按正确路径拆问题。

你可以记住这条主线:

  1. 先抓到真实请求
  2. 定位 sign/timestamp/nonce 等动态字段
  3. 从 Initiator、拦截器、请求封装层倒查
  4. 动态断点确认签名原文
  5. 在 Node.js 中一比一复现
  6. 把复现结果沉淀成可测试、可复用的自动化模块

如果你现在就准备动手,我建议按这个最小闭环来做:

  • 先别急着自动化整套流程
  • 先固定一组参数
  • 打印浏览器中的签名原文和签名结果
  • 在 Node.js 中做到完全一致
  • 再接入实际 HTTP 调用

只要你能把“签名原文”对齐,后面 80% 的问题都会迎刃而解。

最后再强调一次边界:
本文方法仅适用于合法授权的测试、联调和安全研究。
在这个前提下,掌握前端签名定位与复现能力,会让你的接口自动化能力直接上一个台阶。


分享到:

下一篇
《大模型应用落地指南:从 RAG 架构设计到企业知识库问答系统实战》