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

《从请求签名到参数还原:一次中级 Web 逆向实战中的加密逻辑定位与复现》

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

从请求签名到参数还原:一次中级 Web 逆向实战中的加密逻辑定位与复现

很多人学 Web 逆向,卡住的不是“不会下断点”,而是知道请求里有签名,却不知道从哪里开始还原
这篇文章我不讲太玄的理论,而是按一次比较典型的中级实战路径,带你从:

  • 发现异常参数
  • 定位签名生成点
  • 还原参数处理流程
  • 脱离浏览器复现请求

一步一步走完。

重点不是某个站点的特定实现,而是一套可迁移的方法论。你下次碰到 signtokencipherv_t 这类字段时,可以直接套这套思路。


背景与问题

先描述一个常见场景。

某个接口请求长这样:

POST /api/data/list HTTP/1.1
Content-Type: application/json

{
  "page": 1,
  "pageSize": 20,
  "keyword": "test",
  "timestamp": 1724810000000,
  "sign": "9f0c3a..."
}

看起来很普通,但一旦你自己用 Python 或 Node 重放,就会出现几种典型失败:

  • 返回“签名错误”
  • 返回“参数非法”
  • 返回“请求过期”
  • 明明参数一样,但服务端结果不一致

这说明问题通常不只是“缺一个 sign”,而是下面几类之一:

  1. 签名原文不是你看到的请求体
  2. 参数在发送前被重排、过滤、编码或加密
  3. 签名依赖时间戳、随机串、设备指纹、环境值
  4. 浏览器端做了多层封装,真实逻辑藏在打包代码里

我当时踩过的一个坑就是:
表面看签名只依赖 JSON 字段,实际上它先把对象按键名排序,再把空值删掉,最后拼接一个固定盐值做 MD5。你直接对原始对象 JSON.stringify,永远对不上。

所以,Web 逆向里真正的关键不是“会不会某个算法”,而是:

搞清楚签名前,参数到底经历了什么。


前置知识与环境准备

这篇文章默认你已经会这些基础操作:

  • Chrome DevTools 抓包
  • Sources 面板全局搜索
  • XHR / Fetch 断点
  • 简单阅读压缩后的 JS
  • Node.js 运行脚本

推荐环境:

  • Chrome 最新版
  • Node.js 18+
  • 一个代码编辑器
  • 可选:mitmproxy / Fiddler / Charles

如果你之前只会“看 Network 面板”,那建议这次重点练这三个能力:

  1. 从请求字段反推入口函数
  2. 从入口函数反推参数标准化逻辑
  3. 把浏览器内逻辑搬到独立脚本中复现

问题拆解:先别急着算 sign

中级实战里,最容易犯的错误是:
一看到 sign 就开始全局搜 md5sha1sha256

这样有时能碰运气,但效率并不高。更稳的方法是先拆问题:

你需要回答的 4 个问题

  1. 哪些字段参与签名?
  2. 参与签名的字段是否做了排序、过滤、编码?
  3. 签名算法是什么?
  4. 签名时是否混入了额外上下文值?

把这个过程画成图,大概是这样:

flowchart TD
    A[抓到目标请求] --> B[识别异常字段 sign/timestamp/nonce/data]
    B --> C[定位请求发起代码]
    C --> D[找到签名前的参数对象]
    D --> E[观察参数标准化: 排序/过滤/编码]
    E --> F[定位摘要算法或加密函数]
    F --> G[提取盐值/密钥/环境变量]
    G --> H[脱离浏览器复现]
    H --> I[对比线上请求并校验]

这张图的核心意思只有一句话:

先找“签名前的数据”,再找“签名算法”。


核心原理

这一类请求签名,本质上通常由三层组成。

1. 参数标准化

服务端为了避免同一组参数因为顺序不同而签名不同,通常会做标准化,比如:

  • 按键名排序
  • 删除 nullundefined、空字符串
  • 布尔值/数字转字符串
  • 对嵌套对象做稳定序列化
  • URL 编码或特殊字符转义

例如下面两个对象,语义一样,但直接 JSON.stringify 结果不一定一致:

const a = { b: 2, a: 1 };
const b = { a: 1, b: 2 };

