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

《Web逆向实战:中级开发者如何定位并复现前端签名算法与接口加密流程》

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

Web逆向实战:中级开发者如何定位并复现前端签名算法与接口加密流程

前端签名和接口加密,很多人第一反应是“这不就是加个 md5 吗”。但真到项目里,往往不是这么简单:参数排序、时间戳、随机数、环境校验、JS 混淆、甚至 WebAssembly 都可能混在一起。

这篇文章我想用一条中级开发者可落地的路径,带你从 0 到 1 做一遍:

  1. 抓包定位关键请求
  2. 找出前端签名生成逻辑
  3. 复现接口加密流程
  4. 写出可运行脚本完成验证

重点不是“背某个网站的算法”,而是形成一套稳定的方法论

说明:本文讨论的是合法授权场景下的接口分析、联调、自动化测试和安全研究。请勿用于未授权目标。


背景与问题

在前后端分离项目里,接口往往会增加一层“门槛”:

  • 请求参数里多一个 sign
  • 请求头里带 X-SignX-TimestampX-Nonce
  • 请求体经过 AES 加密
  • 响应体返回密文,需要前端解密
  • 签名逻辑依赖浏览器环境,如 window.navigatorcanvas 指纹等

中级开发者常见卡点通常有这几个:

  1. 知道接口地址,但不知道 sign 怎么来的
  2. 能看到混淆后的 JS,但找不到核心函数
  3. 能算出 sign,但服务端仍返回验签失败
  4. 本地脚本复现成功一次,换个请求就失效
  5. 抓到了密文,却不知道加密前的明文结构

我自己早期踩过一个很典型的坑:以为签名算法就是 md5(timestamp + data),结果折腾半天都不对。最后发现真正参与签名的是排序后的扁平化参数串,而且空字符串字段会被过滤掉。也就是说,差一个“参数标准化”步骤,后面全错。

所以,逆向这类逻辑时,不能上来就盯着加密函数,要先建立全链路视角。


前置知识与环境准备

如果你已经会用浏览器开发者工具,可以直接跳过这节。

建议准备

  • Chrome / Edge DevTools
  • 抓包工具:Charles、Fiddler、mitmproxy 任选一种
  • Node.js 16+
  • 一份 JS 美化工具
  • 可选:AST 工具,如 @babel/parser
  • 可选:Python 3.10+,用于快速写验证脚本

你至少需要搞清楚的概念

  • 签名:通常是为了防篡改、防重放、校验请求完整性
  • 加密:通常是为了隐藏请求内容,常见是 AES
  • 摘要算法:MD5 / SHA1 / SHA256 等
  • 参数标准化:排序、过滤空值、拼接规则、编码方式
  • 浏览器环境依赖:有些函数离开 window 就跑不起来

背景与问题:为什么要先看“链路”,不要先看“算法”

很多人一上来就在 Sources 面板全局搜 md5sha256CryptoJS。这当然有时能奏效,但效率并不稳定。更稳的方式是先把请求生命周期拆开:

flowchart TD
    A[页面触发操作] --> B[组装业务参数]
    B --> C[参数标准化]
    C --> D[生成 timestamp/nonce]
    D --> E[计算 sign]
    E --> F[可选: 加密请求体]
    F --> G[发起 HTTP 请求]
    G --> H[服务端验签/解密]
    H --> I[返回密文或明文]
    I --> J[前端解密/验签]

你真正要定位的,不是某个“神秘函数”,而是这条链路中的几个关键节点:

  • 原始业务参数是什么
  • 签名前参数长什么样
  • 时间戳和随机数格式是什么
  • 密钥是写死、派生,还是服务端下发
  • 最终 HTTP 报文里哪些字段参与了签名

核心原理

这一节不讲过度抽象,我直接按实战里最常见的模式拆。

1. 常见签名结构

很多前端签名其实都能归到这个公式:

sign = HASH( canonical(params) + secret + timestamp + nonce )

