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

《从浏览器到接口:一次典型 Web 逆向中请求签名算法的定位、还原与自动化复现》

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

从浏览器到接口:一次典型 Web 逆向中请求签名算法的定位、还原与自动化复现

很多人第一次做 Web 逆向,最容易卡住的不是“请求发不出去”,而是“明明参数都一样,接口就是返回签名错误”。
这类问题的根源通常不在接口文档,而在浏览器里那段不起眼的前端代码:它会在发请求前,拼接参数、补时间戳、做编码、套哈希,最后生成一个签名字段。

这篇文章我想从一个典型实战路径来讲:怎么从浏览器网络请求出发,定位签名算法,如何还原它,以及怎样把它做成自动化脚本稳定复现。重点不是某个站点的私有实现,而是一套可迁移的方法论


背景与问题

在现代 Web 应用里,请求签名通常承担几个作用:

  • 防止参数被随意篡改
  • 给后端一个“请求来自合法前端”的弱校验
  • 增加接口滥用的门槛
  • 配合时间戳、nonce 防重放

典型表现一般有这些:

  • 请求里多了 signsigtokenauthx-sign 之类字段
  • 同样的 URL 和参数,复制到 Postman 后返回 invalid sign
  • 请求头里带了动态值,比如 x-tsx-nonce
  • 参数顺序一变,签名就失效
  • 明文参数之外,还混入固定盐值、设备指纹、Cookie 字段

如果你只盯着 Network 面板去“抄请求”,成功率通常不高。因为签名算法往往依赖:

  1. 运行时变量:时间戳、随机数、页面状态
  2. 上下文对象:Cookie、本地存储、浏览器环境
  3. 编码细节:URL 编码、Unicode、排序规则
  4. 混淆代码:压缩、重命名、字符串表、控制流平坦化

所以真正有效的方式,不是硬猜,而是按链路拆解。


一个典型签名链路长什么样

先建立整体心智模型。浏览器里一次带签名的请求,通常会走这条路径:

flowchart TD
    A[页面触发请求] --> B[收集业务参数]
    B --> C[补充公共参数 ts nonce appKey]
    C --> D[按规则排序/拼接]
    D --> E[编码或序列化]
    E --> F[哈希/加密生成 sign]
    F --> G[写入请求头或请求体]
    G --> H[发送到接口]
    H --> I[服务端按同样规则验签]

逆向时要做的,就是把 D、E、F 这三步还原清楚,并确认 C 是否还依赖上下文。


核心原理

1. 请求签名本质上是“确定性字符串变换”

无论代码看起来多复杂,大多数签名逻辑最后都可以抽象成:

sign = HASH( canonical_string + secret_or_salt )

其中关键是 canonical_string,也就是“规范化后的待签名字符串”。常见生成规则有:

  • 按参数名 ASCII 排序
  • 忽略空值字段
  • 排除 sign 本身
  • 拼接成 k1=v1&k2=v2...
  • 或直接拼 JSON 字符串
  • 再附加固定盐值、版本号、路径等

比如:

appId=1001&nonce=abc123&query=phone&ts=1710000000 + secret

然后做:

  • MD5
  • SHA1 / SHA256
  • HMAC-SHA256
  • AES 后再 Base64
  • 自定义字符映射或二次摘要

2. 真正难点常常不是哈希,而是“前置处理”

我自己踩过很多坑,最后发现问题不在 MD5 算错,而在下面这些小细节:

  • 排序是按键名排序,还是 key=value 整体排序
  • 数字参数在前端被转成字符串
  • 布尔值是 true/false 还是 1/0
  • undefined 字段是否参与签名
  • URL 编码是签名前做,还是签名后做
  • Unicode 字符串是否做了 UTF-8 编码
  • 请求体 JSON 是否去空格、是否稳定键序

所以逆向时不能只找“用了什么算法”,还要找“算法输入到底是什么”。

3. 定位签名代码的几种高效入口

比起全局硬翻代码,我更推荐从这几个点切入:

入口 A:从 Network 反查调用栈