如果服务端要求按 key 排序,那么签名原文应统一成:

a=1&b=2

2. 混入上下文

常见参与项包括:

  • timestamp
  • nonce
  • 固定盐值 appSecret
  • 用户 token
  • UA、平台标识
  • 某个运行时生成的设备 ID

典型拼接形式:

appId=1001&keyword=test&page=1&pageSize=20&timestamp=1724810000000&secret=abc123

3. 摘要或加密

常见实现有:

  • MD5
  • SHA1 / SHA256
  • HMAC-SHA256
  • AES 加密后再 Base64
  • 自定义字符映射、异或、位运算混淆

注意一点:
很多场景里所谓“加密参数”,其实只是:

  • 先序列化
  • 再做摘要签名
  • 或者 Base64 包装

它并不一定真的是强加密。


一次典型定位流程

下面我用一个“教学型案例”来演示完整路径。
假设我们看到请求:

{
  "page": 1,
  "pageSize": 20,
  "keyword": "laptop",
  "timestamp": 1724810000000,
  "nonce": "7f3a9c21",
  "sign": "e4f1d6a0f8c7..."
}

第一步:在 Network 面板确认参数来源

先看请求是:

  • Query String
  • Form Data
  • Request Payload

如果请求体里直接出现 sign,那大概率是 JS 在发请求前动态生成的。
接着看 Initiator,跳到发起脚本。

第二步:给 XHR / Fetch 下断点

在 DevTools 的 Sources -> Event Listener Breakpoints -> XHR/fetch 打开断点。
重新触发请求后,观察调用栈,重点找:

  • 请求封装函数
  • 参数拦截器
  • 通用签名函数

这一步的目标不是立刻看懂全部代码,而是找到 sign 生成前一刻的对象

第三步:追踪 sign 赋值点

全局搜索这些关键词:

  • sign
  • timestamp
  • nonce
  • md5
  • sha
  • crypto
  • digest

如果代码混淆严重,sign: 可能搜不到,那就直接在断点处查看局部变量,逐层往上追。

第四步:确认签名原文

这是最关键的一步。

你要找的是类似这样的中间代码:

const payload = normalize(params);
const raw = serialize(payload) + secret;
const sign = md5(raw);

其中最容易忽略的是 normalizeserialize
很多人直接盯着 md5,最后复现失败,就是因为漏了这两步。


请求生命周期示意

下面这张时序图可以帮助你建立整体脑图。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant S as 签名模块
    participant N as 网络层
    participant API as 服务端接口

    U->>P: 点击查询
    P->>P: 组装原始参数
    P->>S: 传入 params
    S->>S: 排序/过滤/序列化
    S->>S: 拼接 timestamp/nonce/secret
    S->>S: 计算 sign
    S-->>P: 返回签名后的参数
    P->>N: 发起请求
    N->>API: POST /api/data/list
    API->>API: 校验 sign 与时效
    API-->>N: 返回结果
    N-->>P: 解析响应

实战代码(可运行)

下面我们自己实现一份“浏览器端签名逻辑”,再用 Node.js 复现它。
这个案例覆盖了中级逆向里最常见的几步:

  • 删除空值
  • key 排序
  • 统一序列化
  • 拼接 secret
  • MD5 生成 sign

1. 浏览器端逻辑原型

function normalizeParams(obj) {
  const result = {};
  Object.keys(obj)
    .sort()
    .forEach((key) => {
      const val = obj[key];
      if (val === undefined || val === null || val === "") return;
      result[key] = typeof val === "object" && !Array.isArray(val)
        ? JSON.stringify(sortObject(val))
        : String(val);
    });
  return result;
}

function sortObject(obj) {
  const out = {};
  Object.keys(obj)
    .sort()
    .forEach((k) => {
      const v = obj[k];
      out[k] = v && typeof v === "object" && !Array.isArray(v)
        ? sortObject(v)
        : v;
    });
  return out;
}

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

假设签名逻辑是:

sign = md5(serialize(normalizeParams(params)) + "&secret=demo_secret")

2. Node.js 复现脚本

保存为 sign_demo.js

const crypto = require("crypto");