其中:

  • HASH:MD5 / SHA1 / SHA256 / HMAC-SHA256
  • canonical(params):参数排序并拼接后的字符串
  • secret:可能硬编码、动态下发、或由 token 派生
  • timestamp:毫秒/秒级时间戳
  • nonce:随机串,防重放

2. 常见加密结构

请求体加密通常有两层:

  1. 签名层:防篡改
  2. 加密层:隐藏内容

例如:

plaintext body -> AES-CBC encrypt -> ciphertext
sign = SHA256(ciphertext + timestamp + nonce + secret)

也可能是:

sign = HMACSHA256(JSON.stringify(data), secret)
body = RSA(publicKey, AESKey) + AES(data, AESKey)

3. 你真正要复现的是“协议”,不是单个函数

这点非常重要。

很多人看到某个函数:

function s(e){return md5(e)}

就以为它是核心。其实它只是最后一步。真正关键的是 e 是怎么来的:

  • 对象有没有排序
  • 数组是否展开
  • undefined 是否忽略
  • 布尔值是否转成字符串
  • 中文是否 URL encode
  • JSON 是否压缩掉空格
  • key 名字有没有大小写转换

这些差异,都会让最终 sign 完全不同。


定位路径:从抓包到断点的实战方法

我建议你按这个顺序走,成功率最高。

第一步:抓包确认“异常字段”

先在 Network 面板看目标请求,重点观察:

  • Query String Parameters
  • Request Payload
  • Headers
  • Cookie
  • Response

优先找这些特征字段:

  • sign / signature
  • ts / timestamp
  • nonce
  • token
  • data / encryptData
  • 看起来像 Base64 或 Hex 的长字符串

如果一个请求里同时出现:

  • 一个时间戳
  • 一个随机串
  • 一个 32 或 64 位十六进制串

那基本就是签名流程了。

第二步:发起点打断点

找到请求触发位置,通常可以在以下位置下手:

  • fetch
  • XMLHttpRequest.prototype.send
  • axios 拦截器
  • 某个 API 封装文件

你可以先在 DevTools 里搜:

  • fetch(
  • axios.create
  • interceptors.request.use
  • XMLHttpRequest
  • CryptoJS
  • md5
  • sha256

第三步:Hook 通用 API,看入参

如果代码混淆得比较厉害,别急着啃业务代码。先 Hook 常见加密函数。

例如在控制台注入:

(function () {
  const rawFetch = window.fetch;
  window.fetch = async function (...args) {
    console.log('[fetch args]', args);
    return rawFetch.apply(this, args);
  };
})();

如果页面用的是 CryptoJS,还可以 Hook:

(function () {
  if (!window.CryptoJS) return;
  const rawStringify = CryptoJS.enc.Utf8.stringify;
  CryptoJS.enc.Utf8.stringify = function (...args) {
    console.log('[Utf8.stringify]', args);
    return rawStringify.apply(this, args);
  };
})();

当然,更常用的是直接 Hook 签名前的字符串生成函数,这需要你结合调用栈去找。

第四步:对比两次请求,抽出变量

这是非常实用的一招。

同一个接口发两次,只改一个业务参数,比如把:

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

改成:

{"page":2,"size":10}

然后对比:

  • 哪些字段跟着变
  • sign 是否完全变化
  • ciphertext 长度是否变化
  • nonce 是否总是不同
  • timestamp 是否参与校验窗口

通过差分分析,你能快速判断:

  • sign 依赖哪些参数
  • 加密是对整体 body 还是部分字段
  • 时间戳是否只是辅助字段

实战案例:复现一个典型前端签名 + AES 请求流程

下面我们构造一个常见但足够真实的例子来演示。目标流程如下:

  1. 前端业务参数对象 data
  2. 参数排序并拼接
  3. 生成 timestampnonce
  4. SHA256 生成 sign
  5. AES-CBC 加密请求体
  6. 组装请求发送

协议假设

  • 签名串格式:
appId=demo&page=1&size=10&timestamp=1562391621&nonce=abc123&secret=top_secret
  • sign = sha256(签名串)
  • body = AES-CBC(JSON字符串, key, iv),输出 Base64
  • 请求格式:
{
  "appId": "demo",
  "timestamp": 1562391621,
  "nonce": "abc123",
  "sign": "xxx",
  "data": "base64cipher"
}

实战代码(可运行)

下面分别给出 Node.js 版本和 Python 版本,方便你交叉验证。

Node.js 版本

先安装依赖:

npm install crypto-js axios

1)签名与加密实现

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