在浏览器开发者工具里,找到目标请求后看:

  • Request Payload / Form Data
  • Request Headers
  • Initiator / Call Stack

如果能点到发起脚本,往往能直接看到请求封装函数,比如:

  • axios.interceptors.request.use
  • fetchWrapper
  • request(config)
  • signParams(data)

入口 B:全局搜索可疑关键词

重点搜这些关键词:

  • sign
  • sha
  • md5
  • crypto
  • nonce
  • timestamp
  • token
  • headers
  • interceptors

如果代码混淆严重,还可以搜某个固定参数名,比如请求中的 x-sign

入口 C:Hook 加断点

如果直接搜不到,最稳的办法是:

  • XMLHttpRequest.prototype.send
  • window.fetch
  • axios 请求拦截器

下断点,观察请求发出前的配置对象。
这时你通常能看到签名已经被写进 header 或 body,沿调用栈向上回溯,就能找到生成位置。


定位流程:从请求到算法

下面给出一个比较实用的定位流程。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant N as 网络层
    participant A as 接口服务

    U->>P: 点击查询
    P->>P: 组装业务参数
    P->>S: 传入参数/时间戳/上下文
    S-->>P: 返回 sign
    P->>N: 发起请求(headers/body含sign)
    N->>A: HTTP 请求
    A-->>N: 验签通过/失败
    N-->>P: 返回结果

实际操作建议这样做:

第一步:固定输入,减少变量

先让环境尽可能稳定:

  • 使用同一个账号
  • 固定查询词
  • 保持 Cookie 不变
  • 连续抓两次包,对比差异

如果只有 tssign 在变,说明签名很可能只依赖时间戳和固定参数。
如果 noncedeviceId 也在变,那就要继续追踪这些动态来源。

第二步:对比多次请求,推断输入集合

比如抓到两组请求:

请求1:
query=book
page=1
ts=1710000001
sign=xxxx

请求2:
query=book
page=2
ts=1710000005
sign=yyyy

这时可以推断:

  • page 大概率参与签名
  • ts 大概率参与签名
  • 若 Cookie 改变签名也变,则 Cookie 可能参与签名

第三步:在请求发送点断住

在 Console 中可临时注入 Hook:

(function () {
  const rawFetch = window.fetch;
  window.fetch = async function (...args) {
    debugger;
    console.log("fetch args:", args);
    return rawFetch.apply(this, args);
  };
})();

如果站点用的是 XHR:

(function () {
  const rawSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function (body) {
    console.log("xhr body:", body);
    debugger;
    return rawSend.call(this, body);
  };
})();

一旦断住,就看:

  • 请求体里是否已出现签名
  • headers 是在哪一步被补进去的
  • 调用栈上方是否存在格式化或哈希函数

第四步:识别“规范化”函数和“摘要”函数

通常会看到两类函数:

  1. 参数整理函数

    • 排序
    • 过滤空字段
    • 拼接字符串
  2. 摘要/加密函数

    • 调用 CryptoJS.MD5
    • 调用 CryptoJS.HmacSHA256
    • 或调用自定义实现

如果是混淆代码,也别慌。你要做的是先判断角色:

  • 输入是对象,输出是字符串:多半是规范化
  • 输入是字符串,输出是十六进制/Base64:多半是摘要

一个可运行的实战示例

下面我用一个简化但非常接近真实场景的例子来演示:
前端签名规则如下:

  1. 收集参数:appIdquerypagetsnonce
  2. 过滤掉空值和 sign
  3. 按键名升序排序
  4. 拼成 k=v&k=v
  5. 末尾加盐值 &secret=demo_secret
  6. 取 SHA256 十六进制小写

浏览器侧原始逻辑示意

