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

《从抓包到还原签名:中级开发者实战 Web 逆向中的前端加密参数分析与自动化复现》

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

从抓包到还原签名:中级开发者实战 Web 逆向中的前端加密参数分析与自动化复现

很多中级开发者第一次接触 Web 逆向时,卡住的并不是“不会抓包”,而是抓到了请求,却发现接口参数里有一串看起来像随机值的 signtokennoncetxyz,直接重放请求必然失败。

这篇文章我不讲太玄的理论,而是按真实工作流带你走一遍:

  1. 先抓包定位关键请求
  2. 判断签名参数属于哪一类
  3. 回到前端 JS 中定位生成逻辑
  4. 抽离签名算法
  5. 用脚本自动化复现

文章目标读者是已经有一定前端、Python 或 Node.js 基础的开发者。默认你知道浏览器开发者工具怎么开,也知道 HTTP 请求长什么样。

提醒:本文内容用于合法授权的安全研究、接口联调、自动化测试与教学分析。不要用于未授权的数据抓取或绕过访问控制。


背景与问题

一个典型场景是这样的:

你在浏览器里看到某个接口请求如下:

POST /api/data/list HTTP/1.1
Host: example.com
Content-Type: application/json
X-Sign: 8f3c1b7d...
X-Timestamp: 1698650000

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

你把这个请求复制到 Postman 或 Python 里,结果服务端返回:

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

这时常见问题有几个:

  • sign 是怎么生成的?
  • 是不是和请求体、时间戳、Cookie 绑定?
  • 是前端自己算的,还是服务端下发后再加工的?
  • 该如何稳定自动化复现,而不是每次手动复制?

你真正要解决的,不只是“算出一个 sign”

更准确地说,你要回答这几个问题:

  • 输入是什么:路径、参数、时间戳、随机数、设备指纹、Cookie?
  • 变换过程是什么:排序、拼接、编码、摘要、加密、混淆?
  • 运行环境是什么:浏览器、Node、Webpack 模块、运行时补环境?
  • 校验边界是什么:时效、重放、登录态、Referer、UA、Header 绑定?

如果这四件事没梳理清楚,就算你暂时跑通了,也很容易第二天失效。


前置知识与环境准备

建议准备以下工具:

  • 浏览器开发者工具(Chrome DevTools)
  • 抓包工具:Charles / Fiddler / mitmproxy / Burp Suite
  • Node.js 18+
  • Python 3.10+
  • 一个 JS 格式化工具:Prettier 或在线 beautify
  • 可选:jsdomcrypto-jsaxios

安装基础环境:

npm init -y
npm install axios crypto-js
pip install requests

核心原理

前端加密参数分析,核心不是“解密”,而是还原客户端生成签名的流程

我一般把这类参数分成 4 种:

  1. 明文拼接后摘要

    • 例如 md5(path + timestamp + body + secret)
    • 最常见,最适合复现
  2. 对象排序后序列化摘要

    • 例如对参数按 key 排序,拼成 querystring 后再 hash
    • 容易踩对象顺序和空值处理的坑
  3. 前端对称加密

    • 例如 AES 加密后再 Base64
    • 重点是 key、iv、mode、padding
  4. 混淆包装型

    • 表面很复杂,实际底层还是 md5/sha256/aes/rsa
    • 常见于 Webpack 打包、字符串数组混淆、控制流平坦化

一个典型签名链路

flowchart LR
A[抓包定位请求] --> B[找出变化参数 sign/timestamp/nonce]
B --> C[全局搜索参数名]
C --> D[定位请求发起点]
D --> E[回溯签名函数]
E --> F[提取输入与算法]
F --> G[Node/Python 自动化复现]
G --> H[对比浏览器真实请求验证]

请求发起与签名生成的协作关系

sequenceDiagram
participant U as 用户操作
participant B as 浏览器前端
participant S as 签名函数
participant A as 接口服务端

U->>B: 点击查询
B->>S: 传入 path/body/timestamp
S-->>B: 返回 sign
B->>A: 携带 sign 发起请求
A->>A: 按相同规则校验
A-->>B: 返回业务数据

我们要找的,不是“复杂代码”,而是“稳定输入输出关系”

比如下面这种代码看着复杂,其实很普通:

function getSign(path, data, ts) {
  const raw = path + "|" + JSON.stringify(data) + "|" + ts + "|salt123";
  return md5(raw);
}

真正难的是它可能被包装成:

  • Webpack 模块
  • 匿名闭包
  • 动态字符串索引
  • Hook 之后才出现的运行时代码
  • 依赖浏览器环境,如 window.navigatordocument.cookie

