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

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

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

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

很多中级开发者第一次接触 Web 逆向时,都会有一种“明明请求我都看见了,但就是复现不出来”的挫败感。

接口地址、请求头、请求体都抄下来了,浏览器里请求也成功了,可一旦换成脚本,就返回:

  • sign invalid
  • timestamp expired
  • request forbidden
  • illegal token

问题往往不在“接口没找到”,而在于签名参数没有还原
这篇文章我不走空泛路线,而是带你从一个中级开发者能真正上手的角度,把完整流程走一遍:

  1. 抓包定位关键请求
  2. 判断签名字段
  3. 追踪 JavaScript 代码
  4. 还原算法
  5. 用脚本自动化复现
  6. 排查常见失败原因

说明:本文讨论的是授权测试、接口联调、兼容性分析、内部调试等合法场景下的技术方法。不要将其用于未授权目标。


背景与问题

现代 Web 应用为了防止接口被随意调用,通常会在前端生成一组动态参数,例如:

  • sign
  • signature
  • token
  • ts
  • nonce
  • x-s
  • x-sign
  • encryptData

这些参数的生成可能依赖:

  • 时间戳
  • 随机数
  • 请求路径
  • 请求体字段排序
  • cookie/localStorage 中的值
  • 浏览器环境指纹
  • 某段混淆后的 JS 算法

所以实际问题不是“怎么发请求”,而是:

怎么找到签名生成逻辑,并把它稳定地在脚本里复现出来。

这类问题最典型的难点有三个:

  1. 参数来源分散:请求头、cookie、body、内存变量混着来
  2. 代码被混淆:函数名不可读、逻辑碎片化
  3. 环境耦合:浏览器有 window/document/navigator,Node/Python 没有

前置知识与环境准备

建议你具备以下基础:

  • 会用 Chrome DevTools 抓包
  • 能读懂基础 JavaScript
  • 知道 MD5 / SHA1 / SHA256 / HMAC 的基本区别
  • 会用 Python 或 Node.js 发 HTTP 请求

本文示例环境:

  • Chrome DevTools
  • Node.js 18+
  • Python 3.10+
  • 可选:mitmproxy / Charles / Fiddler

安装依赖:

npm install crypto-js axios
pip install requests

逐步验证清单

在真正写自动化脚本前,我建议你按下面这个清单一项项过:

  • 请求路径、方法、Query 参数完全一致
  • 请求头是否缺少关键字段
  • cookie 是否有效
  • 时间戳单位是秒还是毫秒
  • 签名前的参数是否排序
  • JSON 序列化是否和浏览器一致
  • 是否参与签名的字段比你想象得多
  • 签名是否依赖 localStorage/sessionStorage
  • 是否依赖浏览器环境值
  • 签名结果是否做了二次编码(hex/base64/urlencode)

这个清单看起来基础,但我自己踩坑时,80% 的问题都卡在这里。


核心原理

先把核心思路说透:签名参数本质上是“服务端与前端约定的一种可验证计算结果”

一个常见签名过程可能是这样:

  1. 收集参与签名的字段
  2. 按固定顺序拼接
  3. 拼上一个 secret 或盐值
  4. 做哈希运算
  5. 输出 hex 或 base64
  6. 放入 header 或 query 中

例如:

sign = md5(path + ts + nonce + body + secret)

或者:

sign = sha256(sortedQuery + "|" + token + "|" + ts)

再复杂一点,会有:

  • AES 加密后再做摘要
  • HMAC 替代普通哈希
  • 先 JSON 序列化再编码
  • 参数名参与拼接
  • 只取哈希结果的一部分
  • 字符串反转、异或、字符表替换

一个典型分析流程

flowchart TD
    A[抓包定位目标请求] --> B[识别可疑签名字段]
    B --> C[全局搜索字段名]
    C --> D[定位生成函数]
    D --> E[梳理输入参数来源]
    E --> F[还原拼接规则/加密算法]
    F --> G[在 Node/Python 中复现]
    G --> H[与浏览器结果对比验证]

请求时序关系