const APP_ID = 'demo';
const SECRET = 'top_secret';
const AES_KEY = CryptoJS.enc.Utf8.parse('1234567890abcdef');
const AES_IV = CryptoJS.enc.Utf8.parse('abcdef1234567890');

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

function canonicalize(obj) {
  return Object.keys(obj)
    .filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
    .sort()
    .map((k) => `${k}=${String(obj[k])}`)
    .join('&');
}

function createSign(data, timestamp, nonce) {
  const base = {
    appId: APP_ID,
    ...data,
    timestamp,
    nonce,
  };
  const signStr = `${canonicalize(base)}&secret=${SECRET}`;
  const sign = CryptoJS.SHA256(signStr).toString(CryptoJS.enc.Hex);
  return { sign, signStr };
}

function encryptData(data) {
  const plaintext = JSON.stringify(data);
  const encrypted = CryptoJS.AES.encrypt(plaintext, AES_KEY, {
    iv: AES_IV,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  });
  return encrypted.toString();
}

function buildRequestPayload(data) {
  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = generateNonce();
  const { sign, signStr } = createSign(data, timestamp, nonce);
  const encryptedData = encryptData(data);

  return {
    payload: {
      appId: APP_ID,
      timestamp,
      nonce,
      sign,
      data: encryptedData,
    },
    debug: {
      signStr,
      plaintext: JSON.stringify(data),
    },
  };
}

// demo
const data = {
  page: 1,
  size: 10,
  keyword: 'phone',
};

const result = buildRequestPayload(data);
console.log(JSON.stringify(result, null, 2));

2)带请求发送的示例

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

const APP_ID = 'demo';
const SECRET = 'top_secret';
const AES_KEY = CryptoJS.enc.Utf8.parse('1234567890abcdef');
const AES_IV = CryptoJS.enc.Utf8.parse('abcdef1234567890');

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

function canonicalize(obj) {
  return Object.keys(obj)
    .filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
    .sort()
    .map((k) => `${k}=${String(obj[k])}`)
    .join('&');
}

function createSign(data, timestamp, nonce) {
  const signBase = {
    appId: APP_ID,
    ...data,
    timestamp,
    nonce,
  };
  const signStr = `${canonicalize(signBase)}&secret=${SECRET}`;
  return CryptoJS.SHA256(signStr).toString(CryptoJS.enc.Hex);
}

function encryptData(data) {
  return CryptoJS.AES.encrypt(JSON.stringify(data), AES_KEY, {
    iv: AES_IV,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  }).toString();
}

async function requestDemo() {
  const data = { page: 1, size: 10 };
  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = generateNonce();
  const sign = createSign(data, timestamp, nonce);
  const encryptedData = encryptData(data);

  const payload = {
    appId: APP_ID,
    timestamp,
    nonce,
    sign,
    data: encryptedData,
  };

  try {
    const resp = await axios.post('https://example.com/api/list', payload, {
      headers: {
        'Content-Type': 'application/json',
      },
      timeout: 5000,
    });
    console.log(resp.data);
  } catch (err) {
    console.error('request failed:', err.message);
  }
}

requestDemo();

Python 版本

先安装依赖:

pip install pycryptodome requests

1)签名与 AES-CBC 加密

