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

《从抓包到算法还原:一次典型 Web 逆向中请求签名参数的定位、分析与复现实战》

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

从抓包到算法还原:一次典型 Web 逆向中请求签名参数的定位、分析与复现实战

在做 Web 逆向时,最常见、也最让人“卡壳”的点之一,就是接口明明找到了,参数也看着差不多,但一发请求就报错:sign invalidsignature errorillegal request

这类问题通常不是“接口没找对”,而是请求签名参数没有还原出来

这篇文章我想按一次典型实战的节奏,带你完整走一遍:

  • 怎么从抓包里确认“到底是谁在校验”
  • 怎么从前端代码里定位签名生成逻辑
  • 怎么判断它是明文拼接、哈希、加密,还是混淆后的组合逻辑
  • 怎么在本地把它跑通,最终完成复现

为了让过程更贴近真实场景,下面我会用一个通用但可运行的示例来演示。重点不是某个站点细节,而是这套定位思路和还原方法


背景与问题

很多 Web 接口都有类似这样的请求:

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

{
  "page": 1,
  "size": 20,
  "keyword": "phone",
  "ts": 1700000000000,
  "sign": "9f1a7b..."
}

看起来业务参数很普通,但只要 sign 不对,服务端就直接拒绝。

我第一次处理这类问题时,最容易犯的错有两个:

  1. 只盯着请求体,不看请求头和 cookie
  2. 拿到 sign 就直接猜算法,没先确认依赖项

结果就是浪费大量时间在错误方向上。

一个更靠谱的判断标准

当你看到以下任一特征时,基本可以推断接口有签名校验:

  • 请求参数中出现 signsignaturetokenauthx-sx-sign
  • 请求头里有自定义校验字段,如 X-SignatureX-Timestamp
  • 参数中带时间戳、随机数、nonce、设备指纹
  • 相同业务参数下,某些字段每次都变
  • 浏览器正常返回,脚本请求却 401/403/参数非法

前置知识

开始前,建议你至少熟悉这些内容:

  • Chrome DevTools / 抓包工具的基本使用
  • JavaScript 基础语法
  • 常见摘要算法:MD5、SHA1、SHA256
  • Node.js 基本运行方式
  • 浏览器端请求链路:页面事件 → JS 组参 → 发请求

如果你对 AST、webpack、混淆还不熟,也没关系。这篇先聚焦在**“定位 + 验证 + 复现”**这条主线。


环境准备

本文示例使用:

  • Chrome DevTools
  • Node.js 18+
  • 一个文本编辑器
  • 可选:Charles / Fiddler / mitmproxy

安装 Node 后,初始化一个目录:

mkdir web-sign-demo
cd web-sign-demo
npm init -y

如果你要做哈希验证,Node 自带 crypto 就够了,不必额外装包。


核心原理

请求签名本质上是在做一件事:

把请求中的关键数据按某种规则处理后,生成一个可校验的摘要值,服务端用同样规则重算并比对。

常见签名流程一般长这样:

flowchart LR
  A[业务参数] --> B[参数标准化]
  B --> C[拼接固定字段]
  C --> D[加入时间戳/nonce/secret]
  D --> E[哈希或加密]
  E --> F[得到 sign]
  F --> G[随请求发送]

这里最关键的不是“它用了 MD5 还是 SHA256”,而是前面的标准化规则

  • 参数是否排序
  • 空值是否参与
  • 数字和字符串是否做类型转换
  • 是否 URL 编码
  • 是否只取部分字段
  • 是否包含请求路径、请求方法、请求头、cookie

很多时候算法本身不复杂,真正难的是这些“规则细节”。

一个典型签名模型

以最常见的一类前端签名为例:

  1. 取请求参数对象
  2. 按 key 升序排序
  3. 拼接成 key=value&key2=value2
  4. 追加时间戳与固定 secret
  5. 做一次 MD5 / SHA256
  6. 输出十六进制字符串