sequenceDiagram
    participant U as 用户操作
    participant B as 浏览器前端
    participant J as 签名JS逻辑
    participant S as 服务端

    U->>B: 触发页面请求
    B->>J: 收集 path/body/ts/token
    J->>J: 生成 sign
    J->>S: 发送带 sign 的请求
    S->>S: 按同规则验签
    S-->>B: 返回数据

背景示例:构造一个可分析的签名接口

为了让过程完整,这里我构造一个常见但不过分简单的签名规则:

  • 请求路径:/api/v1/list
  • 请求方法:POST
  • body:
{
  "page": 1,
  "size": 20,
  "keyword": "phone"
}
  • header 中有:
    • x-ts: 当前毫秒时间戳
    • x-nonce: 随机字符串
    • x-sign: 签名值

签名规则假设为:

raw = method.toUpperCase() + "\n" +
      path + "\n" +
      canonical_json(body) + "\n" +
      ts + "\n" +
      nonce + "\n" +
      secret

x-sign = sha256(raw).hex()

这里最关键的是 canonical_json(body),也就是稳定 JSON 序列化
很多人复现失败,不是哈希算法错了,而是序列化结果不同。


实战代码(可运行)

下面用一个完整可运行的例子来演示算法复现。

第一步:在前端中识别签名逻辑

假设你在 DevTools 的 Network 面板里发现请求头中有:

x-ts: 1720000000123
x-nonce: ab12cd34
x-sign: 9d0f...

此时可以做几件事:

  1. Sources 全局搜索 x-sign
  2. 搜索 sha256 / md5 / CryptoJS
  3. 搜索请求发起位置,比如 fetch(/api/v1/list)
  4. 在 XHR/Fetch Breakpoints 中对接口路径下断点

很多时候你会看到类似代码:

function buildSign(method, path, body, ts, nonce) {
  const secret = "demo_secret_2024";
  const raw =
    method.toUpperCase() + "\n" +
    path + "\n" +
    stableStringify(body) + "\n" +
    ts + "\n" +
    nonce + "\n" +
    secret;
  return sha256(raw);
}

如果源码没这么友好,而是混淆后的:

function _0x12ab(a,b,c,d,e){return _0x9f8e(a+'\n'+b+'\n'+_0x8c7d(c)+'\n'+d+'\n'+e+'\n'+_0x4b11);}

也别慌,拆出来仍然是同一件事:拼接字符串 + 摘要算法


第二步:还原稳定 JSON 序列化

这里先用 Node.js 写一个“稳定序列化”函数,保证对象 key 按字典序输出。

function stableStringify(value) {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value);
  }

  if (Array.isArray(value)) {
    return '[' + value.map(stableStringify).join(',') + ']';
  }

  const keys = Object.keys(value).sort();
  const items = keys.map(key => {
    return JSON.stringify(key) + ':' + stableStringify(value[key]);
  });
  return '{' + items.join(',') + '}';
}

为什么这一步重要?

因为浏览器里可能签的是:

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

而你脚本里传出去的是:

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

语义一样,签名却完全不同。


第三步:Node.js 复现签名算法

const crypto = require('crypto');
const axios = require('axios');

function stableStringify(value) {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value);
  }

  if (Array.isArray(value)) {
    return '[' + value.map(stableStringify).join(',') + ']';
  }

  const keys = Object.keys(value).sort();
  const items = keys.map(key => {
    return JSON.stringify(key) + ':' + stableStringify(value[key]);
  });
  return '{' + items.join(',') + '}';
}

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

function randomNonce(length = 8) {
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += chars[Math.floor(Math.random() * chars.length)];
  }
  return result;
}

function buildSign({ method, path, body, ts, nonce, secret }) {
  const raw = [
    method.toUpperCase(),
    path,
    stableStringify(body),
    String(ts),
    nonce,
    secret
  ].join('\n');

  return sha256Hex(raw);
}

