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

《从抓包到还原签名链路:中级开发者实战分析 Web 逆向中的前端加密与接口鉴权机制》

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

从抓包到还原签名链路:中级开发者实战分析 Web 逆向中的前端加密与接口鉴权机制

本文聚焦合法授权的安全研究、接口联调、自家系统排障与测试环境分析。不要将文中方法用于未授权目标。

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

表面上看,只是一个前端发请求:

  • URL 也看到了
  • 参数也能抄下来
  • Cookie 也复制了

但你用 Postman 或脚本一发,服务端却回你:

  • sign invalid
  • timestamp expired
  • unauthorized
  • risk control blocked

问题往往不在“接口不存在”,而在于你只抄到了结果,没有还原出签名链路

这篇文章我会按一个更接近真实工作的路径来讲:先抓包,再定位加密点,再还原签名输入,最后用可运行代码复现请求。重点不是某个站点的私有算法,而是你以后遇到类似页面时,能自己拆出来。


背景与问题

在现代 Web 应用里,常见的接口保护手段大致有这几类:

  1. 静态 Token / Cookie 鉴权
    • 登录态、Session、JWT
  2. 动态签名
    • sign = md5(path + body + ts + secret)
  3. 请求体加密
    • 如 AES 加密 payload,服务端解密后再处理
  4. 设备指纹 / 风控参数
    • 浏览器环境、Canvas、WebGL、时区、语言等
  5. 时序校验
    • 时间戳、nonce、防重放

很多人一上来就搜“JS 逆向”,然后直接在压缩后的大 bundle 里找 sign。这不算错,但效率不高。更稳的方法,是先建立链路图

  • 哪个请求失败
  • 失败的是哪个字段
  • 字段来自哪里
  • 它依赖哪些输入
  • 输入又从什么上下文拿

一个典型现象

你抓到如下请求:

POST /api/order/list HTTP/1.1
Host: example.com
Content-Type: application/json
X-Timestamp: 1699999999
X-Nonce: p8Qx2kLm
X-Sign: 8a4c7b5d...
Cookie: sessionid=abc123

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

你照着发:

  • Header 一样
  • Body 一样
  • Cookie 一样

结果服务端还是报签名错误。

这时要意识到:签名不是一个静态值,而是基于当前请求上下文实时计算的。通常至少依赖:

  • 请求路径
  • 请求方法
  • 规范化后的参数
  • 时间戳
  • nonce
  • 某个前端内置或下发的 secret / token

前置知识

如果你已经熟悉下面内容,阅读会更顺:

  • 浏览器开发者工具 Network / Sources / Application
  • 基本抓包工具使用
  • JavaScript 基础
  • 常见哈希与对称加密概念:
    • MD5 / SHA256
    • AES-CBC / AES-GCM
    • Base64 / Hex / UTF-8
  • HTTP 请求结构与 Cookie / Header 作用

环境准备

建议准备以下工具:

  • Chrome DevTools
  • 抓包工具
    • Charles / Fiddler / mitmproxy 任选其一
  • Node.js 16+
  • Python 3.9+
  • 可选:
    • webpack bundle 美化插件
    • source-map-explorer
    • AST 工具如 @babel/parser

我个人常用的组合是:

  • 浏览器里先看 Network 和 Sources
  • 需要改包时用 mitmproxy
  • 需要快速复算签名时用 Node.js
  • 需要批量联调时用 Python

背景与问题:为什么“抄请求”经常失败

先用一张图把整体流程摆出来。

flowchart TD
    A[浏览器页面操作] --> B[前端收集输入]
    B --> C[生成 ts/nonce]
    C --> D[参数规范化]
    D --> E[签名或加密]
    E --> F[组装 Header/Body]
    F --> G[发送接口请求]
    G --> H[服务端验签/解密]
    H --> I{通过?}
    I -- 是 --> J[返回业务数据]
    I -- 否 --> K[返回鉴权/风控错误]