function buildSign(params) {
  const secret = "demo_secret";
  const canonical = Object.keys(params)
    .filter((k) => k !== "sign" && params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort()
    .map((k) => `${k}=${String(params[k])}`)
    .join("&");
  return sha256(`${canonical}&secret=${secret}`);
}

实战代码(可运行)

下面分别给出浏览器端和 Python 自动化复现代码。

方案一:Node.js 复现签名

保存为 sign.js

const crypto = require("crypto");

function buildCanonical(params) {
  return Object.keys(params)
    .filter((k) => k !== "sign" && params[k] !== undefined && params[k] !== null && params[k] !== "")
    .sort()
    .map((k) => `${k}=${String(params[k])}`)
    .join("&");
}

function buildSign(params, secret = "demo_secret") {
  const canonical = buildCanonical(params);
  const raw = `${canonical}&secret=${secret}`;
  return crypto.createHash("sha256").update(raw, "utf8").digest("hex");
}

function buildPayload(query, page = 1) {
  const payload = {
    appId: "1001",
    query,
    page,
    ts: Math.floor(Date.now() / 1000),
    nonce: Math.random().toString(36).slice(2, 10),
  };
  payload.sign = buildSign(payload);
  return payload;
}

const payload = buildPayload("book", 1);
console.log(payload);

运行:

node sign.js

方案二:Python 自动化请求

保存为 client.py

import time
import random
import string
import hashlib
import requests


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


def build_canonical(params: dict) -> str:
    items = []
    for k in sorted(params.keys()):
        v = params[k]
        if k == 'sign':
            continue
        if v is None or v == '':
            continue
        items.append(f"{k}={str(v)}")
    return '&'.join(items)


def build_sign(params: dict, secret='demo_secret') -> str:
    canonical = build_canonical(params)
    raw = f"{canonical}&secret={secret}"
    return hashlib.sha256(raw.encode('utf-8')).hexdigest()


def build_payload(query: str, page: int = 1) -> dict:
    payload = {
        'appId': '1001',
        'query': query,
        'page': page,
        'ts': int(time.time()),
        'nonce': random_nonce(),
    }
    payload['sign'] = build_sign(payload)
    return payload


def main():
    url = 'https://httpbin.org/post'
    payload = build_payload('book', 1)

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

    resp = requests.post(url, json=payload, headers=headers, timeout=10)
    print('request payload:', payload)
    print('status:', resp.status_code)
    print(resp.text)


if __name__ == '__main__':
    main()

运行:

python client.py

这个例子虽然简单,但它已经覆盖了 Web 逆向里最常见的签名复现要素:

  • 参数规范化
  • 字段过滤
  • 排序
  • 字符串化
  • 哈希摘要
  • 自动补动态参数

如何验证你“还原对了”

不要一上来就直接写自动化。最稳的方式是分三层验证。

验证 1:中间字符串是否一致

先不要比最终 sign,先比待签名串。

比如你在浏览器断点里拿到:

appId=1001&nonce=abc123&page=1&query=book&ts=1710000000&secret=demo_secret

那你本地脚本生成的也必须逐字符一致。
只要这一步不一致,后面的哈希必然不一致。

验证 2:摘要输出是否一致

把同一份待签名串分别丢到:

  • 浏览器里的原始函数
  • 你自己的 Node/Python 实现

看输出是否完全一样。

验证 3:接口是否接受

最后再发真实请求。
如果本地算出的 sign 和浏览器一致,但接口还是报错,问题通常就转移到:

  • Cookie
  • Header
  • Referer / Origin
  • 时间戳有效期
  • nonce 去重策略
  • 请求体编码格式

常见坑与排查

这部分非常关键。我把实战里高频翻车点集中列一下。

1. 参数顺序错了

很多人会说:“参数一样啊。”
但签名不只看参数值,还看顺序

排查方式:

print(build_canonical(payload))

和浏览器里的原始待签名串逐字比较。


2. 漏掉了隐藏参与字段

有些字段不在请求体里,却参与签名,比如:

  • 当前路径 /api/search
  • 请求方法 POST
  • User-Agent
  • Cookie 中的某个 token
  • localStorage 里的设备 ID

排查思路:

  • 看签名函数的参数来源
  • 追踪它是否读取了 document.cookielocation.pathnamelocalStorage

3. JSON 序列化不一致

特别是请求体直接签 JSON 时,常见问题有:

  • Python 字典默认键序与前端不同
  • 是否带空格
  • 中文是否转义
  • 布尔值格式不同

例如浏览器可能签的是:

JSON.stringify(obj)

而你在 Python 里用了默认 json.dumps(obj),输出可能不同。
应当显式控制:

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

4. 时间戳位数错了

有的接口要秒级时间戳:

1710000000

有的要毫秒级:

1710000000123

还有的会把时间戳转成字符串再参与拼接。
看起来很小的差异,实际会让签名完全不同。


5. URL 编码时机错了

常见有三种:

  • 先拼明文,再整体编码
  • 每个 value 先编码,再拼接
  • 签名用明文,请求发送时才编码

这个坑我真的踩过很多次。
最笨但最有效的办法就是:在浏览器签名前断点,直接看函数输入。


6. 混淆后看不懂算法

如果代码被混淆,不要试图“一眼读懂全文件”。建议这样拆:

  • 先找到请求发起点
  • 只追这一条调用链
  • 给关键函数重命名
  • 把中间返回值打印出来
  • 必要时复制函数到本地逐步去壳

你不是在审计整个前端工程,而是在找“哪段代码决定了这个 sign”。


7. 浏览器环境依赖没补齐

有些签名函数在浏览器里能跑,到 Node 里就报错,因为它依赖:

  • window
  • document
  • navigator
  • atob / btoa
  • Web Crypto API

这时有几种策略:

  • 只抽取纯算法部分重写
  • jsdom 补简化环境
  • 用 Puppeteer 在真实浏览器上下文执行原函数

如果算法混淆重、环境耦合深,直接在浏览器上下文复用原 JS 往往更省事。


自动化复现的两种思路

实际项目里,一般是这两条路线。

flowchart LR
    A[目标: 自动化复现签名] --> B[方案1: 纯本地还原]
    A --> C[方案2: 浏览器上下文调用原函数]

    B --> B1[速度快]
    B --> B2[部署轻]
    B --> B3[维护成本低]
    B --> B4[但前期逆向更费劲]

    C --> C1[适合强混淆]
    C --> C2[兼容浏览器环境]
    C --> C3[还原成本低]
    C --> C4[但性能和稳定性要额外处理]

方案 1:纯算法还原

也就是把签名规则彻底抄出来,用 Python/Node 重写。

优点:

  • 性能好
  • 部署简单
  • 易于批量化
  • 不依赖真实浏览器

缺点:

  • 前期分析成本高
  • 页面改版后需要重新适配

适合:

  • 签名逻辑相对稳定
  • 算法清晰可重写
  • 批量请求量较大

方案 2:复用前端原始函数

通过 Puppeteer、Playwright 等工具进入页面上下文,直接调用原站点加载后的签名函数。

优点:

  • 对重混淆场景更友好
  • 环境依赖问题少
  • 适合快速验证

缺点:

  • 资源开销大
  • 速度慢
  • 更容易受页面改版、风控、登录状态影响

适合:

  • 算法强依赖浏览器环境
  • 只是少量调用
  • 需要先验证方案可行性

一个基于 Playwright 的自动化示例

如果你已经知道页面上有个可调用函数 window.signer.sign(payload),可以这么做。

保存为 playwright_sign.js

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  const payload = {
    appId: '1001',
    query: 'book',
    page: 1,
    ts: Math.floor(Date.now() / 1000),
    nonce: Math.random().toString(36).slice(2, 10)
  };

  const sign = await page.evaluate((data) => {
    if (!window.signer || typeof window.signer.sign !== 'function') {
      throw new Error('sign function not found');
    }
    return window.signer.sign(data);
  }, payload);

  payload.sign = sign;
  console.log(payload);

  await browser.close();
})();

