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

《Web逆向实战:从前端加密参数定位到接口签名算法复现的完整分析方法》

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

Web逆向实战:从前端加密参数定位到接口签名算法复现的完整分析方法

做 Web 逆向时,最常见也最让人头疼的场景,不是“接口找不到”,而是接口找到了,请求也抓到了,但关键参数是加密的、签名是动态的、直接重放就是失败

这篇文章我不打算只讲零散技巧,而是带你走一遍更稳定的方法论:如何从前端页面定位加密参数生成逻辑,再到把接口签名算法复现出来,最终用脚本稳定调用接口。如果你已经有一定 JavaScript 基础,也用过 DevTools、抓过包,这篇内容会比较适合你。

说明:本文讨论的是通用分析方法,适用于自有系统调试、安全测试、协议研究等合法场景。请勿用于未授权目标。


背景与问题

很多现代 Web 应用会在前端做这些事情:

  • 对请求参数做加密,如 AES/RSA/自定义混淆
  • 对请求体或查询串做签名,如 signtokenx-sign
  • 加入时间戳、随机数、设备指纹等动态字段
  • 对代码进行压缩、混淆、控制流平坦化,增加分析难度

于是我们经常会遇到这样的现象:

  • 明明请求 URL 和 body 一模一样,重放却返回“签名错误”
  • 某个参数每次都不同,看不出规律
  • 浏览器里请求成功,脚本里请求失败
  • 切换账号、切换环境后算法行为不一致

从经验上看,问题通常不在“不会写代码”,而在于定位链路不完整。很多人上来就盯着 sign,其实真正影响结果的,可能是:

  1. 请求参数经过了排序
  2. 某些字段在签名前被二次编码
  3. 时间戳单位是秒还是毫秒
  4. Header 里的某个值也参与签名
  5. 签名之前做了哈希,哈希之前又做了 JSON 序列化
  6. JSON 序列化键顺序与我们本地实现不一致

所以,本文重点不是“某个网站的某段代码”,而是一套可迁移的分析路径


前置知识

在开始之前,建议你至少具备这些基础:

  • 会使用 Chrome DevTools:Network、Sources、Debugger
  • 看得懂基础 JavaScript
  • 知道常见加密/摘要算法:MD5、SHA256、AES、RSA、HMAC
  • 会用 Python 或 Node.js 写简单脚本

如果你对 JS 断点调试还不熟,优先练这几个动作:

  • XHR / Fetch 断点
  • 全局搜索关键字
  • Call Stack 回溯调用链
  • Local Scope / Closure 变量观察
  • Override 或 Snippet 注入调试代码

环境准备

我平时会准备这样一套工具:

  • 浏览器:Chrome
  • 抓包工具:Charles / Fiddler / mitmproxy
  • 脚本环境:
    • Node.js 18+
    • Python 3.10+
  • 常用库:
    • Node: crypto-js
    • Python: requests, hashlib, hmac, pycryptodome

安装示例:

npm install crypto-js
pip install requests pycryptodome

核心原理

要复现前端签名,核心不是“硬猜算法”,而是搞清楚这四个问题:

  1. 输入是什么
  2. 处理顺序是什么
  3. 算法是什么
  4. 输出放到哪里

一个典型签名流程大致如下:

flowchart TD
  A[抓到接口请求] --> B[定位可疑参数 sign/token]
  B --> C[全局搜索参数名或请求URL]
  C --> D[在发送前断点]
  D --> E[回溯调用链]
  E --> F[识别参与签名的原始字段]
  F --> G[确认排序/拼接/编码规则]
  G --> H[识别摘要或加密算法]
  H --> I[本地脚本复现]
  I --> J[对比浏览器结果]

把它拆开理解,会更清晰。

1. 输入层:哪些字段真正参与了签名

常见输入包括:

  • Query 参数
  • POST body
  • Header 中的某些字段
  • Cookie / localStorage / sessionStorage 中的 token
  • 时间戳、nonce、traceId
  • 路径本身,如 /api/order/list
  • 固定盐值或版本号