分析路径:从抓包到定位签名函数

这一部分是实战里最值钱的,因为很多人不是不会写代码,而是不会找入口。

第一步:抓包,找“会变”的字段

先连续发起 2~3 次相同请求,对比这些字段:

  • Query 参数
  • Request Body
  • Header
  • Cookie
  • URL Path

重点找:

  • 每次都变:timestampnonce
  • 请求内容变时才变:signtoken
  • 登录切换时变化:sessionauthorization

如果你看到这种规律:

字段请求 1请求 2规律
timestamp16986500001698650012明显时间戳
noncea8f1...c2b7...随机数
sign9c7d...f112...依赖前两者或请求体

那就说明签名大概率依赖:

  • 请求参数
  • 时间戳
  • 随机数
  • 固定盐值

第二步:在 Sources 中全局搜索关键字

在浏览器 DevTools 中优先搜:

  • sign
  • timestamp
  • nonce
  • 请求路径片段,例如 /api/data/list
  • md5sha1sha256
  • CryptoJS
  • axios.interceptors.request.use

很多签名并不在业务代码里直接调用,而是放在:

  • axios 请求拦截器
  • 公共请求封装函数
  • 某个 util 模块

例如:

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

那就已经快找到核心了。

第三步:打断点,看真实入参

如果全局搜索搜到很多结果,最有效的方法不是盲读,而是下断点

建议断在:

  • 请求拦截器
  • XMLHttpRequest.send
  • fetch
  • 目标工具函数入口

你要确认这些内容:

  • 最终参与签名的是原始对象还是序列化字符串?
  • JSON.stringify 之前是否排序?
  • 是否加了固定前后缀?
  • 是否调用了 URL 编码?
  • 时间戳单位是秒还是毫秒?

我当时踩过一个非常典型的坑:页面里展示的是秒级时间戳,但签名函数内部用的是毫秒级,最后服务端只取前 10 位。你如果直接拿前端 Header 里的秒级值参与计算,永远对不上。


实战示例:还原一个典型签名

下面构造一个常见案例,流程足够贴近真实项目,同时代码可运行。

目标请求

POST /api/data/list
X-Timestamp: 1698650000
X-Sign: 6d3c...
Content-Type: application/json

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

通过调试定位到前端代码

假设你在前端里找到了这样一段逻辑:

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

function makeSign(url, data, ts) {
  const payload = sortObject(data);
  const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=demo_secret`;
  return md5(raw).toUpperCase();
}

这就是一个非常典型的“排序 + 序列化 + 固定盐 + MD5”。


实战代码(可运行)

下面分别给出 Node.js 和 Python 版本,便于你做自动化复现。

Node.js 版:生成签名并发起请求

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

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

function makeSign(url, data, ts) {
  const payload = sortObject(data);
  const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=demo_secret`;
  return CryptoJS.MD5(raw).toString().toUpperCase();
}

async function main() {
  const url = "/api/data/list";
  const body = {
    page: 1,
    size: 20,
    keyword: "book"
  };
  const ts = Math.floor(Date.now() / 1000).toString();
  const sign = makeSign(url, body, ts);

  console.log("timestamp:", ts);
  console.log("sign:", sign);

  const resp = await axios.post("https://example.com/api/data/list", body, {
    headers: {
      "Content-Type": "application/json",
      "X-Timestamp": ts,
      "X-Sign": sign,
      "User-Agent": "Mozilla/5.0"
    },
    timeout: 10000
  });

  console.log(resp.data);
}

main().catch(err => {
  if (err.response) {
    console.error("status:", err.response.status);
    console.error("data:", err.response.data);
  } else {
    console.error(err.message);
  }
});

Python 版:复现同样签名逻辑

import time
import json
import hashlib
import requests

def sort_object(obj: dict) -> dict:
    result = {}
    for key in sorted(obj.keys()):
        val = obj[key]
        if val is not None and val != "":
            result[key] = val
    return result

def make_sign(url: str, data: dict, ts: str) -> str:
    payload = sort_object(data)
    raw = f'{url}?{json.dumps(payload, ensure_ascii=False, separators=(",", ":"))}&t={ts}&key=demo_secret'
    return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()

def main():
    url = "/api/data/list"
    body = {
        "page": 1,
        "size": 20,
        "keyword": "book"
    }
    ts = str(int(time.time()))
    sign = make_sign(url, body, ts)

    print("timestamp:", ts)
    print("sign:", sign)

    resp = requests.post(
        "https://example.com/api/data/list",
        json=body,
        headers={
            "Content-Type": "application/json",
            "X-Timestamp": ts,
            "X-Sign": sign,
            "User-Agent": "Mozilla/5.0"
        },
        timeout=10
    )
    print(resp.status_code)
    print(resp.text)