import json
import time
import random
import string
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

APP_ID = "demo"
SECRET = "top_secret"
AES_KEY = b"1234567890abcdef"
AES_IV = b"abcdef1234567890"

def generate_nonce(length=8):
    chars = string.ascii_lowercase + string.digits
    return ''.join(random.choice(chars) for _ in range(length))

def canonicalize(obj):
    items = []
    for k in sorted(obj.keys()):
        v = obj[k]
        if v is None or v == "":
            continue
        items.append(f"{k}={v}")
    return "&".join(items)

def create_sign(data, timestamp, nonce):
    sign_base = {
        "appId": APP_ID,
        **data,
        "timestamp": timestamp,
        "nonce": nonce
    }
    sign_str = f"{canonicalize(sign_base)}&secret={SECRET}"
    sign = hashlib.sha256(sign_str.encode("utf-8")).hexdigest()
    return sign, sign_str

def encrypt_data(data):
    plaintext = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
    ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
    return base64.b64encode(ciphertext).decode("utf-8")

def build_payload(data):
    timestamp = int(time.time())
    nonce = generate_nonce()
    sign, sign_str = create_sign(data, timestamp, nonce)
    encrypted = encrypt_data(data)
    return {
        "payload": {
            "appId": APP_ID,
            "timestamp": timestamp,
            "nonce": nonce,
            "sign": sign,
            "data": encrypted
        },
        "debug": {
            "signStr": sign_str,
            "plaintext": json.dumps(data, ensure_ascii=False, separators=(",", ":"))
        }
    }

if __name__ == "__main__":
    data = {"page": 1, "size": 10, "keyword": "phone"}
    result = build_payload(data)
    print(json.dumps(result, ensure_ascii=False, indent=2))

如何把浏览器里的真实算法“抠”出来

上面的例子是标准流程。但在真实项目里,你拿到的是混淆后的代码。下面是更贴近实战的定位思路。

方法一:从请求拦截器逆推

很多项目都会在 axios 请求拦截器里统一签名:

axios.interceptors.request.use((config) => {
  const ts = Date.now();
  const nonce = genNonce();
  const sign = makeSign(config.data, ts, nonce);
  config.headers['X-Timestamp'] = ts;
  config.headers['X-Nonce'] = nonce;
  config.headers['X-Sign'] = sign;
  return config;
});

所以你搜 interceptors.request.use,往往能直接摸到签名函数入口。

方法二:在加密库调用处下断点

如果页面里出现:

  • CryptoJS.AES.encrypt
  • CryptoJS.SHA256
  • window.btoa
  • atob
  • SubtleCrypto
  • TextEncoder

就可以在这些调用处下断点,查看上层调用栈。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant S as 签名函数
    participant E as 加密函数
    participant H as HTTP客户端
    participant R as 服务端

    U->>P: 点击搜索/翻页
    P->>S: 传入业务参数
    S-->>P: 返回 sign/timestamp/nonce
    P->>E: 传入明文 data
    E-->>P: 返回密文 data
    P->>H: 组装 headers/body
    H->>R: 发起请求
    R-->>H: 返回密文/明文
    H-->>P: 响应处理

方法三:直接 Hook 摘要函数,打印输入

如果能找到 md5sha256 的实现对象,直接包一层最省时间:

(function () {
  if (!window.CryptoJS || !CryptoJS.SHA256) return;
  const raw = CryptoJS.SHA256;
  CryptoJS.SHA256 = function (...args) {
    console.log('[SHA256 input]', args[0]);
    const out = raw.apply(this, args);
    console.log('[SHA256 output]', out.toString());
    return out;
  };
})();

这样做有一个好处:你不用先完全读懂混淆代码,就能先看到签名前的原始字符串

我个人在调试复杂站点时,经常先这么干。因为“输入串”一旦拿到,后面大多数时候就是参数还原问题了。


逐步验证清单