这里一个很常见的坑是:你看到的请求参数,不等于签名时的原始输入

例如浏览器最终发的是:

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

但签名实际输入可能是:

path=/api/data/list&page=1&size=20&ts=1710000000000&secret=xxxx

也可能是:

JSON.stringify({page:1,size:20}) + token + ts

2. 过程层:排序、拼接、编码

这一步决定了“同样字段,为何结果不一样”。

常见处理方式:

  • 按 key 字典序排序
  • 过滤空值、nullundefined
  • 数组按特定格式展开
  • URL 编码后再签名
  • JSON 压缩后签名
  • 值统一转字符串
  • 转小写或大写
  • 前后拼接固定盐值

例如:

a=1&b=2&c=3 + secret

secret + a=1&b=2&c=3

哪怕算法一样,结果也完全不同。


3. 算法层:哈希、HMAC、对称加密、非对称加密

最常见的不是“纯加密”,而是签名摘要

  • MD5
  • SHA1 / SHA256
  • HMAC-SHA256
  • 自定义变种:先 Base64 再 MD5,或先 AES 再 MD5

也有些接口会把业务参数整体加密:

  • AES-CBC / AES-ECB
  • DES / 3DES
  • RSA 分段加密
  • SM2 / SM4(国产算法场景较多)

经验上看:

  • 只有一个短字符串参数 sign:多半是哈希/HMAC
  • 一个长字符串参数 data / payload:多半是 AES/RSA 加密结果
  • 既有 data 又有 sign:通常是“先加密业务参数,再对密文或明文签名”

4. 输出层:签名放在哪

常见位置:

  • Query:?sign=xxx
  • Body:{"sign":"xxx"}
  • Header:X-Sign: xxx
  • Cookie:较少,但有

你必须确认签名字段是最终产物,还是中间产物。有些站点会先算一个 token,再基于 token 算第二个 sign


一套稳定的实战分析路径

我建议按下面的顺序做,不要跳。

sequenceDiagram
  participant U as 分析者
  participant B as 浏览器页面
  participant J as 前端JS
  participant S as 服务端接口

  U->>B: 打开页面并触发请求
  B->>J: 组装请求参数
  J->>J: 生成ts/nonce/sign/data
  J->>S: 发起XHR/Fetch请求
  S-->>J: 返回结果
  J-->>B: 渲染页面
  U->>J: 在请求发送前断点
  U->>J: 回溯sign/data生成逻辑
  U->>U: 本地脚本复现算法
  U->>S: 脚本请求验证

背景与问题:一个可复现的小案例

为了让过程具体,我们构造一个典型接口:

  • 请求地址:POST /api/demo/list
  • 请求体:
{
  "page": 1,
  "size": 20,
  "ts": 1710000000000,
  "nonce": "8f3a2c1d",
  "sign": "待计算"
}

假设前端签名规则是:

  1. pagesizetsnonce
  2. 按 key 升序排序
  3. 拼成 key=value 形式,用 & 连接
  4. 在尾部拼接 &secret=demo_key_2025
  5. 对最终字符串做 SHA256
  6. 结果转小写十六进制,作为 sign

待签名原串类似这样:

nonce=8f3a2c1d&page=1&size=20&ts=1710000000000&secret=demo_key_2025

这类结构在真实项目里非常常见:不复杂,但细节很多,稍微错一处就会失败。


核心原理:如何在前端定位签名逻辑

方法一:从 Network 面板反推

看到请求里有 sign 后,先做这几步:

  1. 在 Network 中找到目标请求
  2. 记下:
    • URL 路径
    • Method
    • Query / Payload
    • Header 中可能相关的字段
  3. 到 Sources 全局搜索:
    • 请求路径关键字,如 /api/demo/list
    • 参数名,如 sign
    • 常见发送函数,如 fetchaxios.post

如果代码没混淆太狠,通常能直接搜到。


方法二:XHR / Fetch 断点