例如:

keyword=phone&page=1&size=20&ts=1700000000000&secret=demo_secret

然后:

sha256(上面的字符串)

服务端再按同样步骤计算一次。


抓包定位:先确认签名存在与作用范围

别急着进源码。第一步一定是抓包观察变量关系

观察点 1:哪些字段是动态变化的

打开浏览器 DevTools,进入 Network,筛选目标接口,重点看:

  • Query String Parameters
  • Request Payload
  • Headers
  • Cookie

你要做的不是只看“有哪些字段”,而是看:

  • 同样操作重复请求时,哪些字段会变化
  • 哪些字段变化但响应仍正常
  • 哪些字段一改就报错

观察点 2:最小改动实验

一个实用技巧是做“单变量破坏”:

  • 保持所有参数不变
  • 只改 sign
  • 看是否立刻报签名错误
  • 只改 ts
  • 看报错是“时间过期”还是“签名错误”
  • 只改业务参数
  • 看是否会影响签名验证

如果业务参数一改,sign 不变就报错,基本说明:

sign 依赖业务参数

如果只改 ts 就报错,说明:

时间戳参与签名,或者服务端还做了时效校验

请求交互关系图

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

  U->>J: 触发查询
  J->>J: 组装参数 page/size/keyword/ts
  J->>J: 计算 sign
  J->>S: 发送请求(参数+sign)
  S->>S: 按同规则校验签名
  alt 签名正确
    S-->>J: 返回业务数据
  else 签名错误/时间戳失效
    S-->>J: 401/403/参数非法
  end

从源码里找 sign:定位方法比盲猜更重要

确认有签名后,下一步才是看前端代码。

方法一:全局搜索关键字

优先搜这些关键词:

  • sign
  • signature
  • md5
  • sha1
  • sha256
  • crypto
  • timestamp
  • nonce
  • token

如果站点没混淆,很多时候直接就能搜到类似:

params.sign = getSign(params)

方法二:从请求发起点反推

如果全局搜索没结果,就从请求入口追:

  • 搜接口路径,如 /api/data/list
  • 找到调用它的 fetch / axios / XMLHttpRequest
  • 看请求发出前,参数在哪里被加工

典型链路经常是:

api.list(data) 
request.post(url, data)
requestInterceptor(config)
buildCommonParams()
genSign(payload)

方法三:断点比搜索更有效

对于混淆站点,我更推荐直接断点:

  1. 在 Network 中选中请求
  2. 右键 Replay XHR 或重新触发一次
  3. 在 Sources 中对 fetchXHR.sendaxios interceptor 下断点
  4. 单步看请求参数何时被补上 sign

有时候源码里变量名全是 _0x1a2b3c,靠搜关键字几乎没法看;但断点一停,作用域里有哪些变量,立刻就清楚了。


识别算法:别一上来就“这是 AES 吧”

找到签名生成函数后,先判断它属于哪一类。

常见类型

1. 明文拼接 + 哈希

最常见,也最容易复现。

特征:

  • 出现 sort()join('&')
  • 调用 md5()sha256()
  • 输出固定长度十六进制字符串

2. HMAC

特征:

  • 用到 secret key
  • 调用形态像 createHmac('sha256', key)
  • 输出 hex/base64

3. 对称加密后再编码

特征:

  • 出现 AESCBCECB
  • ivkey
  • 最终结果是 base64 或长密文

4. 混合逻辑

比如:

  • 参数排序后拼接
  • 取某几个字段
  • 加盐
  • 中间做一次编码
  • 再 hash

一个实战里最重要的判断

签名字段是否“可逆”不重要,重要的是“生成过程是否可复现”。

大多数情况下,签名不是要“解密”,而是要重跑同样逻辑


示例目标:还原一个典型签名算法

下面我们构造一个常见前端逻辑,模拟真实逆向场景:

前端源码大致如下:

function normalize(obj) {
  return Object.keys(obj)
    .filter(k => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
    .sort()
    .map(k => `${k}=${String(obj[k])}`)
    .join('&');
}

function getSign(data, secret) {
  const base = normalize(data) + `&secret=${secret}`;
  return sha256(base);
}

请求时:

const payload = {
  page: 1,
  size: 20,
  keyword: 'phone',
  ts: Date.now()
};

payload.sign = getSign(payload, 'demo_secret');

我们现在要做的,就是在本地完整复现这一过程。


实战代码(可运行)

下面给出一套可直接运行的 Node.js 代码。

1)签名生成脚本

新建 sign.js

const crypto = require('crypto');

function normalize(obj) {
  return Object.keys(obj)
    .filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
    .sort()
    .map((k) => `${k}=${String(obj[k])}`)
    .join('&');
}

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

function getSign(data, secret) {
  const base = `${normalize(data)}&secret=${secret}`;
  return sha256(base);
}

function buildPayload(input = {}) {
  const payload = {
    page: input.page ?? 1,
    size: input.size ?? 20,
    keyword: input.keyword ?? 'phone',
    ts: input.ts ?? Date.now(),
  };

  payload.sign = getSign(payload, 'demo_secret');
  return payload;
}

if (require.main === module) {
  const payload = buildPayload({
    page: 1,
    size: 20,
    keyword: 'phone',
  });

  console.log('payload =');
  console.log(JSON.stringify(payload, null, 2));
}

module.exports = {
  normalize,
  getSign,
  buildPayload,
};

运行:

node sign.js

你会得到类似输出:

{
  "page": 1,
  "size": 20,
  "keyword": "phone",
  "ts": 1700000000000,
  "sign": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

2)模拟服务端校验

新建 server.js

const http = require('http');
const { getSign } = require('./sign');

const SECRET = 'demo_secret';

function send(res, code, data) {
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(data));
}

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/api/data/list') {
    let body = '';

    req.on('data', (chunk) => {
      body += chunk;
    });

    req.on('end', () => {
      try {
        const payload = JSON.parse(body || '{}');
        const { sign, ...data } = payload;

        if (!sign) {
          return send(res, 400, { ok: false, msg: 'missing sign' });
        }

        const expected = getSign(data, SECRET);

        if (expected !== sign) {
          return send(res, 403, {
            ok: false,
            msg: 'invalid sign',
            expected,
            got: sign,
          });
        }

        const now = Date.now();
        if (Math.abs(now - Number(data.ts)) > 5 * 60 * 1000) {
          return send(res, 403, {
            ok: false,
            msg: 'timestamp expired',
          });
        }

        return send(res, 200, {
          ok: true,
          msg: 'success',
          data: {
            list: [
              { id: 1, title: 'phone A' },
              { id: 2, title: 'phone B' },
            ],
          },
        });
      } catch (err) {
        return send(res, 500, { ok: false, msg: err.message });
      }
    });

    return;
  }

  send(res, 404, { ok: false, msg: 'not found' });
});

server.listen(3000, () => {
  console.log('server running at http://127.0.0.1:3000');
});

运行:

node server.js

3)客户端请求验证

新建 client.js

const { buildPayload } = require('./sign');