你在抓包工具里看到的,通常只是 F -> G 这一步。

但服务端校验看的却是整条链:

  • ts 是否过期
  • nonce 是否重复
  • body 是否被改动
  • 参数顺序是否一致
  • sign 是否匹配
  • cookie / token 是否与用户态对应

所以真正要还原的,不是“某个 sign 字符串”,而是sign 的生成方法


核心原理

这一部分我们不讲站点私货,而讲你在实际项目里最常见的几种模式。

1. 鉴权参数通常分布在哪

前端接口鉴权参数常见出现位置:

  • Header
    • X-Sign
    • X-Timestamp
    • Authorization
  • QueryString
    • ?sign=...&t=...
  • Body
    • { data: "...密文...", sign: "..." }
  • Cookie
    • csrf_token
    • sessionid

它们之间往往存在依赖关系。

2. 常见签名链路模式

模式 A:纯签名,不加密

sign = SHA256(method + path + sortedParams + ts + nonce + secret)

特点:

  • 参数可见
  • 重点在签名校验
  • 服务端更容易排查

模式 B:先加密,再签名

cipher = AES(payload, key, iv)
sign = MD5(path + cipher + ts + secret)

特点:

  • 抓包看到的是密文
  • 需要先定位加密函数
  • 密文编码方式常见为 Base64 / Hex

模式 C:服务端下发动态密钥

page load -> get config -> receive token/seed -> derive sign key -> request api

特点:

  • 仅看单个接口不够
  • 要补抓初始化配置接口
  • 常有 token 轮换、过期时间

3. 参数规范化是高频坑点

即使你算法抄对了,参数拼接规则错一点也会失败。

比如同一个对象:

{"b":2,"a":1}

不同实现下可能变成:

  • b=2&a=1
  • a=1&b=2
  • {"b":2,"a":1}
  • {"a":1,"b":2}

服务端要求的往往是固定规范,比如:

  1. 对象 key 按字典序排序
  2. 忽略值为 nullundefined 的字段
  3. 数组用 , 拼接或保留 JSON 字面量
  4. Unicode 编码先转 UTF-8
  5. 空格是否编码成 %20 而不是 +

这个地方我踩过很多次坑:不是算法错,是输入串不一致。

4. 浏览器端加密代码藏在哪

一般可以优先从这几处找:

  • Network 里失败请求对应的 Initiator
  • Sources 全局搜索关键字:
    • sign
    • sha256
    • md5
    • encrypt
    • timestamp
    • nonce
    • authorization
  • 在请求发出前下断点:
    • XMLHttpRequest.prototype.send
    • fetch
  • 搜索固定错误提示文案
  • 搜索请求头名称,比如 X-Sign

下面这张时序图更直观:

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名模块
    participant B as 浏览器请求层
    participant R as 服务端

    U->>P: 点击查询
    P->>S: 传入 path/body/上下文
    S->>S: 生成 ts/nonce
    S->>S: 规范化参数
    S->>S: 哈希/加密
    S-->>P: 返回 sign/header/body
    P->>B: fetch/XHR 发请求
    B->>R: 发送请求
    R->>R: 验签/验时效/验会话
    R-->>B: 返回结果
    B-->>P: Promise resolve/reject

一套实战分析方法:从抓包到还原

下面给一个可迁移的方法论,你可以套到自己的授权测试环境中。

第一步:先锁定“最小可复现请求”

不要一开始就分析最复杂的接口。

优先找:

  • 请求参数少
  • 返回稳定
  • 不依赖复杂页面状态
  • 失败时有明确报错

例如“获取列表第一页”,通常比“提交订单”更适合入手。

第二步:确认哪些字段是动态的

抓两次同样操作,对比请求差异:

  • body 是否变了
  • ts 是否变了
  • nonce 是否变了
  • sign 是否变了
  • 有无新的 token

如果只有 ts/nonce/sign 变化,说明这是典型签名请求。