如果搜索不到,就用更稳的方式:

  • Sources → Event Listener Breakpoints
  • 勾选:
    • XHR/fetch Breakpoints
    • 或添加 URL 关键字断点

当请求发出前停住时,看调用栈(Call Stack),不断往上回退,直到找到:

  • 参数组装函数
  • 签名函数
  • 加密函数

这是我最常用的方法,因为它不依赖变量名是否可读。


方法三:Hook 关键 API

如果目标站点代码混淆重、调用链长,可以临时 Hook:

  • window.fetch
  • XMLHttpRequest.prototype.send
  • JSON.stringify
  • CryptoJS.SHA256
  • btoa / atob
  • encrypt / decrypt 之类的全局函数

例如在控制台注入:

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

如果站点用了 CryptoJS.SHA256,甚至可以直接拦:

const rawSHA256 = CryptoJS.SHA256;
CryptoJS.SHA256 = function (...args) {
  console.log('[SHA256 input]', args[0]);
  debugger;
  return rawSHA256.apply(this, args);
};

这样你能直接看到签名前的原始字符串。


实战代码(可运行)

下面用一个完整示例演示:前端签名函数 + Python/Node 复现


1. 前端示例签名代码

function buildSign(params, secret) {
  const sortedKeys = Object.keys(params).sort();
  const pairs = [];

  for (const key of sortedKeys) {
    const value = params[key];
    if (value === undefined || value === null || value === '') continue;
    pairs.push(`${key}=${String(value)}`);
  }

  const plain = `${pairs.join('&')}&secret=${secret}`;
  return CryptoJS.SHA256(plain).toString(CryptoJS.enc.Hex);
}

// 示例
const params = {
  page: 1,
  size: 20,
  ts: 1710000000000,
  nonce: '8f3a2c1d'
};

const sign = buildSign(params, 'demo_key_2025');
console.log(sign);

2. Node.js 复现

如果你已经确认算法是 SHA256,那么 Node.js 原生 crypto 就够用了。

const crypto = require('crypto');

function buildSign(params, secret) {
  const plain = Object.keys(params)
    .sort()
    .filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== '')
    .map((key) => `${key}=${String(params[key])}`)
    .join('&') + `&secret=${secret}`;

  return crypto.createHash('sha256').update(plain, 'utf8').digest('hex');
}

const params = {
  page: 1,
  size: 20,
  ts: 1710000000000,
  nonce: '8f3a2c1d'
};

const sign = buildSign(params, 'demo_key_2025');
console.log('sign =', sign);

运行:

node sign.js

3. Python 复现

import hashlib

def build_sign(params: dict, secret: str) -> str:
    items = []
    for key in sorted(params.keys()):
        value = params[key]
        if value is None or value == '':
            continue
        items.append(f"{key}={value}")
    plain = "&".join(items) + f"&secret={secret}"
    return hashlib.sha256(plain.encode("utf-8")).hexdigest()

params = {
    "page": 1,
    "size": 20,
    "ts": 1710000000000,
    "nonce": "8f3a2c1d"
}

sign = build_sign(params, "demo_key_2025")
print("sign =", sign)

4. Python 发起真实请求示例

import time
import uuid
import hashlib
import requests

def build_sign(params: dict, secret: str) -> str:
    items = []
    for key in sorted(params.keys()):
        value = params[key]
        if value is None or value == '':
            continue
        items.append(f"{key}={value}")
    plain = "&".join(items) + f"&secret={secret}"
    return hashlib.sha256(plain.encode("utf-8")).hexdigest()

def make_nonce() -> str:
    return uuid.uuid4().hex[:8]

url = "https://example.com/api/demo/list"
secret = "demo_key_2025"

payload = {
    "page": 1,
    "size": 20,
    "ts": int(time.time() * 1000),
    "nonce": make_nonce()
}

payload["sign"] = build_sign(payload, secret)

headers = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0"
}

resp = requests.post(url, json=payload, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)

进一步升级:AES 加密 + 签名的组合场景