async function main() {
  const method = 'POST';
  const path = '/api/v1/list';
  const body = {
    page: 1,
    size: 20,
    keyword: 'phone'
  };

  const ts = Date.now();
  const nonce = randomNonce();
  const secret = 'demo_secret_2024';

  const sign = buildSign({
    method,
    path,
    body,
    ts,
    nonce,
    secret
  });

  console.log('ts =', ts);
  console.log('nonce =', nonce);
  console.log('sign =', sign);

  const url = 'https://example.com' + path;

  try {
    const resp = await axios.post(url, body, {
      headers: {
        'content-type': 'application/json',
        'x-ts': String(ts),
        'x-nonce': nonce,
        'x-sign': sign
      },
      timeout: 10000
    });

    console.log(resp.data);
  } catch (err) {
    if (err.response) {
      console.log('status:', err.response.status);
      console.log('data:', err.response.data);
    } else {
      console.error(err.message);
    }
  }
}

main();

第四步:Python 自动化复现版本

如果你的自动化链路主要在 Python,也可以直接搬过去:

import json
import time
import random
import string
import hashlib
import requests


def stable_dumps(value):
    if value is None or not isinstance(value, (dict, list)):
        return json.dumps(value, ensure_ascii=False, separators=(',', ':'))

    if isinstance(value, list):
        return '[' + ','.join(stable_dumps(item) for item in value) + ']'

    items = []
    for key in sorted(value.keys()):
        k = json.dumps(key, ensure_ascii=False, separators=(',', ':'))
        v = stable_dumps(value[key])
        items.append(f'{k}:{v}')
    return '{' + ','.join(items) + '}'


def sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode('utf-8')).hexdigest()


def random_nonce(length=8):
    chars = string.ascii_lowercase + string.digits
    return ''.join(random.choice(chars) for _ in range(length))


def build_sign(method, path, body, ts, nonce, secret):
    raw = '\n'.join([
        method.upper(),
        path,
        stable_dumps(body),
        str(ts),
        nonce,
        secret
    ])
    return sha256_hex(raw)


def main():
    method = 'POST'
    path = '/api/v1/list'
    url = 'https://example.com' + path
    body = {
        'page': 1,
        'size': 20,
        'keyword': 'phone'
    }

    ts = int(time.time() * 1000)
    nonce = random_nonce()
    secret = 'demo_secret_2024'
    sign = build_sign(method, path, body, ts, nonce, secret)

    headers = {
        'content-type': 'application/json',
        'x-ts': str(ts),
        'x-nonce': nonce,
        'x-sign': sign
    }

    print('ts =', ts)
    print('nonce =', nonce)
    print('sign =', sign)

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


if __name__ == '__main__':
    main()

第五步:怎么验证你还原得对不对

真正高效的做法,不是直接怼接口,而是先做本地对照验证

验证方法 1:浏览器 Console 里打印原始拼接串

如果能改写前端代码或在断点处执行表达式,可以打印:

console.log(raw);
console.log(sign);

然后和你 Node/Python 中生成的值比对。

验证方法 2:固定输入做单元测试

把时间戳和 nonce 固定住,避免动态值干扰:

const sample = {
  method: 'POST',
  path: '/api/v1/list',
  body: { page: 1, size: 20, keyword: 'phone' },
  ts: 1720000000123,
  nonce: 'ab12cd34',
  secret: 'demo_secret_2024'
};

这样浏览器和脚本应该输出完全相同的签名。

验证方法 3:比较“签名前字符串”,而不是只比较 sign

这是我最推荐的一步。
因为如果最终 sign 不一致,原因可能有十几个;但如果 raw 不一致,排查范围就瞬间缩小。


算法还原时的拆解方法

遇到真实场景,签名逻辑一般不会像示例这么整洁。这时建议按下面的路径拆。

1. 先找“出口”

也就是最终请求发出去的地方:

  • fetch
  • XMLHttpRequest.send
  • axios.interceptors.request.use

因为所有签名参数最终都要在这里汇合。

2. 再找“入口”

签名函数的输入通常来自:

  • location.pathname
  • 请求 body
  • cookie
  • localStorage
  • 时间函数 Date.now()
  • 随机函数 Math.random()

3. 最后找“变换过程”

常见变换包括:

  • 排序
  • 编码
  • JSON 序列化
  • 哈希
  • 加密
  • 截断
  • 拼接固定盐值

典型组件关系图

