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

《从抓包到还原签名链路:一次典型 Web 逆向中 JS 混淆、加密参数与接口复现的实战拆解》

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

从抓包到还原签名链路:一次典型 Web 逆向中 JS 混淆、加密参数与接口复现的实战拆解

很多人刚接触 Web 逆向时,最容易卡住的不是“怎么抓包”,而是明明已经看到请求了,却复现不出来
参数里有一串看不懂的 signtokenencData,前端代码又经过混淆压缩,直接搜字段名几乎等于大海捞针。

这篇文章我不打算只讲概念,而是按一次典型实战链路来拆:

  1. 先抓到真实请求
  2. 再定位签名参数是在哪生成的
  3. 处理 JS 混淆,抽出核心算法
  4. 还原请求参数、加密逻辑与 headers
  5. 最后用代码把接口稳定复现出来

重点不是某个站点,而是一套可迁移的方法。只要目标站不是特别重的风控体系,这套思路大多数都能落地。


背景与问题

在典型的 Web 页面里,前端发起接口请求时,通常不会直接把原始参数送给后端,而是会附加一些保护字段,比如:

  • 时间戳 ts
  • 随机串 nonce
  • 签名 sign
  • 加密体 data / encData
  • 设备指纹 fingerprint
  • 动态 token / session 派生值

如果只是看浏览器 Network 面板,我们能拿到结果,但拿不到生成过程
真正难的部分往往是这些问题:

  • sign 到底由哪些字段拼接?
  • 拼接顺序是固定还是排序?
  • 有没有加盐?
  • 是 MD5 / SHA1 / HMAC / RSA / AES,还是几种组合?
  • 混淆后的 JS 入口在哪?
  • 接口失败是签名错了,还是 cookie / header / 时序错了?

我当时第一次做这类分析时,最大的误区是:一上来就盯着所有混淆 JS 看
后来发现,更高效的方式是反过来——从请求出发,倒推签名链路


前置知识

如果你已经熟悉这些内容,可以直接跳到后面的实战部分。

建议至少具备以下基础:

  • 会用浏览器开发者工具看 Network / Sources / Console
  • 会简单读 JavaScript
  • 知道常见哈希与对称加密的区别
  • 能用 Python 或 Node.js 发送 HTTP 请求

环境准备

本文示例使用以下工具,任选替代品也行:

  • Chrome DevTools
  • Fiddler / Charles / mitmproxy(抓包可选)
  • Node.js 16+
  • Python 3.10+
  • 一个格式化 JS 的工具:
    • 浏览器 Pretty Print
    • js-beautify
    • 在线 AST 工具(如 AST Explorer)

安装 Python 依赖:

pip install requests pycryptodome

安装 Node 依赖(如果需要跑前端还原逻辑):

npm init -y
npm install crypto-js axios

整体思路:先定链路,再拆算法

很多逆向文章一上来就开始“扣代码”,但实战里更推荐分层推进:

flowchart TD
    A[抓包定位目标接口] --> B[识别关键参数 sign ts nonce data]
    B --> C[XHR/Fetch 断点定位调用栈]
    C --> D[找到签名函数入口]
    D --> E[处理 JS 混淆与抽取核心逻辑]
    E --> F[验证参数拼接与加密过程]
    F --> G[Python/Node 复现请求]
    G --> H[稳定性验证与异常排查]

这个流程的核心原则是:

  • 先缩小范围:只盯目标接口相关代码
  • 先验证输入输出:不要一开始追所有函数细节
  • 先复现最小闭环:能返回正确数据,再谈工程化

背景与问题:一个典型目标接口长什么样

假设我们抓到某站点的一个搜索接口:

POST /api/search HTTP/1.1
Host: example.com
Content-Type: application/json
X-Token: 7f3d...
User-Agent: Mozilla/5.0 ...

{
  "q": "laptop",
  "page": 1,
  "ts": 1710000000000,
  "nonce": "a8f1c2e9",
  "sign": "a2c9e5b8d7f1...",
  "data": "U2FsdGVkX1..."
}

服务端返回失败时是这样的:

{
  "code": 403,
  "msg": "invalid sign"
}

此时我们可以先建立几个假设:

  1. sign 依赖 q/page/ts/nonce
  2. data 可能是加密后的业务体
  3. X-Token 也可能参与签名
  4. 请求头顺序通常不参与,但某些 header 的值可能参与
  5. 后端有时间窗口校验,ts 不能复用太久