真实站点里,常见的是这个结构:

  1. 业务 JSON 先 AES 加密成 data
  2. 再对 data + ts + nonce + secret 做签名
  3. 请求体发送:
{
  "data": "密文",
  "ts": 1710000000000,
  "nonce": "8f3a2c1d",
  "sign": "摘要"
}

这种时候你要分两段分析:

  • 加密链:明文 JSON 如何变成 data
  • 签名链:哪些字段参与签名

下面给一个可运行的 Node 示例。

Node.js:AES-CBC + SHA256

const crypto = require('crypto');

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

function buildSign(data, ts, nonce, secret) {
  const plain = `data=${data}&nonce=${nonce}&ts=${ts}&secret=${secret}`;
  return crypto.createHash('sha256').update(plain, 'utf8').digest('hex');
}

const bizObj = {
  page: 1,
  size: 20
};

const key = '1234567890abcdef';
const iv = 'abcdef1234567890';
const ts = 1710000000000;
const nonce = '8f3a2c1d';
const secret = 'demo_key_2025';

const data = aesEncrypt(JSON.stringify(bizObj), key, iv);
const sign = buildSign(data, ts, nonce, secret);

console.log({ data, ts, nonce, sign });

Python:AES-CBC + SHA256

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

def aes_encrypt(plain_text: str, key: str, iv: str) -> str:
    cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
    encrypted = cipher.encrypt(pad(plain_text.encode("utf-8"), AES.block_size))
    return base64.b64encode(encrypted).decode("utf-8")

def build_sign(data: str, ts: int, nonce: str, secret: str) -> str:
    plain = f"data={data}&nonce={nonce}&ts={ts}&secret={secret}"
    return hashlib.sha256(plain.encode("utf-8")).hexdigest()

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

key = "1234567890abcdef"
iv = "abcdef1234567890"
ts = 1710000000000
nonce = "8f3a2c1d"
secret = "demo_key_2025"

data = aes_encrypt(json.dumps(biz_obj, separators=(",", ":")), key, iv)
sign = build_sign(data, ts, nonce, secret)

print({
    "data": data,
    "ts": ts,
    "nonce": nonce,
    "sign": sign
})

这里我特意用了:

json.dumps(biz_obj, separators=(",", ":"))

因为很多前端 JSON.stringify 输出是紧凑格式,没有空格。这个细节非常关键。


逐步验证清单

我建议你在复现时,不要一次性写完“最终脚本”,而是按下面顺序一点点核对。

第 1 步:确认请求结构

核对这些内容是否一致:

  • URL
  • Method
  • Query
  • Body
  • Header
  • Cookie
  • Origin / Referer

第 2 步:确认动态字段来源

重点查:

  • ts 是秒还是毫秒
  • nonce 长度和生成规则
  • token 来自 Cookie、localStorage 还是内存变量

第 3 步:确认签名前原串

这是最关键的一步。你必须拿到:

  • 原始拼接字符串
  • 排序规则
  • 编码规则
  • 是否过滤空值

第 4 步:确认算法与输出格式

核对:

  • MD5 / SHA256 / HMAC-SHA256
  • Hex / Base64
  • 大写 / 小写

第 5 步:浏览器值与本地值对拍

做一个最小输入,把浏览器里的:

  • plain
  • sign

打印出来,再和脚本结果逐项比较。

如果不一致,不要继续往后写请求脚本,先把签名对齐。


混淆代码下的定位技巧

很多时候你看到的是这种代码:

a["b"](c["d"](e, f), g())

变量名全废了。这时靠“读代码”会非常痛苦。我更推荐以下方法。

1. 盯住稳定锚点

稳定锚点包括:

  • 请求 URL
  • 固定 Header 名
  • 参数名 sign / data
  • CryptoJS
  • encodeURIComponent
  • JSON.stringify

这些东西即使被混淆,通常也不会完全消失。


2. 用调用栈,不要硬读全文件

当断点停住时,看调用栈,顺着往上点,你会很快找到:

  • 谁在构造 body
  • 谁在计算 sign
  • 谁在做加密

这比在几十万行 bundle 里瞎搜要高效得多。