function sortObject(obj) {
  const out = {};
  Object.keys(obj)
    .sort()
    .forEach((k) => {
      const v = obj[k];
      if (v && typeof v === "object" && !Array.isArray(v)) {
        out[k] = sortObject(v);
      } else {
        out[k] = v;
      }
    });
  return out;
}

function normalizeParams(obj) {
  const result = {};
  Object.keys(obj)
    .sort()
    .forEach((key) => {
      const val = obj[key];
      if (val === undefined || val === null || val === "") return;

      if (Array.isArray(val)) {
        result[key] = JSON.stringify(val);
      } else if (typeof val === "object") {
        result[key] = JSON.stringify(sortObject(val));
      } else {
        result[key] = String(val);
      }
    });
  return result;
}

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

function md5(text) {
  return crypto.createHash("md5").update(text).digest("hex");
}

function buildSignedParams(params, secret) {
  const normalized = normalizeParams(params);
  const raw = `${serialize(normalized)}&secret=${secret}`;
  const sign = md5(raw);
  return {
    ...params,
    sign,
  };
}

// 测试
const params = {
  page: 1,
  pageSize: 20,
  keyword: "laptop",
  timestamp: 1724810000000,
  nonce: "7f3a9c21",
  extra: {
    b: 2,
    a: 1,
  },
  emptyValue: "",
  unused: null,
};

const secret = "demo_secret";
const signed = buildSignedParams(params, secret);

console.log("原始参数:", params);
console.log("签名后参数:", signed);
console.log("签名原文:", `${serialize(normalizeParams(params))}&secret=${secret}`);

运行:

node sign_demo.js

3. 如果目标站点用了 SHA256

只需要把摘要部分替换:

function sha256(text) {
  return crypto.createHash("sha256").update(text).digest("hex");
}

4. 如果目标站点用了 HMAC

function hmacSha256(text, secret) {
  return crypto.createHmac("sha256", secret).update(text).digest("hex");
}

5. 带请求发送的完整示例

保存为 request_demo.js

const crypto = require("crypto");

function sortObject(obj) {
  const out = {};
  Object.keys(obj).sort().forEach((k) => {
    const v = obj[k];
    out[k] = v && typeof v === "object" && !Array.isArray(v) ? sortObject(v) : v;
  });
  return out;
}

function normalizeParams(obj) {
  const result = {};
  Object.keys(obj).sort().forEach((key) => {
    const val = obj[key];
    if (val === undefined || val === null || val === "") return;
    if (Array.isArray(val)) {
      result[key] = JSON.stringify(val);
    } else if (typeof val === "object") {
      result[key] = JSON.stringify(sortObject(val));
    } else {
      result[key] = String(val);
    }
  });
  return result;
}

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

function md5(text) {
  return crypto.createHash("md5").update(text).digest("hex");
}

function signParams(params, secret) {
  const normalized = normalizeParams(params);
  const raw = `${serialize(normalized)}&secret=${secret}`;
  return md5(raw);
}

async function main() {
  const secret = "demo_secret";
  const payload = {
    page: 1,
    pageSize: 20,
    keyword: "laptop",
    timestamp: Date.now(),
    nonce: Math.random().toString(16).slice(2, 10),
  };

  const sign = signParams(payload, secret);
  const finalBody = { ...payload, sign };

  console.log("发送参数:", finalBody);

  // Node 18+ 原生 fetch
  const res = await fetch("https://httpbin.org/post", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(finalBody),
  });

  const data = await res.json();
  console.log("响应:", JSON.stringify(data, null, 2));
}

main().catch(console.error);

这个脚本虽然请求的是测试站,但已经具备了完整的“签名复现”结构。
你把 normalizeParams / serialize / signParams 替换成目标逻辑即可。


如何从混淆代码里提取关键逻辑

如果站点代码经过打包、压缩、变量名混淆,可以按这条线索走:

1. 不要先反混淆全量代码

全量还原非常耗时,而且很多时候没有必要。
更实用的做法是:

  • 用断点停在请求发送前
  • 看当前作用域里的参数对象
  • 顺着调用栈只追关键几层

2. 盯住“输入”和“输出”

签名函数不管怎么混淆,本质都是:

  • 输入:一个对象 / 字符串
  • 输出:一个固定长度字符串