核心原理

一次典型 Web 接口保护,大致是以下几种模式之一:

1. 纯签名模式

业务参数明文传输,只额外增加一个摘要签名。

例如:

sign = md5("page=1&q=laptop&ts=1710000000000&nonce=a8f1c2e9&key=secret")

特点:

  • 参数可见
  • 重点是拼接顺序与盐值
  • 很适合快速复现

2. 签名 + 对称加密模式

业务参数先加密,再配合签名校验。

例如:

data = AES(JSON.stringify(payload), key, iv)
sign = SHA256(data + ts + nonce + secret)

特点:

  • 明文参数不直接发送
  • 需要同时还原加密与签名
  • 常见于中等强度保护

3. 动态派生模式

签名依赖运行时上下文,如:

  • cookie 中某个字段
  • 本地存储 token
  • 页面初始化接口返回值
  • JS 运行环境生成的指纹

特点:

  • 单纯复制一段算法代码往往不够
  • 需要补齐上下游依赖

先从抓包入手:锁定真正要分析的请求

抓包时不要见到请求就分析,先做筛选:

看这几个点

  • 是否是业务核心接口,而不是埋点
  • 请求失败时是否真的和签名相关
  • 参数里是否存在明显动态值
  • 接口触发时机是否稳定、可重复

具体做法

  1. 打开 DevTools 的 Network
  2. 过滤 Fetch/XHR
  3. 触发一次页面操作
  4. 找到返回业务数据的那个请求
  5. 右键 Copy as fetchCopy as cURL

如果直接复制请求就能复现,说明站点保护很弱,那就没必要过度逆向。
真正值得分析的是:复制原请求也只能成功一次,或者换参数后就失败


定位签名入口:比“全文搜索 sign”更靠谱的方法

仅仅在 Sources 里搜 sign,通常命中一堆无关代码。更有效的手段有两个:

方法一:XHR / Fetch 断点

在 Chrome DevTools 中:

  • Sources
  • Event Listener Breakpoints
  • 勾选 XHR/fetch

重新触发请求,浏览器会在发送前断住。
然后看调用栈(Call Stack),沿着栈往上翻,通常能定位到:

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

方法二:重写关键函数做日志

在 Console 临时 hook:

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

  const originOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (...args) {
    console.log('[xhr open]', args);
    return originOpen.apply(this, args);
  };

  const originSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function (...args) {
    console.log('[xhr send]', args);
    return originSend.apply(this, args);
  };
})();

这段代码的作用不是直接拿到签名算法,而是先确认数据是在什么阶段被处理的

  • send 前 body 已经是密文了吗?
  • body 是字符串还是对象?
  • header 是在哪一层附加的?

签名链路长什么样:建立“输入 -> 变换 -> 输出”模型

定位到关键函数后,不要马上逐行读。先做一张链路图,把输入输出梳理清楚。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant S as 签名模块
    participant E as 加密模块
    participant A as 接口服务端

    U->>P: 输入搜索词/翻页
    P->>P: 组装 payload
    P->>S: 传入 q,page,ts,nonce,token
    S-->>P: 返回 sign
    P->>E: 传入 payload
    E-->>P: 返回 data
    P->>A: 发送 data, ts, nonce, sign
    A-->>P: 返回业务结果

这一步特别重要。
因为很多混淆代码看起来很乱,但真正参与签名的只有一小撮变量。
你只要先把“谁输入,谁输出”搞清楚,后面剥代码会轻松很多。


处理 JS 混淆:先去噪,再抽核心

前端混淆最常见的几类形式:

  • 变量名压缩:_0x12ab
  • 字符串数组映射:_0x3f2c(0x1a)
  • 控制流平坦化:while(!![]){switch(...)}
  • 自执行包装:一层层 IIFE
  • 大量无关 polyfill / 框架代码

第一步:格式化

先在浏览器里点击 Pretty Print,或者用工具美化。
目标不是“看懂所有代码”,而是让函数边界清晰。

第二步:识别可疑特征

重点盯这些行为:

  • JSON.stringify
  • Object.keys(...).sort()
  • join('&')
  • md5, sha1, sha256, hmac
  • CryptoJS
  • encrypt, decrypt
  • Date.now()
  • Math.random()
  • localStorage, sessionStorage, document.cookie