3. 直接打印中间值

如果你已经找到疑似函数,最有效的方式往往不是“理解全部逻辑”,而是先插日志:

console.log('params=', params);
console.log('plain=', plain);
console.log('sign=', sign);

我当时踩过一个坑,就是花了半小时分析某个 helper 干了什么,后来发现只要打印一下入参和出参,三分钟就搞清楚了。


常见坑与排查

这一节非常重要。实际失败大多不是“算法错得离谱”,而是细节差一位

stateDiagram-v2
  [*] --> 抓包成功
  抓包成功 --> 请求重放失败
  请求重放失败 --> 检查时间戳
  请求重放失败 --> 检查排序规则
  请求重放失败 --> 检查JSON序列化
  请求重放失败 --> 检查Header参与签名
  请求重放失败 --> 检查编码方式
  检查时间戳 --> 成功
  检查排序规则 --> 成功
  检查JSON序列化 --> 成功
  检查Header参与签名 --> 成功
  检查编码方式 --> 成功
  成功 --> [*]

坑 1:秒级时间戳和毫秒级时间戳搞混

前端常见:

Date.now() // 毫秒
Math.floor(Date.now() / 1000) // 秒

如果服务端校验窗口很严,错一个单位直接失败。


坑 2:JSON 序列化不一致

例如前端签名用的是:

JSON.stringify({a:1,b:2})

你 Python 写成:

json.dumps({"a": 1, "b": 2})

默认会带空格,字符串就不同了。

正确做法通常是:

json.dumps(obj, separators=(",", ":"), ensure_ascii=False)

坑 3:字段顺序不一致

虽然很多语言里的字典现在“看起来有序”,但你仍然不应该赌。

要明确:

  • 是按插入顺序
  • 还是字典序排序
  • 还是固定字段顺序

坑 4:URL 编码时机错误

下面两种结果不同:

name=张三&city=北京

name=%E5%BC%A0%E4%B8%89&city=%E5%8C%97%E4%BA%AC

必须确认:

  • 签名前编码
  • 还是签名后编码
  • 编码一次还是两次

坑 5:Hex/Base64 混用

例如前端:

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

CryptoJS.SHA256(plain).toString(CryptoJS.enc.Base64)

完全不同。


坑 6:AES 模式、填充方式、密钥长度错了

AES 常见差异:

  • ECB / CBC
  • PKCS7 / ZeroPadding
  • key 长度 16 / 24 / 32
  • iv 是否参与
  • 输出是 Hex 还是 Base64

只要其中一个参数错,密文就全变。


坑 7:签名依赖运行时环境

有些站点签名依赖这些浏览器特征:

  • navigator.userAgent
  • window.screen
  • Canvas 指纹
  • WebGL 信息
  • 时区 / 语言

这类站点单纯复现算法还不够,可能还要补环境。


坑 8:Header 也参与签名

特别容易漏掉:

  • Authorization
  • X-Timestamp
  • X-Nonce
  • X-App-Version

你看到 body 一样,不代表签名前原串一样。


排查思路:失败时怎么快速定位

如果你已经写好了脚本,但接口还是失败,我建议按下面顺序排:

1. 先比“浏览器原始串”和“脚本原始串”

不要先比最终 sign,先比签名前字符串。

如果原串不同,后面都不用看。


2. 再比摘要输出格式

确认:

  • 小写 hex?
  • 大写 hex?
  • Base64?
  • 去掉了 = padding 吗?

3. 再检查请求环境差异

包括:

  • Header 是否一致
  • Cookie 是否一致
  • 是否缺少 token
  • Origin / Referer 是否被校验

4. 最后才考虑反爬或风控

如果签名完全对了还失败,再看:

  • 是否有设备指纹
  • 是否有行为校验
  • 是否有请求频率限制
  • 是否有一次性 nonce

安全/性能最佳实践

这一节不是“站在服务端教育前端”,而是从分析和复现两边都值得知道的点来说。

1. 不要迷信前端加密的安全性