if __name__ == "__main__":
    main()

逐步验证清单

不要一上来就跑完整自动化。更稳妥的方式是按下面步骤验证。

验证 1:只校验签名函数输入输出

先固定一组参数,手工比对:

const url = "/api/data/list";
const body = { page: 1, size: 20, keyword: "book" };
const ts = "1698650000";
console.log(makeSign(url, body, ts));

目标是让你脚本的输出与浏览器中的真实 X-Sign 完全一致。

验证 2:确认序列化格式

重点检查:

  • JSON 是否有空格
  • key 顺序是否一致
  • 中文是否转义
  • 布尔值是否转成字符串
  • 数字是否被字符串化

例如 Python 默认 json.dumps 会带空格,如果前端没有空格,你就要用:

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

有些服务端并不是只校验 sign,还会同时校验:

  • Cookie
  • User-Agent
  • Origin
  • Referer
  • 登录 token

这时签名对了,请求仍可能失败。

验证 4:确认时间窗

很多接口会限制:

  • 时间戳偏差不能超过 5 分钟
  • nonce 不能重复
  • 同一登录态重放被拒绝

更复杂场景:Webpack 打包与混淆代码怎么处理

如果你看到的是类似下面这种东西:

var _0x2f13 = ["MD5", "stringify", "headers", "X-Sign"];
function _0xabc(a, b) { ... }

先别慌,常见处理方式是:

  1. 先格式化代码
  2. 找到请求发起位置
  3. 围绕请求栈回溯
  4. 打印中间变量,而不是试图一次性读懂全文件

一个可行的策略

flowchart TD
A[找到接口调用位置] --> B[定位拦截器或公共请求函数]
B --> C[观察 sign 在哪一行赋值]
C --> D[向上回溯依赖函数]
D --> E[插桩 console.log 中间值]
E --> F[确认最终摘要或加密算法]
F --> G[抽离最小可运行代码]

插桩比死读代码更高效

例如你定位到:

config.headers["X-Sign"] = h(n(u(config.data), Date.now()));

与其猜 h/n/u 分别干了什么,不如临时改成:

const a = u(config.data);
console.log("u(data) =", a);

const b = n(a);
console.log("n(u(data)) =", b);

const c = h(b, Date.now());
console.log("final sign =", c);

config.headers["X-Sign"] = c;

这样你会很快看出:

  • u 是排序
  • n 是 JSON 序列化
  • h 是 MD5 或 AES

如果依赖浏览器环境,如何补环境复现

有些签名函数不能直接拷到 Node 里跑,原因是它依赖:

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

这时可以做“最小补环境”。

简单补环境示例

global.window = global;
global.navigator = {
  userAgent: "Mozilla/5.0"
};
global.document = {
  cookie: "sessionid=demo"
};
global.location = {
  href: "https://example.com/page"
};

function btoa(str) {
  return Buffer.from(str, "binary").toString("base64");
}
function atob(str) {
  return Buffer.from(str, "base64").toString("binary");
}

global.btoa = btoa;
global.atob = atob;

如果依赖更重,可以使用 jsdom

npm install jsdom
const { JSDOM } = require("jsdom");
const dom = new JSDOM(`<!DOCTYPE html><p>Hello</p>`, {
  url: "https://example.com"
});

global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
global.location = dom.window.location;

但我的建议是:优先抽离纯算法,补环境只是兜底方案。


常见坑与排查

这一节我尽量说得接地气一点,因为这些坑几乎每个人都会踩。

1. 明明算法一样,结果就是不对

优先检查:

  • 参数顺序是否一致
  • 空值字段是否被过滤
  • 时间戳位数是否一致
  • 路径是否包含 query
  • 大小写是否一致
  • 摘要结果是否转大写

比如:

md5(raw).toUpperCase()

你漏了 toUpperCase(),结果一定不一致。


2. Python 和 JS 算出来不同

最常见原因是 JSON 序列化差异。

JS

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

Python 默认

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

Python 默认会输出带空格的字符串,和 JS 常常不一致。

正确写法一般要改成:

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

3. 接口偶尔成功,偶尔失败

这种情况大概率不是算法错,而是时效或上下文依赖问题。

排查顺序:

  1. 时间戳是否过期
  2. nonce 是否复用
  3. Cookie 是否失效
  4. 登录 token 是否过期
  5. 某些 header 是否缺失
  6. 是否需要先访问首页拿动态变量

4. 浏览器里能调,脚本里 403