第三步:打印中间值

假设我们定位到这样一个函数:

function makeSign(payload, token) {
  const ts = Date.now();
  const nonce = randStr(8);
  const sorted = Object.keys(payload)
    .sort()
    .map(k => `${k}=${payload[k]}`)
    .join('&');
  const raw = `${sorted}&ts=${ts}&nonce=${nonce}&token=${token}&key=abc123`;
  const sign = md5(raw);
  return { ts, nonce, sign };
}

即便它被混淆成很难看的样子,你要关心的仍然是:

  • payload 是什么
  • 有没有排序
  • ts/nonce/token 是否参与
  • 盐值是什么
  • 最终哈希算法是什么

一个可运行的最小示例:还原签名函数

下面我们构造一个典型场景:
接口签名规则为:

  1. 业务参数按 key 排序
  2. 组成 k=v&k=v 字符串
  3. 拼上 tsnoncetoken
  4. 再拼一个固定密钥
  5. 最后做 MD5

Node.js 版本

const crypto = require('crypto');

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

function buildQuery(obj) {
  return Object.keys(obj)
    .sort()
    .map(key => `${key}=${obj[key]}`)
    .join('&');
}

function makeSign(payload, token, ts, nonce) {
  const base = buildQuery(payload);
  const raw = `${base}&ts=${ts}&nonce=${nonce}&token=${token}&key=abc123`;
  return md5(raw);
}

// demo
const payload = {
  q: 'laptop',
  page: 1
};

const token = 'user_token_xxx';
const ts = 1710000000000;
const nonce = 'a8f1c2e9';

const sign = makeSign(payload, token, ts, nonce);

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

如果还有加密参数:AES 还原示例

有些站点不是直接提交明文参数,而是先把业务体 AES 加密,再计算签名。
下面给一个常见模式:

  • data = AES-CBC(JSON.stringify(payload), key, iv)
  • sign = md5(data + ts + nonce + secret)

Node.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 md5(text) {
  return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}

function buildEncryptedRequest(payload, ts, nonce) {
  const key = '1234567890abcdef';
  const iv = 'abcdef1234567890';
  const secret = 'server_secret';

  const data = aesEncrypt(JSON.stringify(payload), key, iv);
  const sign = md5(data + ts + nonce + secret);

  return {
    data,
    ts,
    nonce,
    sign
  };
}

const payload = {
  q: 'laptop',
  page: 1
};

console.log(buildEncryptedRequest(payload, 1710000000000, 'a8f1c2e9'));

Python 复现接口请求

实际工作里,我更常用 Python 做接口复现,因为方便串联测试、重试和数据处理。

Python 版本:签名 + 请求发送

import hashlib
import time
import random
import string
import requests


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


def build_query(data: dict) -> str:
    items = sorted(data.items(), key=lambda x: x[0])
    return "&".join(f"{k}={v}" for k, v in items)


def random_nonce(length=8) -> str:
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))


def make_sign(payload: dict, token: str, ts: int, nonce: str) -> str:
    base = build_query(payload)
    raw = f"{base}&ts={ts}&nonce={nonce}&token={token}&key=abc123"
    return md5(raw)


def send_request():
    url = "https://example.com/api/search"
    payload = {
        "q": "laptop",
        "page": 1
    }
    token = "user_token_xxx"
    ts = int(time.time() * 1000)
    nonce = random_nonce(8)
    sign = make_sign(payload, token, ts, nonce)

    body = {
        **payload,
        "ts": ts,
        "nonce": nonce,
        "sign": sign
    }

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

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


if __name__ == "__main__":
    send_request()

Python 版本:AES 加密参数复现

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


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


def random_nonce(length=8) -> str:
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))


def aes_encrypt(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(text.encode("utf-8"), AES.block_size))
    return base64.b64encode(encrypted).decode("utf-8")


def build_request(payload: dict):
    ts = int(time.time() * 1000)
    nonce = random_nonce(8)
    key = "1234567890abcdef"
    iv = "abcdef1234567890"
    secret = "server_secret"

    data = aes_encrypt(json.dumps(payload, separators=(",", ":")), key, iv)
    sign = md5(data + str(ts) + nonce + secret)

    return {
        "data": data,
        "ts": ts,
        "nonce": nonce,
        "sign": sign
    }