async function main() {
  const payload = buildPayload({
    page: 1,
    size: 20,
    keyword: 'phone',
  });

  const res = await fetch('http://127.0.0.1:3000/api/data/list', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  const data = await res.json();
  console.log(data);
}

main().catch(console.error);

运行:

node client.js

正常会返回:

{
  "ok": true,
  "msg": "success",
  "data": {
    "list": [
      { "id": 1, "title": "phone A" },
      { "id": 2, "title": "phone B" }
    ]
  }
}

逐步验证清单

实际逆向时,我建议不要一步到位写完整脚本,而是按下面顺序一项项验证。

第一步:固定原始参数

先把浏览器里成功请求的参数抄下来,尤其是:

  • page / size / keyword
  • ts
  • sign
  • headers 中可能参与签名的字段

第二步:只复现明文拼接串

不要急着算 hash,先把待签名字符串打出来。

例如:

console.log(normalize(data) + '&secret=demo_secret');

然后和浏览器中的输入值逐项对比:

  • key 顺序对不对
  • 值有没有变成字符串
  • 是否漏掉空字段
  • 是否多带了 sign 本身

第三步:验证 hash 结果

确保输入串一致后,再验证摘要结果是否一致。

第四步:放进真实请求中试跑

sign 和浏览器一致时,再放进真实请求里验证。


一个非常关键的排查思路:先还原“输入”,再还原“算法”

很多人会陷入这样的误区:

“我知道是 SHA256,为什么结果还是不一样?”

问题往往不在 SHA256,而在输入串不同

请记住这句话:

逆向签名时,90% 的错误来自输入不一致,而不是算法不一致。

常见输入差异包括:

  • 参数顺序不同
  • 数字被转成字符串/没转
  • null、空串是否参与
  • URL 编码时机不同
  • 时间戳位数不同(秒/毫秒)
  • 布尔值被转成 true/false 还是 1/0

常见坑与排查

这一节我尽量讲得“接地气”一点,因为这些坑我基本都踩过。

坑 1:把 sign 自己也参与签名了

错误示例:

payload.sign = getSign(payload, secret);

如果 getSign() 内部没有排除 sign 字段,而你复用旧对象,第二次计算时就会把旧 sign 带进去,结果永远不一致。

排查方法:

  • 打印参与签名的最终对象
  • 显式剔除 sign
const { sign, ...data } = payload;

坑 2:排序规则和你想的不一样

有的站点不是简单 Object.keys(obj).sort(),而是:

  • 只对特定字段排序
  • 按字典序但区分大小写
  • 按接口文档指定顺序
  • 对嵌套对象做递归展开后再排序

如果你的排序错了,hash 结果一定错。


坑 3:时间戳精度不一致

前端常见:

  • Date.now():毫秒,13 位
  • Math.floor(Date.now() / 1000):秒,10 位

如果站点既做签名,又做过期判断,哪怕算法对了,时间戳精度错也会失败。


坑 4:浏览器环境依赖没补齐

有些签名函数依赖:

  • window
  • document
  • navigator
  • location
  • canvas
  • localStorage

你把函数抠出来在 Node 跑,结果直接报错。

排查建议:

  • 先断点确认签名函数真正依赖哪些浏览器对象
  • 如果依赖少,手工 mock
  • 如果依赖重,考虑在浏览器控制台直接运行,或用 jsdom / 补环境方案

坑 5:算法前还有一层编码处理

例如:

  • encodeURIComponent
  • Base64
  • UTF-8/Latin1
  • 自定义字符替换

最典型现象是:你看起来“字符串一样”,但 hash 就是不一样。
这时要重点查编码。


坑 6:webpack 打包代码读不动

这是很常见的情况。不要一上来硬啃整包代码。

更高效的办法:

  1. 找请求入口
  2. 下断点
  3. 单步进入生成 sign 的函数
  4. 在 Console 里直接打印中间变量

比纯静态看包,效率高很多。


还原流程图:从抓包到复现

flowchart TD
  A[抓包锁定目标请求] --> B[识别动态字段 sign/ts/nonce]
  B --> C[最小改动实验确认依赖关系]
  C --> D[从接口路径或请求入口追源码]
  D --> E[断点定位签名生成函数]
  E --> F[提取参数标准化规则]
  F --> G[确认哈希/加密方式]
  G --> H[本地复现签名]
  H --> I[用真实请求验证]
  I --> J[排查边界条件与环境依赖]

安全/性能最佳实践

这里既包括逆向分析时的实践,也包括如果你是接口开发者,应该怎么设计得更稳。

对分析者来说

1. 优先做“中间结果对齐”

不要只比最终 sign,要比:

  • 原始参数对象
  • 排序后的 key 列表
  • 待签名字符串
  • 最终摘要值

这样定位最快。

2. 保留每次实验记录

建议你把每次测试记录成表格:

项目浏览器值本地值是否一致
ts17000000000001700000000000
参数顺序keyword,page,size,tskeyword,page,size,ts
base string

很多时候,你不是不会,而是比对粒度太粗。

3. 少改、多验证

每次只改一个变量,能极大缩小问题范围。


对接口设计者来说

1. 不要把前端签名当成真正安全边界

这是非常重要的一点。

前端签名的 secret 如果硬编码在 JS 中,本质上是可以被提取的。
它的作用更多是:

  • 增加滥用成本
  • 过滤低质量脚本
  • 做基础风控配合

不能替代服务端鉴权

2. 避免过重签名逻辑阻塞主线程

如果每次请求都要在前端做复杂加密、设备指纹采集、大量序列化,页面交互会明显变卡。

可以考虑:

  • 简化签名输入
  • 使用 Web Worker
  • 合理缓存稳定字段
  • 避免重复计算

3. 加入 nonce 与时效控制

仅靠固定参数签名,容易被重放。更稳妥的做法是加入:

  • 时间戳
  • nonce
  • 会话绑定字段
  • 服务端一次性校验策略

进阶判断:什么时候不是“签名问题”?

有时你把 sign 复现出来了,接口还是失败。别急着怀疑人生,可能问题不在签名。

常见非签名因素:

  • cookie 缺失
  • CSRF token 缺失
  • 请求头顺序/来源校验
  • referer / origin 校验
  • 人机验证
  • 设备指纹
  • IP 风控
  • 接口权限不足

一个经验判断是:

  • 如果报错明确是 invalid sign,优先看签名
  • 如果报错是 unauthorizedforbiddenrisk control,要扩大排查范围

一套我常用的实战策略

如果你想把这类问题做得更稳,我建议按这个顺序来:

  1. 抓包确认动态字段
  2. 做单变量破坏实验
  3. 从请求入口反推到签名函数
  4. 断点打印中间字符串
  5. 先对齐输入串,再对齐摘要
  6. 最后再移植到 Node/Python 中复现

为什么强调这个顺序?

因为它能避免一开始就陷入“猜算法”。
真实项目里,算法反而往往是最不难的一环,难的是那些隐藏在流程里的细节。


总结

从抓包到算法还原,请求签名复现这件事,核心其实可以浓缩成一句话:

先锁定参与签名的数据和规则,再复现算法本身。

你可以把本文的方法记成一个简单框架:

  • 抓包看变化
  • 实验找依赖
  • 断点定位置
  • 打印中间值
  • 本地复现
  • 真实验证

如果最终结果对不上,优先排查这几项:

  1. 参数排序是否一致
  2. 空值与类型转换是否一致
  3. 时间戳位数是否一致
  4. 是否误把 sign 本身带入计算
  5. 是否存在编码或浏览器环境依赖

最后再强调一个边界条件:

前端签名可以被还原,但并不代表所有接口问题都能靠“算出 sign”解决。真实场景中,服务端通常还会叠加 cookie、token、风控、人机校验等多层策略。所以当你完成签名复现后,仍要结合整个请求链路来判断问题归因。

如果你正在做自己的第一个 Web 签名逆向,我建议不要追求“一次搞定复杂站点”,而是先把本文这种标准流程练熟。流程稳定了,面对不同站点只是细节变化,思路不会乱。


分享到:

上一篇
《Java Web开发实战:基于Spring Boot与JWT实现中后台系统的登录鉴权与权限控制》
下一篇
《AI Agent 在企业知识库中的落地实践:从 RAG 检索增强到权限控制与效果评估》