所以你可以在可疑函数前后打日志:

console.log("before sign input:", input);
console.log("after sign output:", output);

如果不方便改源代码,也可以在 Console 临时覆写函数。

3. 识别常见摘要特征

常见长度特征:

  • 32 位十六进制:大概率 MD5
  • 40 位十六进制:大概率 SHA1
  • 64 位十六进制:大概率 SHA256
  • Base64 结尾带 =:可能做了编码或密文输出

当然这不是绝对规则,但在定位阶段很好用。


参数还原的关键:别忽略“看起来不重要”的细节

很多复现失败都出在小细节。

下面这张图总结了参数从“业务对象”到“签名原文”的变形过程:

flowchart LR
    A[业务参数对象] --> B[删除空值]
    B --> C[键名排序]
    C --> D[嵌套对象稳定化]
    D --> E[类型转字符串]
    E --> F[序列化为查询串]
    F --> G[拼接 secret/timestamp/nonce]
    G --> H[摘要输出 sign]

典型细节清单

  • 数字 1 和字符串 "1" 是否等价?
  • 布尔值是 true 还是 "true"
  • 嵌套对象是否也排序?
  • 数组是否原样保留顺序?
  • URL 编码是在签名前还是签名后?
  • 空字符串要不要参与签名?
  • undefinednull 是否都会被过滤?

我建议你每复现一个站点,都手写一份“签名前处理规则”。
不要觉得麻烦,这能帮你快速定位误差来源。


逐步验证清单

中级读者最需要的不是“更多知识”,而是可执行验证步骤
下面这份清单很实用:

验证 1:只比较签名原文

先不要比最终 sign,先比“签名输入字符串”。

浏览器里拿到:

keyword=laptop&nonce=7f3a9c21&page=1&pageSize=20&timestamp=1724810000000&secret=demo_secret

你本地脚本也打印一份。
只要两边一字不差,后面的摘要一般就不会错。

验证 2:固定时间戳和随机数

不要每次都用实时值。
先把:

  • timestamp
  • nonce

固定住,这样结果可重复,便于对比。

验证 3:先复现单参数场景

比如只保留:

{ page: 1, timestamp: 1724810000000, nonce: "abcd1234" }

简化输入后更容易确认序列化规则。

验证 4:确认编码时机

尤其是中文、空格、特殊字符。

例如:

keyword: "测试 空格"

要确认是:

  • encodeURIComponent 再签名
  • 还是先签名再编码发送

这两种结果差别很大。


常见坑与排查

这一节基本是我自己和很多同学最常踩的坑。

坑 1:看到了 MD5,就以为结束了

其实 MD5 往往只是最后一步。
真正决定成败的是前面的参数标准化。

排查方法:

  • 打印签名原文
  • 比较排序、过滤、字符串化结果
  • 检查嵌套对象是否稳定序列化

坑 2:浏览器里是对象,请求里却是字符串

有些代码流程是:

  1. 原始对象
  2. JSON.stringify
  3. AES 加密
  4. Base64 输出到 data

这时请求里只有一个 data 字段,你却去签原始对象,肯定对不上。

排查方法:

  • 观察发送前最终 body
  • 确认签名是在加密前还是加密后
  • 看服务端校验的是明文还是密文

有些接口会把这些也混进去:

  • Authorization
  • x-device-id
  • User-Agent
  • Cookie 中的某个 token

排查方法:

  • 全局搜索 header 设置位置
  • 看签名函数参数是否不止一个
  • 在调用栈上层找上下文来源

坑 4:时间窗口限制

你本地复现逻辑完全正确,但依然报“请求过期”。
这通常说明服务端校验了时间窗口,比如 5 分钟内有效。

排查方法:

  • 同步本地时间
  • 实时生成 timestamp
  • 检查是否有服务端时间偏移逻辑

坑 5:Node 复现和浏览器结果不同

常见原因:

  • 编码实现不同
  • CryptoJS 和 Node crypto 输出格式不同
  • Unicode 处理差异
  • Base64 / Hex 大小写不一致

排查方法:

  • 固定输入字符串
  • 只比摘要函数输出
  • 明确输入编码是 UTF-8 还是其他

坑 6:数组处理方式猜错了