classDiagram
    class RequestContext {
      +method
      +path
      +query
      +body
      +headers
    }

    class ParamCollector {
      +getTimestamp()
      +getNonce()
      +getToken()
    }

    class Canonicalizer {
      +sortKeys()
      +stableStringify()
      +normalizePath()
    }

    class SignEngine {
      +buildRaw()
      +hash()
    }

    RequestContext --> Canonicalizer
    ParamCollector --> SignEngine
    Canonicalizer --> SignEngine

常见坑与排查

这部分非常重要。很多时候不是不会写代码,而是漏了一个看似不起眼的细节。

1. 时间戳单位错了

常见有两种:

  • 秒:1720000000
  • 毫秒:1720000000123

排查方法:

console.log(String(ts).length);
  • 10 位通常是秒
  • 13 位通常是毫秒

有些接口还要求:

  • 时间不能偏差超过 5 秒
  • 必须使用服务端下发的时间偏移量

2. body 参与签名,但你传的是另一份数据

例如签名使用的是:

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

但你实际发送时用了 requests.post(..., data=...) 或 form 格式,导致服务端收到的内容不一样。

排查建议:

  • 确认 Content-Type
  • 确认发送的是 json 还是 form
  • 抓包对比真实出站请求

3. JSON 序列化不一致

几个典型差异:

  • key 顺序不同
  • 空格不同
  • 中文是否转义
  • 布尔值/空值格式
  • 浮点数表示不同

例如 Python 默认 json.dumps 可能会输出空格,影响拼接结果,所以要显式加:

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

有些签名函数里会读:

localStorage.getItem('token')
document.cookie

你如果只复制了 header,没有同步这些上下文,签名一定不对。

排查方法:

  • 在签名函数附近下断点
  • Watch 关键变量
  • 查调用栈看参数源头

5. 算法对路径做了标准化

例如实际签名用的不是完整 URL,而是:

  • 只要 path,不要域名
  • query 要排序
  • path 末尾斜杠要去掉
  • URL 编码前后不一致

错误示例:

https://example.com/api/v1/list?page=1

正确参与签名的可能是:

/api/v1/list

或:

/api/v1/list?page=1&size=20

6. 签名结果做了二次处理

你以为是 SHA256 hex,实际上可能是:

  • base64(sha256(raw))
  • md5(raw).toUpperCase()
  • sha1(raw).substr(8, 16)
  • encodeURIComponent(base64(...))

排查时不要只盯着哈希函数本身,要看返回值离开函数前有没有再加工


7. 混淆代码里“看起来像无关代码”的地方其实很关键

我曾经踩过一个坑:看着像无意义的函数,实际上是在做 key 排序和 unicode 归一化。删掉后,签名全错。

经验建议:

  • 不要急着“简化”
  • 先逐行验证输入输出
  • 每删一层包裹函数,都要比对中间结果

安全/性能最佳实践

即使是做逆向分析,也建议把工程质量拉起来,不然脚本很脆。

1. 把签名逻辑做成纯函数

推荐这种形式:

function buildSign(ctx) {
  // 输入固定,输出唯一
}

好处:

  • 便于测试
  • 便于迁移到不同运行时
  • 便于定位问题

2. 固定测试样例,保存“黄金数据”

例如:

{
  "method": "POST",
  "path": "/api/v1/list",
  "body": {"page":1,"size":20,"keyword":"phone"},
  "ts": 1720000000123,
  "nonce": "ab12cd34",
  "sign": "预期结果"
}

每次改代码都跑一下,防止自己“优化”出 bug。


3. 尽量分离“算法复现”和“请求发送”

不要把逻辑都塞进一个脚本里。可以拆成:

  • sign.js / sign.py
  • client.js / client.py
  • tests/

这样后面接口变更时,只改一处。


4. 对动态环境依赖做适配层

如果签名代码依赖 windowdocumentnavigator,建议做一层 shim:

global.window = {};
global.navigator = { userAgent: 'Mozilla/5.0' };

更复杂的场景可以用:

  • jsdom
  • vm2
  • 直接在浏览器环境里执行 Playwright/Puppeteer 脚本

边界条件是:
如果算法深度依赖浏览器原生行为、Canvas、WebGL、指纹收集,纯 Node 复现成本会明显升高,这时更适合“借用浏览器执行环境”而不是硬抠。