第三步:验证 sign 是否依赖 body

可以在浏览器里改一个无关参数,比如 page=1 改成 page=2,看 sign 是否变化。

  • 变化:说明 body/query 参与签名
  • 不变化:可能只校验路径、时间戳和 token

第四步:定位发请求前的最后一跳

在浏览器控制台中 hook fetch 或 XHR,打印参数。

Hook fetch

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

Hook XHR

(function () {
  const open = XMLHttpRequest.prototype.open;
  const send = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function (method, url) {
    this._method = method;
    this._url = url;
    return open.apply(this, arguments);
  };
  XMLHttpRequest.prototype.send = function (body) {
    console.log('[xhr]', this._method, this._url, body);
    return send.apply(this, arguments);
  };
})();

这一步的意义是:先看到请求发送前的原始输入,再往上找是谁算出的 sign。

第五步:逆着调用栈回溯签名函数

fetch 或 XHR 处打断点,查看调用栈:

  • 哪个模块传入了 headers
  • 哪个函数组装了 X-Sign
  • 哪个函数生成了 timestamp

通常你会看到类似:

headers["X-Sign"] = u(n, t, e)

这里别急着看压缩变量名,先做三件事:

  1. 在调用前打印入参
  2. 在函数返回处打印结果
  3. 确认它是否纯函数

如果是纯函数,基本就能脱离页面单独复现。

第六步:还原“签名原文”

这是最关键的一步。

假设你在代码里发现这样的逻辑:

const plain = `${method}\n${path}\n${sortedQuery}\n${bodyStr}\n${ts}\n${nonce}`;
const sign = sha256(plain + secret);

那么你真正要复现的是:

  • sortedQuery 如何排序
  • bodyStr 如何序列化
  • secret 从哪来
  • ts/nonce 格式是什么

不是简单抄 sha256


实战代码(可运行)

下面我构造一个教学用最小案例:前端对请求体做规范化,再生成签名。你可以直接运行,体会“链路复现”的关键点。

场景说明

假设页面发送请求时采用下面规则:

  • 请求方法:POST
  • 路径:/api/order/list
  • body 为 JSON
  • ts 为秒级时间戳
  • nonce 为随机字符串
  • sign = SHA256(method + "\n" + path + "\n" + canonicalBody + "\n" + ts + "\n" + nonce + "\n" + secret)

Node.js 版签名还原

// sign-demo.js
const crypto = require('crypto');

function sortObject(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(sortObject);

  return Object.keys(obj)
    .sort()
    .reduce((acc, key) => {
      const value = obj[key];
      if (value !== undefined) {
        acc[key] = sortObject(value);
      }
      return acc;
    }, {});
}

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

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

function createNonce(len = 8) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let s = '';
  for (let i = 0; i < len; i++) {
    s += chars[Math.floor(Math.random() * chars.length)];
  }
  return s;
}

function buildSign({ method, path, body, ts, nonce, secret }) {
  const canonicalBody = canonicalJSONStringify(body);
  const plain = [
    method.toUpperCase(),
    path,
    canonicalBody,
    String(ts),
    nonce,
    secret
  ].join('\n');

  const sign = sha256(plain);

  return {
    canonicalBody,
    plain,
    sign
  };
}

function main() {
  const method = 'POST';
  const path = '/api/order/list';
  const body = {
    size: 20,
    page: 1,
    filter: {
      status: 'paid',
      tags: ['vip', 'new']
    }
  };
  const ts = Math.floor(Date.now() / 1000);
  const nonce = createNonce(8);
  const secret = 'demo_secret_123456';

  const result = buildSign({ method, path, body, ts, nonce, secret });

  console.log('canonicalBody =', result.canonicalBody);
  console.log('plain =\n' + result.plain);
  console.log('sign =', result.sign);

  // 模拟最终请求头
  const headers = {
    'Content-Type': 'application/json',
    'X-Timestamp': String(ts),
    'X-Nonce': nonce,
    'X-Sign': result.sign
  };

  console.log('headers =', headers);
}