数组最容易“看起来一样,实际上不一样”。

例如这三种都可能存在:

tags=["a","b"]
tags=a,b
tags[0]=a&tags[1]=b

排查方法:

  • 观察数组进入签名函数前的形态
  • 打印序列化后的中间结果
  • 注意数组顺序通常不可随便排序

安全/性能最佳实践

这部分既适合逆向分析者,也适合理解接口设计本身。

1. 签名复现脚本要模块化

不要把所有逻辑写进一个函数里。
建议拆成:

  • normalizeParams
  • serialize
  • digest
  • buildRequest

这样一旦目标站点升级,只需要替换局部逻辑。


2. 做好“中间态”日志

最有价值的不是最终 sign,而是这些日志:

  • 标准化后的对象
  • 序列化后的字符串
  • 最终摘要输入
  • 摘要输出

例如:

console.log("[normalized]", normalized);
console.log("[raw]", raw);
console.log("[sign]", sign);

这能把排错速度提高很多。


3. 对高频请求做缓存,但别缓存动态签名

如果某些参数固定,可以缓存标准化结果。
但这些字段一般不能缓存过久:

  • timestamp
  • nonce
  • 一次性 token

否则服务端很可能拒绝。


4. 避免把密钥硬编码到公开仓库

如果你做的是合法测试或内部调试,也别把分析出的密钥、盐值直接提交到仓库。
至少要用环境变量:

const SECRET = process.env.APP_SECRET || "";

5. 大批量调用时控制并发

签名计算本身通常不重,但接口风控会关注:

  • 请求频率
  • 相同设备指纹
  • 固定节奏访问
  • 重复 nonce

建议:

  • 做随机抖动
  • 控制并发
  • 正确生成 nonce
  • 失败后退避重试

6. 明确边界:仅用于授权范围内的安全研究与调试

这点必须说清楚。
本文讲的是加密逻辑定位与复现方法,适用于:

  • 自家业务调试
  • 安全测试
  • 协议兼容验证
  • 教学研究

不要超出合法授权范围使用。


一个更接近真实项目的排查套路

如果你面对的是大型前端工程,比如 React/Vue + Axios 拦截器 + Webpack 打包,我建议按这个顺序排:

路线 A:从请求发起点往前追

适合你已经知道目标接口。

  • Network 找接口
  • 看 Initiator
  • 定位请求封装层
  • 找请求拦截器
  • 找 sign 注入位置

路线 B:从异常参数往前追

适合 sign 很明显。

  • 全局搜索 sign
  • 看赋值语句
  • 看传入参数
  • 看标准化函数
  • 回溯 secret 来源

路线 C:从加密库往回追

适合字段名被混淆。

  • crypto.subtle
  • createHash
  • CryptoJS
  • md5( / sha256(
  • 反查调用者

这三条路线没有绝对优先级。
我一般会先走 A,A 不顺再补 B,最后用 C 兜底。


总结

一次中级 Web 逆向里,从请求签名到参数还原,真正的核心不是“识别出用了 MD5 还是 SHA256”,而是这条完整链路:

  1. 抓到目标请求
  2. 定位请求发起位置
  3. 找到签名前的真实参数
  4. 还原排序、过滤、编码、序列化规则
  5. 识别摘要/加密算法
  6. 提取 secret、timestamp、nonce 等上下文
  7. 用独立脚本复现并逐步比对

如果你只记住一句话,我希望是这个:

先还原签名输入,再复现签名输出。

最后给你几个可执行建议:

  • 每次分析都先打印“签名原文”
  • 固定时间戳和随机数做对比实验
  • 不要一上来就全量反混淆
  • 把中间态拆出来单独验证
  • 遇到失败,优先怀疑参数标准化,而不是摘要算法

只要你把这套流程练熟,绝大多数“带签名接口”的定位难度都会从“完全没头绪”,下降到“只是需要耐心”。

如果你正在练习这类题,建议你下一次就按本文的验证清单,完整走一遍。
真正能拉开差距的,往往不是工具,而是你是否把每一步中间结果都核对清楚。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布-381》
下一篇
《自动化测试中的稳定性治理实战:从脆弱用例识别到持续反馈闭环搭建》