这个模式的核心价值在于:
先证明你能稳定拿到正确 sign,再考虑是否要纯本地重写。


安全/性能最佳实践

这里的“最佳实践”,我更偏向工程落地,而不是单纯“能跑”。

1. 不要把签名逻辑写死成黑盒

建议把自动化脚本拆成几个明确模块:

  • 参数采集
  • 规范化
  • 签名生成
  • 请求发送
  • 响应校验

这样一旦接口升级,你只需要替换签名部分,而不是重写整个脚本。


2. 保留中间态日志,但注意脱敏

最有价值的调试日志不是最终 sign,而是:

  • 原始参数
  • 规范化字符串
  • 动态字段来源
  • 请求头摘要

例如:

print({
    'canonical': canonical,
    'ts': payload['ts'],
    'nonce': payload['nonce'],
    'sign': payload['sign'][:8] + '...'
})

注意不要在日志里完整输出敏感 token、cookie、secret。


3. 做签名结果缓存要谨慎

如果签名依赖:

  • 时间戳
  • nonce
  • 一次性 token

那就不适合长时间缓存。
能缓存的往往是:

  • 固定盐值
  • 公共参数模板
  • 已编译好的 JS 运行环境

不要为了省几次哈希,把本来应该动态刷新的字段缓存掉。