main();

运行方式:

node sign-demo.js

Python 版复现

如果你更习惯用 Python 联调接口,可以写成这样:

# sign_demo.py
import json
import time
import random
import string
import hashlib


def sort_object(obj):
    if obj is None or not isinstance(obj, (dict, list)):
        return obj
    if isinstance(obj, list):
        return [sort_object(item) for item in obj]
    return {
        key: sort_object(value)
        for key, value in sorted(obj.items(), key=lambda x: x[0])
        if value is not None
    }


def canonical_json_dumps(obj):
    return json.dumps(sort_object(obj), ensure_ascii=False, separators=(",", ":"))


def sha256(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def create_nonce(length=8):
    chars = string.ascii_letters + string.digits
    return "".join(random.choice(chars) for _ in range(length))


def build_sign(method, path, body, ts, nonce, secret):
    canonical_body = canonical_json_dumps(body)
    plain = "\n".join([
        method.upper(),
        path,
        canonical_body,
        str(ts),
        nonce,
        secret
    ])
    sign = sha256(plain)
    return canonical_body, plain, sign


def main():
    method = "POST"
    path = "/api/order/list"
    body = {
        "size": 20,
        "page": 1,
        "filter": {
            "status": "paid",
            "tags": ["vip", "new"]
        }
    }
    ts = int(time.time())
    nonce = create_nonce(8)
    secret = "demo_secret_123456"

    canonical_body, plain, sign = build_sign(method, path, body, ts, nonce, secret)

    print("canonical_body =", canonical_body)
    print("plain =\n" + plain)
    print("sign =", sign)

    headers = {
        "Content-Type": "application/json",
        "X-Timestamp": str(ts),
        "X-Nonce": nonce,
        "X-Sign": sign
    }
    print("headers =", headers)


if __name__ == "__main__":
    main()

运行方式:

python sign_demo.py

如果接口还有 AES 加密怎么办

很多页面不是直接签 JSON,而是先加密 body。下面给一个 Node.js 的教学示例。

// aes-sign-demo.js
const crypto = require('crypto');

function aesEncrypt(text, key, iv) {
  const cipher = crypto.createCipheriv(
    'aes-128-cbc',
    Buffer.from(key, 'utf8'),
    Buffer.from(iv, 'utf8')
  );
  let encrypted = cipher.update(text, 'utf8', 'base64');
  encrypted += cipher.final('base64');
  return encrypted;
}

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

function main() {
  const path = '/api/secure/data';
  const body = JSON.stringify({ page: 1, size: 20 });
  const ts = String(Math.floor(Date.now() / 1000));
  const key = '1234567890abcdef';
  const iv = 'abcdef1234567890';
  const secret = 'demo_secret';

  const cipherText = aesEncrypt(body, key, iv);
  const signPlain = [path, cipherText, ts, secret].join('|');
  const sign = sha256(signPlain);

  console.log('cipherText =', cipherText);
  console.log('signPlain =', signPlain);
  console.log('sign =', sign);
}

main();

这段代码的意义不在于算法本身,而在于提醒你:密文本身也可能参与签名。所以你不能只把解密做出来,还得还原“先后顺序”和编码格式。


逐步验证清单

我建议你每次都按这个清单走,不要凭感觉改。

验证 1:时间戳是否可用

  • 服务端允许误差是几秒或几分钟
  • 秒级还是毫秒级
  • 是否要求 UTC

验证 2:nonce 是否有格式要求

  • 固定长度?
  • 只能字母数字?
  • 是否必须唯一?

验证 3:参数序列化是否一致

  • JSON 是否排序
  • 是否压缩空格
  • 中文是否 ensure_ascii=false
  • 布尔值大小写是否一致

验证 4:签名输入是否完整

  • method 是否参与
  • path 是否带域名
  • query 是否参与
  • body 是否参与
  • secret 是固定还是动态下发

验证 5:编码方式是否正确

  • Hex 还是 Base64
  • UTF-8 还是 Latin1
  • URL 编码发生在签名前还是签名后

验证 6:会话态是否绑定

  • sign 虽然对了,但 Cookie 不对也会失败
  • 某些 token 与登录用户、设备指纹绑定

常见坑与排查

这是实战里最容易浪费时间的部分。

坑 1:你以为是“加密错了”,其实是“序列化错了”

最典型的现象:

  • 你本地算出的 sign 长度对
  • 算法也对
  • 就是跟浏览器里的 sign 不一致

优先检查:

  • 对象 key 顺序
  • 数组顺序
  • 空字段是否参与
  • JSON 是否带空格
  • 数字和字符串类型是否被隐式转换

例如:

JSON.stringify({a:1,b:2}) !== '{"b":2,"a":1}'

如果服务端按固定字典序验签,而你直接 JSON.stringify 原始对象,就会翻车。

坑 2:你看到的 secret 并不是最终 secret

很多项目会把 secret 再做一层处理,比如:

  • base64 decode
  • 字符串切片
  • 与时间戳拼接
  • 通过 wasm 导出
  • 从配置接口获取 seed 后派生

所以不要只搜“看起来像密钥的字符串”,而要看最终传进 hash/encrypt 的值

坑 3:Hook 晚了,错过原始值

有些站点在页面初始化阶段就缓存了原始 fetch 或签名函数引用。你后面再 hook,可能已经来不及。

排查建议:

  • 刷新前就在 Snippets 注入 hook
  • 使用断点而不是只靠 console.log
  • 必要时在本地代理层改包观察

坑 4:不是前端签名,而是服务端下发一次性票据

有些请求里的 sign 根本不是前端算的,而是服务端预先下发的:

  • 页面初始化接口返回一个 ticket
  • 提交接口直接带这个 ticket
  • ticket 绑定时间窗和用户态

这时你再怎么搜 sha256 都没用。要回到抓包链路,确认票据来源。

坑 5:浏览器环境参与了计算

常见于风控场景:

  • navigator.userAgent
  • 屏幕分辨率
  • 时区
  • Canvas 指纹
  • WebGL 信息

如果脚本复现总失败,而浏览器里总成功,就要怀疑环境差异。

一个实用排查流程图

flowchart TD
    A[请求复现失败] --> B{错误类型}
    B -->|sign invalid| C[检查签名原文]
    B -->|timestamp expired| D[检查时间戳单位/时区]
    B -->|unauthorized| E[检查 Cookie/Token]
    B -->|risk blocked| F[检查环境指纹/频率限制]

    C --> C1[参数排序]
    C --> C2[编码格式]
    C --> C3[secret 来源]
    C --> C4[是否漏了 path/method/query]

    D --> D1[秒/毫秒]
    D --> D2[本地时钟漂移]

    E --> E1[登录态是否过期]
    E --> E2[token 是否和用户绑定]

    F --> F1[UA/Referer]
    F --> F2[浏览器指纹]
    F --> F3[请求频率]

安全/性能最佳实践

这一节从“开发者应该怎么设计”和“分析时怎么避免误判”两个角度说。

1. 不要把前端加密当成真正的密钥保护

从安全设计角度讲:

  • 任何放在前端的 secret,都默认可被提取
  • 前端签名更适合做:
    • 防止参数被随意篡改
    • 增加滥用门槛
    • 配合时效和会话做基础风控
  • 不适合做:
    • 高价值密钥长期保存
    • 单独依赖前端算法作为安全边界

如果是你自己设计系统,真正敏感的校验应尽量放到服务端。

2. 签名要绑定上下文

如果只对 body 做签名,而不带上:

  • path
  • method
  • ts
  • nonce
  • user/session

那么签名容易被复用或重放。

比较稳的做法是至少包含:

  • 请求方法
  • 路径
  • 规范化参数
  • 时间戳
  • nonce
  • 会话上下文标识

3. 服务端要做重放防护

常见做法:

  • 限制 ts 有效窗口,比如 300 秒
  • 存储短期 nonce,拒绝重复提交
  • 签名与用户会话绑定
  • 对高风险接口增加验证码或二次确认

4. 前端实现要兼顾性能

很多项目把签名逻辑写在每个请求拦截器里,如果:

  • 每次都做重排序
  • 大对象频繁 JSON.stringify
  • AES 对大 payload 重复加密

会带来不小的性能开销。

优化思路:

  • 只对必要字段签名
  • 避免对超大对象重复深拷贝
  • 对稳定配置做缓存
  • 在高频接口中减少不必要的加密层

5. 调试时保留“原文日志”

如果你在维护自家系统,我强烈建议服务端在测试环境保留可审计日志:

  • 参与验签的 canonical string
  • 签名失败原因
  • 时间戳差值
  • nonce 冲突情况

这比只返回一句 sign invalid 好排查太多。


一个更贴近真实项目的分析思路

很多中级开发者已经会抓包了,但还缺少“怎么组织证据”的意识。我的建议是,把每次逆向分析都整理成下面这张表:

项目结论
请求路径/api/order/list
请求方法POST
鉴权字段位置Header
动态字段X-Timestamp, X-Nonce, X-Sign
sign 是否依赖 body
body 序列化规则JSON 字典序,无空格
secret 来源页面配置 / 初始化接口 / 内置常量
是否依赖 Cookie
是否依赖环境指纹待确认
失败报错sign invalid

这张表看起来朴素,但非常有用。因为你会发现,真正难的不是写出哈希代码,而是把链路中的不确定项逐个消掉


边界条件:什么时候不值得继续深挖

不是所有接口都值得完整还原。下面几种情况,我通常会建议先停一下,评估成本:

  1. 强依赖复杂浏览器指纹
    • 说明重点不只是签名,而是风控系统
  2. 关键逻辑在 wasm 或 native 容器
    • 成本明显升高
  3. 密钥由服务端一次一发,且强绑定会话
    • 适合联调,不适合脱离原上下文复现
  4. 接口本身有合法开放方式
    • 优先走官方 SDK 或正式联调方案

逆向分析是手段,不是目的。对工程来说,最低成本拿到可验证结论更重要。


总结

把这篇文章压缩成一句话,就是:

Web 逆向里最关键的,不是“找到加密算法”,而是“还原完整签名链路”。

你可以把实战过程记成 6 步:

  1. 抓包:锁定最小可复现请求
  2. 对比:找出动态字段
  3. Hook:截获发送前原始参数
  4. 回溯:定位签名函数调用链
  5. 还原:确认 canonical string、secret、编码规则
  6. 复现:用 Node/Python 独立计算并验证

如果你现在手上就有一个“明明抓到了请求但就是复现不了”的接口,我建议立刻做这三件事:

  • 抓两次完全相同的操作,做请求 diff
  • fetch / XHR 发送前下断点,看 headers 谁写进去的
  • 把“签名输入原文”打印出来,而不是只盯着 sign 结果

当你能稳定回答下面这几个问题时,签名链路基本就算拿下了:

  • sign 的输入到底是什么?
  • 输入的序列化规则是什么?
  • secret 从哪里来?
  • 时间戳和 nonce 的校验边界是什么?
  • 会话态和环境信息是否参与?

做到这一步,你面对大多数常规前端签名与接口鉴权场景,就不会再是“靠运气试参数”了,而是能有方法地拆、有证据地改、有把握地复现


分享到:

上一篇
《Web3 中级实战:从零搭建基于钱包登录与链上签名的去中心化身份认证系统》
下一篇
《前端性能实战:基于 Web Vitals 的首屏加载优化与排查方案》