这通常说明服务端做了环境校验,不只是签名校验。

重点检查:

  • Origin
  • Referer
  • User-Agent
  • Cookie
  • TLS 指纹差异
  • 是否需要特定请求顺序

有些站点会要求你先加载某个初始化接口,拿到动态盐值再请求正式接口。


5. 看到 AES / RSA 就慌了

其实不一定复杂。

  • AES:看 key、iv、mode、padding
  • RSA:通常用于加密某个短字段,不适合大数据
  • 混合方案:随机 AES key + RSA 加密 AES key

很多时候,你真正需要复现的不是“解密所有内容”,而只是“生成前端请求要求的那个密文参数”。


安全/性能最佳实践

做自动化复现时,很多人只关心“跑通”,但中级开发者更应该考虑稳定性和边界。

安全实践

  1. 只在合法授权范围内分析

    • 用于自家系统联调、安全测试、教学研究
    • 不要用于未授权绕过
  2. 不要硬编码敏感凭据到仓库

    • Cookie
    • token
    • 私钥
    • 动态盐值

建议使用环境变量:

export API_COOKIE="sessionid=xxxx"
const cookie = process.env.API_COOKIE || "";
  1. 保留最小化数据
    • 调试日志不要把完整用户隐私数据打印到控制台
    • 落盘时做脱敏

性能实践

  1. 签名函数做纯函数化

    • 输入明确
    • 无副作用
    • 更方便单测与复用
  2. 避免每次重新解析整份前端代码

    • 一旦抽离出算法,单独封装模块
    • 不要每次启动都加载整个浏览器环境
  3. 缓存可复用上下文

    • 例如动态配置、公共 token、静态盐值
    • 但不要缓存短时效 nonce
  4. 加重试但别盲重试

    • 403/签名错误通常不是网络问题
    • 先重算签名,再决定是否重试

一个更工程化的封装方式

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

class SignClient {
  constructor(secret) {
    this.secret = secret;
  }

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

  makeSign(url, data, ts) {
    const payload = this.sortObject(data);
    const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=${this.secret}`;
    return CryptoJS.MD5(raw).toString().toUpperCase();
  }

  buildHeaders(url, data) {
    const ts = Math.floor(Date.now() / 1000).toString();
    return {
      "X-Timestamp": ts,
      "X-Sign": this.makeSign(url, data, ts)
    };
  }
}

module.exports = SignClient;

这样后面你做接口测试、压测、回归验证时都可以复用。


调试建议:建立“对照组”思维

我个人很推荐一个办法:每次只改一个变量,然后和浏览器真实请求做对照

建议记录下面这张表:

项目浏览器真实值脚本值是否一致
URL/api/data/list/api/data/list
Body 字符串{"keyword":"book","page":1,"size":20}
Timestamp1698650000
Raw 拼接串...
Sign6D3C...

只要你把 raw 拼接串 对齐,后面的 hash 基本就没问题了。


一个简单的状态视图:你现在卡在哪一步

stateDiagram-v2
[*] --> 抓包完成
抓包完成 --> 找到变化字段
找到变化字段 --> 定位请求发起点
定位请求发起点 --> 找到签名函数
找到签名函数 --> 提取输入输出
提取输入输出 --> 本地复现成功
本地复现成功 --> 自动化请求成功
自动化请求成功 --> [*]
找到签名函数 --> 依赖浏览器环境
依赖浏览器环境 --> 补环境或插桩
补环境或插桩 --> 提取输入输出

总结

从抓包到还原签名,本质上不是比谁“会解密”,而是比谁能更快建立这条链路:

  1. 抓包找变化字段
  2. 在前端代码中定位请求入口
  3. 回溯到签名函数
  4. 确认输入、序列化、摘要/加密方式
  5. 最小化抽离并自动化复现
  6. 用真实请求做逐项对照验证

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

  • 先会看差异,再读代码
  • 先对齐 raw 字符串,再谈算法
  • 先抽纯函数,再考虑补环境
  • 先做最小可运行复现,再工程化封装

最后强调边界条件:

  • 如果签名强依赖设备指纹、动态下发密钥、WASM、行为校验或风控链路,那么“纯还原 sign”可能还不够。
  • 这类场景就不能只盯着一个参数,而要看整条请求上下文。

但对绝大多数常规 Web 前端签名来说,只要方法对,路径其实是稳定的。别被混淆代码吓住,很多复杂外壳下面,仍然只是排序、拼接、摘要这几个老朋友。


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的订单系统一致性设计与落地》
下一篇
《从签名参数到请求重放:中级工程师实战拆解 Web 逆向中的常见加密校验链路》