def send_request():
    url = "https://example.com/api/search"
    payload = {
        "q": "laptop",
        "page": 1
    }
    body = build_request(payload)

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

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


if __name__ == "__main__":
    send_request()

逐步验证清单

不要一口气写完整脚本后才测试。更稳的方式是按下面顺序逐步验证:

第 1 步:先复用抓包参数

  • 直接把抓包里的原始 body 和 headers 拷贝到代码里
  • 如果这样都失败,说明站点还依赖 cookie、referer、UA 或时间窗口

第 2 步:只替换一个字段

  • 比如只改 page
  • 看错误从“成功”变成“invalid sign”
  • 这说明签名确实覆盖了业务参数

第 3 步:手动验证签名函数

在浏览器 Console 中执行你抽出来的签名函数,比较:

  • 浏览器生成的 sign
  • 你本地代码生成的 sign

必须完全一致。

第 4 步:再引入随机值生成

验证:

  • ts 格式是否一致(秒 / 毫秒)
  • nonce 长度与字符集是否一致
  • 是否有 URL 编码或大小写转换

第 5 步:补齐加密体

如果有 data

  • 比较密文长度是否接近
  • 比较 Base64 / Hex 编码格式
  • 检查 JSON 是否压缩了空格

常见坑与排查

这是实战里最容易浪费时间的部分。我把高频问题按现象整理一下。

1. 签名算法对了,但结果还是不一致

常见原因:

  • 参数排序方式不同
  • 数字在 JS 里是 number,到了 Python 变成字符串
  • 空值字段是否参与签名不一致
  • Boolean 在不同语言里表现不同:true/false vs True/False
  • JSON 序列化格式不同,尤其空格和 key 顺序

排查建议:

payload = {"page": 1, "q": "laptop"}
print(sorted(payload.items(), key=lambda x: x[0]))

原始拼接字符串打出来,而不是只看最终 hash。


2. 明明复制了浏览器请求,代码里还是 403

常见原因:

  • cookie 缺失
  • 某个动态 header 没带
  • referer/origin 校验
  • token 过期
  • IP 风控

排查顺序建议:

  1. 对比浏览器与脚本完整请求
  2. 确认 cookie 是否必要
  3. 确认 token 是否页面初始化时更新
  4. 检查时间戳是否超时

3. AES 结果不一致

常见原因:

  • CBC / ECB 模式搞错
  • key、iv 长度不对
  • 填充方式不同(PKCS7 / ZeroPadding)
  • 输出格式不同(Base64 / Hex)

可用这张状态图梳理加密排查过程:

stateDiagram-v2
    [*] --> 确认算法
    确认算法 --> 确认模式
    确认模式 --> 确认key_iv
    确认key_iv --> 确认填充方式
    确认填充方式 --> 确认输出编码
    确认输出编码 --> 对比浏览器结果
    对比浏览器结果 --> [*]

4. 代码抽出来运行就报错

这很常见,因为前端函数可能依赖浏览器环境:

  • window
  • document
  • navigator
  • atob/btoa
  • localStorage

应对方式:

  • 能重写就重写,不要整包搬运
  • 必要时在 Node 里补 mock
  • 优先抽“纯算法函数”,避免耦合环境

比如:

global.window = {};
global.navigator = { userAgent: 'Mozilla/5.0' };
global.atob = str => Buffer.from(str, 'base64').toString('binary');
global.btoa = str => Buffer.from(str, 'binary').toString('base64');

5. 找到函数了,但看不懂混淆变量

我的经验是:不要试图直接“看懂”,而是先把函数跑起来

可以这样做:

function debugWrap(fn, name) {
  return function (...args) {
    console.log(`[${name}] args=`, args);
    const ret = fn.apply(this, args);
    console.log(`[${name}] ret=`, ret);
    return ret;
  };
}

然后把目标函数包起来观察入参与返回值。
很多时候你不需要彻底反混淆,只要能确认:

  • 输入是什么
  • 中间字符串是什么
  • 最终输出是什么

就足够复现了。


一个更贴近真实场景的定位套路

如果你面对的是 Webpack 打包后的大站点,可以按下面这套路径走:

flowchart LR
    A[Network 找到目标请求] --> B[查看 Initiator]
    B --> C[定位到发请求模块]
    C --> D[找请求拦截器/统一封装层]
    D --> E[定位 sign/data 生成逻辑]
    E --> F[抽出独立函数验证]
    F --> G[脚本复现]

这里有个小经验非常实用:

优先找统一请求封装层

很多项目不会在每个页面单独写签名,而是统一封装在:

  • axios interceptor
  • request.js
  • api client
  • 通用 util 模块

也就是说,真正应该先找的不是业务页面,而是请求公共层
如果你在调用栈里看见 axios.requestinterceptors.request.use 之类的结构,十有八九就是突破口。


安全/性能最佳实践

这里的“最佳实践”不是教你“怎么绕过安全”,而是强调在分析与复现时,如何做到更稳、更安全、更节制。

1. 只做最小化验证

不要上来就高频并发请求。
先确认:

  • 参数是否正确
  • 请求是否稳定
  • 是否存在时间窗口或限速

建议先低频单线程验证。

2. 保存中间产物

非常建议把这些内容记录下来:

  • 原始请求体
  • 原始签名串
  • 中间加密前明文
  • 最终加密结果
  • 对应时间戳与 nonce

这样一旦失败,可以快速比对是哪一层出问题。

3. 抽象成可测试函数

不要把逻辑全堆在一个脚本里。
最好的结构是:

  • build_payload()
  • encrypt_data()
  • make_sign()
  • send_request()

这样你每层都能单测。

4. 注意时效性

很多站点的签名依赖:

  • 短期 token
  • 页面初始化值
  • session
  • cookie

这类逻辑即便今天复现成功,过几天也可能失效。
所以最好把“获取前置令牌”的过程也纳入自动化。

5. 避免过度依赖整包前端代码

直接在 Node 里跑整个前端 bundle,短期看省事,长期非常脆弱:

  • 依赖浏览器环境太多
  • 版本更新容易崩
  • 很难维护

更稳的做法是:把核心算法剥离成最小实现


一个推荐的工程化目录

如果你打算把一次分析沉淀下来,建议按这种结构组织:

project/
├── samples/
│   ├── request_success.json
│   └── request_failed.json
├── js/
│   └── sign_extract.js
├── python/
│   ├── sign.py
│   └── client.py
├── docs/
│   └── notes.md
└── README.md

这样做的好处是:

  • 样本、算法、请求逻辑分离
  • 方便后续站点更新时快速定位差异
  • 团队协作时也更清楚

边界条件:什么时候这套方法不够用

本文的方法适合大多数“前端生成签名”的常见场景,但它也有边界:

不太好直接套用的情况

  • 签名依赖 WebAssembly
  • 依赖浏览器指纹、Canvas、WebGL 等复杂环境
  • 强风控绑定设备/IP/行为轨迹
  • 请求前有多轮挑战校验
  • 参数由 Native App 或插件生成,而非纯前端 JS

这种情况下,思路还是类似,但工具和工作量会明显上一个台阶。


总结

把一次 Web 逆向里的签名链路还原出来,核心不是“把所有混淆 JS 看懂”,而是这三件事:

  1. 从抓包结果倒推生成过程
  2. 只抓关键输入输出,不陷入无关代码
  3. 先做最小可运行复现,再逐步补齐上下游依赖

如果你只记住一个实战口诀,我建议是这句:

先抓请求,后断调用栈;先对输入输出,后拆混淆细节;先复现最小闭环,再谈稳定工程化。

最后给几个可执行建议:

  • 遇到 sign 不一致,第一时间打印“原始拼接串”
  • 遇到 AES 不一致,优先核对模式、填充、输出编码
  • 遇到脚本 403,别只盯算法,要把 cookie、token、header、时效一起看
  • 遇到混淆严重,优先用断点和 hook 找入口,而不是硬啃整包代码

只要你把“请求链路”拆成一个个可验证节点,Web 逆向这件事就会从“玄学”变成“调试工程”。

如果你正在做自己的练习,建议照着本文顺序完整走一遍:
抓包 → 定位请求入口 → 抽签名函数 → 对比中间值 → 脚本复现
这一套打通一次,后面再碰到类似站点,效率会高很多。


分享到:

上一篇
《Spring Boot 中基于拦截器与 AOP 的接口幂等性设计与实战》
下一篇
《分布式架构中基于一致性哈希与服务治理的灰度发布实战指南》