中级开发者最容易犯的错误不是“不会写代码”,而是一次改太多,不知道哪一步错了。建议按下面清单逐项验证。

验证 1:参数标准化是否一致

确认这些问题:

  • 是否按 key 字典序排序
  • 是否过滤空值
  • 是否把数字转字符串
  • 数组是 a=1,2 还是 a[0]=1&a[1]=2
  • 对象是否做 JSON 序列化
  • 中文是否编码

验证 2:时间戳单位是否一致

常见有三种:

  • 秒:1562391621
  • 毫秒:1562391621123
  • 字符串时间:2019-07-06 05:40:21

只差 3 位,签名就全错。

验证 3:哈希输出格式是否一致

同一个 SHA256,输出可能是:

  • Hex 小写
  • Hex 大写
  • Base64
  • 截取前 16 位

例如:

CryptoJS.SHA256(str).toString(CryptoJS.enc.Hex)

CryptoJS.SHA256(str).toString()

通常是一样的 Hex,但有些库默认不一样,不能想当然。

验证 4:AES 模式/填充是否正确

重点确认:

  • ECB / CBC / GCM
  • IV 是否固定
  • 输出是 Base64 还是 Hex
  • 密钥是原始字符串还是 Utf8.parse 后的 WordArray
  • 明文 JSON 是否有空格

很多验签失败,并不是算法错,而是漏掉了:

  • Authorization
  • X-Device-Id
  • User-Agent
  • 某个 Cookie 值
  • 服务端下发的动态 token

常见坑与排查

这部分我尽量写得“接地气”一点,因为这里最容易浪费时间。

坑 1:只复现了 sign,没有复现“参数预处理”

现象:

  • 你算出的 sign 看起来格式完全对
  • 但服务端始终返回 invalid sign

排查方式:

  • 打印浏览器里签名前的完整字符串
  • 打印你本地脚本签名前的完整字符串
  • 一字符一字符比对

最常见差异:

  • 少了某个字段
  • 排序不一致
  • JSON 空格不同
  • 布尔值 true 被转成 True
  • null 被当成字符串 "null"

坑 2:AES 能加密,但服务端解不开

现象:

  • 密文格式像是对的
  • 但返回“解密失败”

排查方式:

  • 确认 key 长度:16/24/32 字节
  • 确认 IV 长度:16 字节
  • 确认模式:CBC/ECB
  • 确认 padding:Pkcs7/ZeroPadding
  • 确认输出编码:Base64/Hex

很多 Node 和 Python 库默认行为不完全一样,尤其是字符串转字节这一步。

坑 3:浏览器里能跑,本地脚本跑不起来

现象:

  • 在浏览器 Console 调用函数成功
  • 拷到 Node 里报错 window is not defined

原因通常是签名函数依赖了浏览器环境:

  • window.navigator.userAgent
  • location.href
  • document.cookie
  • canvas 指纹
  • atob / btoa

解决思路:

  • 补最小运行环境 mock
  • 或者抽出纯算法部分单独运行

示例:

global.window = {
  navigator: { userAgent: 'Mozilla/5.0 demo' },
  location: { href: 'https://example.com' },
};
global.document = {
  cookie: 'token=abc123',
};

坑 4:签名偶尔成功,偶尔失败

这类问题我见过很多,通常不是算法错,而是时间窗口随机因子复用问题。

排查重点:

  • 服务端是否校验 5 秒/30 秒时间窗口
  • nonce 是否要求唯一
  • 是否有一次性 token
  • 请求重放是否被拦截

坑 5:JS 混淆太深,根本读不动

这时候不要硬啃所有代码,优先做三件事:

  1. 美化代码
  2. 断在请求发起点
  3. 用调用栈向上找 2~4 层

很多情况下,真正关键的逻辑并不多,盯住“请求前最后一步”就够了。


安全/性能最佳实践

这部分不仅对逆向分析有帮助,对你自己设计接口协议也很有价值。