5. 控制请求频率,避免无意义重试

签名失败时,有些人第一反应是疯狂重试。
这通常只会让问题更难看。

建议:

  • 单次请求前先本地打印 raw 和 sign
  • 对 401/403 分类处理
  • 加指数退避
  • 记录 request-id、时间戳、签名原文摘要

6. 妥善处理敏感信息

在调试输出中,不要直接打印:

  • 完整 cookie
  • token
  • secret
  • 用户隐私数据

推荐只打掩码日志:

function mask(text, keep = 4) {
  if (!text || text.length <= keep * 2) return text;
  return text.slice(0, keep) + '****' + text.slice(-keep);
}

从“能跑”到“稳定跑”:建议的工程化结构

对于中级开发者,我建议不要停留在 demo 层,而是直接按下面思路组织:

project/
  ├─ signer/
  │   ├─ canonicalize.js
  │   ├─ hash.js
  │   └─ build-sign.js
  ├─ client/
  │   └─ request.js
  ├─ fixtures/
  │   └─ sign-case.json
  └─ tests/
      └─ sign.test.js

这样你后续遇到接口升级时,只需要回答三个问题:

  1. 输入参数有没有变
  2. 拼接顺序有没有变
  3. 摘要/编码方式有没有变

而不是每次重新抓瞎。


一个最小可用的调试模板

如果你现在手上就有一个签名接口要分析,可以先套这个模板:

function debugSignPipeline(ctx) {
  const canonicalBody = stableStringify(ctx.body);
  const raw = [
    ctx.method.toUpperCase(),
    ctx.path,
    canonicalBody,
    String(ctx.ts),
    ctx.nonce,
    ctx.secret
  ].join('\n');

  const sign = sha256Hex(raw);

  console.log('[debug] canonicalBody =', canonicalBody);
  console.log('[debug] raw =', raw);
  console.log('[debug] sign =', sign);

  return sign;
}

这段代码的价值不在“高级”,而在于它能让你快速回答:

  • 到底是哪一步不一致?
  • 是 body 问题,还是 path 问题?
  • 是 raw 不一致,还是 hash 不一致?

什么时候该换思路,而不是死磕算法还原

有些场景下,继续做纯算法还原性价比很低:

  • 签名逻辑严重依赖浏览器指纹
  • JS 动态下发、频繁变动
  • WebAssembly 参与计算
  • 关键逻辑在 native bridge 或插件中
  • 服务端还做了设备态校验

这时更可行的方案通常是:

  1. 浏览器内执行:Puppeteer/Playwright 注入调用现成函数
  2. Hook 请求层:在页面环境中拦截并复用真实签名结果
  3. 半自动方案:把最难复现的部分留在浏览器中,其余逻辑脚本化

也就是说,别把“纯净还原”当成唯一目标。
工程上,稳定可维护往往比“理论最优雅”更重要。


总结

把 Web 逆向中的签名参数分析做明白,核心不是背多少加密算法,而是掌握一套稳定流程:

  1. 抓包定位目标请求
  2. 识别签名字段和依赖参数
  3. 全局搜索并定位生成函数
  4. 拆清楚输入、拼接、编码、哈希过程
  5. 先对比原始拼接串,再对比最终签名
  6. 最后再做自动化复现和工程化封装

如果你记不住全部细节,至少记住一句最有用的话:

签名失败时,先比对“签名前字符串”,不要只盯着最终 sign。

这是我自己在实战里最常用、也最省时间的方法。

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

  • 第一次分析时,优先固定 tsnonce
  • 尽量把签名逻辑提炼成纯函数
  • 为关键样例保存黄金测试数据
  • 不要轻易忽略序列化、编码、排序这些“小细节”
  • 如果环境依赖很重,及时切换到浏览器内执行方案

只要你把“抓包 → 定位 → 拆解 → 对照 → 复现”这条链路跑顺,绝大多数中等复杂度的 Web 签名参数,都是可以被稳定分析和自动化复现的。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:从热点数据防穿透到一致性治理》
下一篇
《区块链节点数据索引实战:面向中级开发者的链上事件解析与高性能查询设计》