4. 控制并发,别把自己送进风控

很多接口的签名校验和风控策略是绑定的。即使签名正确,也可能因为:

  • 并发过高
  • 请求间隔过短
  • UA/代理不稳定
  • Cookie 复用异常

而被判定为异常流量。

建议:

  • 加随机抖动
  • 按账号/IP 做并发隔离
  • 做失败重试但限制次数
  • 区分“签名错误”和“风控拦截”

5. 优先还原“规则”,不要沉迷“跑通一次”

能跑通一次不代表已经还原成功。
真正可靠的标志是:

  • 多组参数都能通过
  • 不同时间都能通过
  • 换一台机器仍然能通过
  • 中间规则可以解释清楚

如果你的方案只能在当前浏览器 tab 里偶尔成功一次,那大概率只是碰巧,而不是完成了复现。


6. 合法合规边界必须明确

请求签名逆向本身是技术分析行为,但在真实环境中,必须确保你的操作:

  • 获得授权
  • 用于安全研究、测试或自有系统联调
  • 不触碰未授权数据
  • 不破坏目标服务稳定性
  • 不绕过法律与平台规则

这点不只是“形式上的提醒”,而是底线。


一个推荐的实操清单

如果你准备自己做一遍,可以按这个顺序:

  1. 抓到目标请求,记录 URL、方法、参数、headers
  2. 连续抓 2~3 次包,对比动态字段
  3. fetchxhr.send 下断点
  4. 沿调用栈回溯到签名生成函数
  5. 区分“规范化函数”和“摘要函数”
  6. 打印中间待签名字符串
  7. 本地重写并对齐中间字符串
  8. 对齐最终 sign
  9. 发真实请求验证
  10. 封装成自动化脚本并加入日志与重试

如果第 7 步一直过不去,不要急着怀疑哈希算法,先回去检查:

  • 排序
  • 编码
  • 空值过滤
  • 动态字段来源

十次里有八次,问题都出在这里。


总结

从浏览器到接口,请求签名逆向的核心并不是“猜中某个加密算法”,而是把整个链路拆开:

  • 先找到请求发出前的真实输入
  • 再还原参数规范化规则
  • 最后确认摘要算法与上下文依赖

实战里最有效的方法也很朴素:

  1. 从 Network 找请求
  2. 在发送点断住
  3. 沿调用栈回溯
  4. 打印中间态
  5. 先对齐待签名串,再对齐最终签名
  6. 最后做自动化封装

如果你是中级读者,我给一个最可执行的建议:
以后遇到签名错误,先别急着改哈希算法,先把浏览器里的“待签名原文”拿出来。
只要这一步拿到了,后面的问题基本就从“猜”变成“对齐”。

边界条件也要记住:当签名高度依赖浏览器环境、混淆很重、且页面变动频繁时,直接复用前端原函数往往比纯重写更划算;而当你需要高性能、稳定批量化时,完整还原规则才是最终解法。

说到底,Web 逆向里最重要的不是某个技巧,而是能不能把复杂问题拆成可验证的步骤。签名算法这件事,尤其如此。


分享到:

上一篇
《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离鉴权实战与权限设计》
下一篇
《面向中型业务的集群架构设计实战:从高可用部署、流量调度到故障切换的落地方案》