安全方面

1)不要把“前端签名”当成真正的安全边界

前端代码天然可见,算法和密钥只要在浏览器执行,就有被分析的可能。前端签名更适合:

  • 增加调用门槛
  • 降低随意抓接口的成本
  • 防止简单篡改和重放

但不适合单独承担核心安全能力。

2)敏感密钥不要硬编码在前端

如果把长期有效的 secret 直接写进 JS 包,逆向者迟早能拿到。更合理的思路是:

  • 使用短期 token
  • 服务端参与派生密钥
  • 用设备态、会话态信息做辅助校验
  • 服务端做频控、行为风控

3)签名要覆盖关键字段

至少应覆盖:

  • 路径
  • 业务参数
  • 时间戳
  • nonce
  • 用户态标识

否则只签了一部分字段,篡改空间还很大。

性能方面

1)避免重复加密大对象

如果请求体很大,每次都全量 JSON 序列化 + AES,移动端性能会比较差。可以考虑:

  • 只加密敏感字段
  • 减少深层对象结构
  • 避免重复签名同一数据

2)统一签名中间层

工程上建议把这部分收敛到一层,而不是散落到各个页面:

classDiagram
    class ApiClient {
      +request(config)
    }

    class Signer {
      +canonicalize(data)
      +createSign(data, ts, nonce)
    }

    class Encryptor {
      +encrypt(data)
      +decrypt(data)
    }

    ApiClient --> Signer
    ApiClient --> Encryptor

这样做的好处是:

  • 更容易排查问题
  • 更容易做协议升级
  • 更容易插入日志和灰度控制

3)保留调试开关,但不要泄露到生产日志

很多项目为了排查问题,会打印:

  • 签名前字符串
  • AES 明文
  • token/nonce/sign

开发环境可以,生产环境一定要谨慎,否则日志系统本身就成了泄露点。


一套我常用的定位模板

如果你想把这篇文章的方法直接带走,我建议记住下面这套顺序:

A. 先抓包确认字段

看有没有:

  • sign
  • timestamp
  • nonce
  • encryptData

B. 再找请求入口

优先找:

  • axios request interceptor
  • fetch 封装
  • XHR send 调用点

C. 再 Hook 常见函数

优先 Hook:

  • SHA256 / MD5
  • AES.encrypt
  • JSON.stringify
  • btoa / atob

D. 抓“签名前原串”

这是最关键证据。

E. 最后复现协议

顺序通常是:

  1. 参数标准化
  2. 时间戳/nonce
  3. sign
  4. body 加密
  5. 请求头补齐

别反过来。很多人上来先复现 AES,最后才发现 sign 串都错了。


总结

前端签名算法和接口加密流程,看起来花哨,拆开以后其实核心就几步:

  1. 找到请求触发点
  2. 抓出签名前原始字符串
  3. 搞清参数标准化规则
  4. 确认摘要算法、输出格式、时间戳单位
  5. 确认 AES 模式、IV、编码
  6. 用本地脚本逐步复现并做差分验证

如果你是中级开发者,我给你的可执行建议是:

  • 不要先读全量混淆代码,先抓链路
  • 不要先猜算法,先拿签名前原串
  • 不要一步到位,先把 sign 跑通,再补加密
  • 每次只验证一个变量:排序、时间戳、编码、IV

最后再强调边界:前端逆向更像是协议还原,不是单纯“解密函数搜索”。一旦你把“参数标准化 + 签名输入 + 加密输入”这三个中间态抓住,问题通常就已经解决了大半。

如果你照这套流程做,下一次再遇到 signnonceAES 混在一起的接口,不会再只剩“硬猜算法”这一条路。


分享到:

上一篇
《从提示工程到 RAG:中级开发者构建企业级 AI 问答系统的实战路径》
下一篇
《分布式架构中服务拆分后的事务一致性实战:基于 Saga、Outbox 与幂等设计的落地方案》