前端代码运行在用户环境中,算法、密钥、流程理论上都可能被观察到。把关键安全能力完全放在前端,本身就不稳

更合理的做法是:

  • 前端只做传输层保护或协议封装
  • 服务端做真正的鉴权与验签
  • 密钥分级管理,不在前端暴露长期核心密钥

2. 签名设计要防重放

如果你是协议设计方,建议至少加入:

  • 时间戳
  • nonce
  • 过期窗口
  • 服务端幂等或 nonce 去重

否则别人抓到一包就能长期重放。


3. 避免过度复杂但不可维护的自定义算法

很多项目喜欢自己设计一套“魔改签名”,结果是:

  • 前端维护困难
  • 多端实现不一致
  • 一升级就兼容性出问题
  • 实际安全收益并不高

优先考虑成熟方案:

  • HMAC-SHA256
  • AES-GCM / AES-CBC(配合标准填充与密钥管理)
  • 标准 OAuth / JWT / AK-SK 方案

4. 复现脚本中做好缓存与连接复用

如果你需要批量请求,不要每次都:

  • 重新初始化重型对象
  • 重新建 TCP 连接
  • 重复计算可缓存的静态内容

Python 中建议使用 requests.Session()

import requests

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0"
})

这样性能和稳定性都会更好。


5. 调试期保留中间日志,生产期减少敏感输出

调试时建议记录:

  • 原始参数
  • 签名前字符串
  • 签名结果
  • 请求响应

但进入正式环境后,应避免把这些敏感内容直接落日志,尤其是:

  • token
  • secret
  • 明文业务数据
  • 加密前原串

一个更贴近真实项目的分析模板

下面给你一个我自己常用的逆向记录模板。真到项目里,照着填会很省事。

接口基础信息

  • 接口路径:
  • 请求方法:
  • Content-Type:
  • 是否需要登录:

关键字段

  • 动态参数:
  • 签名字段:
  • 密文字段:
  • Header 特殊字段:

参与签名的内容

  • 是否包含 path:
  • 是否包含 query:
  • 是否包含 body:
  • 是否包含 header:
  • 是否包含 token:

预处理规则

  • 排序方式:
  • 空值过滤:
  • URL 编码:
  • JSON 序列化方式:
  • 拼接格式:

算法信息

  • 哈希算法:
  • 加密算法:
  • 模式/填充:
  • 输出格式:

验证结果

  • 浏览器原串:
  • 本地原串:
  • 浏览器 sign:
  • 本地 sign:
  • 是否一致:

这个模板的价值在于:你会强迫自己把模糊判断变成确定信息


总结

从前端加密参数定位到接口签名算法复现,本质上不是“猜谜”,而是一个可拆解、可验证、可复盘的过程:

  1. 先抓请求,识别关键参数
  2. 再在发送前断点,回溯调用链
  3. 拿到签名前原始输入
  4. 确认排序、拼接、编码规则
  5. 识别摘要/加密算法与输出格式
  6. 用 Node 或 Python 本地复现
  7. 逐项对拍浏览器结果,直到完全一致

如果你只记住一句话,我建议记这个:

签名复现的关键,不是先认出算法,而是先拿到“签名前原串”。

因为一旦原串对了,算法通常很快就能确认;但如果原串错了,哪怕你猜对了 SHA256、AES,也还是会失败。

最后给几个可执行建议:

  • 优先用断点和调用栈,不要一上来就啃混淆代码
  • 先打印中间值,再谈理解全局逻辑
  • 每次只验证一个变量:原串、摘要、请求结构,分层排查
  • 对 Python/Node 复现,重点盯 JSON.stringify 差异、排序规则、编码方式
  • 如果签名已对仍失败,再去考虑风控、指纹、环境依赖

只要你把这套方法走熟,遇到大多数“前端加密 + 接口签名”场景,基本都能拆开解决。


分享到:

上一篇
《分布式架构中基于一致性哈希的服务路由与节点扩缩容实战》
下一篇
《区块链数据索引实战:从智能合约事件到高性能查询接口的设计与实现》