签名算法还原

最后更新:2026-03-29 Web/App 接口通常对请求参数做签名(sign),防止参数篡改和重放攻击。常见形式: 逆向目标:找到 sign 的计算逻辑,使用 Python 自行构造相同的 sign。 在 Chrome DevTools 的 Sources 面板,Ctrl+Shift+F 全局搜索: 在 Sources → XHR/fetch Breakpoints 添加请求 URL 关键词,发起请求时自动断到 XHR 发送处,从调用栈往上追溯到 sign 赋值。 对于 App 的 Native 签名,用 Frida 直接 Hook 签名方法: 最

分享

官方文档:https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto
适用场景:分析 Web/App 请求签名的完整还原流程,从参数提取到 Python 复现

最后更新:2026-03-29


1. 什么是签名

Web/App 接口通常对请求参数做签名(sign),防止参数篡改和重放攻击。常见形式:

# URL 或请求体中的签名参数
GET /api/goods?id=100&ts=1711680000&sign=a3f4b2c1...
POST /api/order  {"data": "...", "sign": "...", "nonce": "xxx", "ts": 1711680000}

逆向目标:找到 sign 的计算逻辑,使用 Python 自行构造相同的 sign。


2. 签名定位流程

1. 抓包       → 找到含 sign/token/signature 等参数的请求
2. 定位代码   → 四种方法(见下)
3. 分析逻辑   → 确定参与签名的字段、排序规则、加密算法
4. Python 还原 → 用 Python 复现签名
5. 验证       → 用还原的签名发请求,验证正确性

3. 定位签名代码的四种方法

方法一:全局搜索关键词(最常用)

在 Chrome DevTools 的 Sources 面板,Ctrl+Shift+F 全局搜索:

sign        signature       _sign
token       X-Token         Authorization
nonce       timestamp       appkey

方法二:XHR 断点

Sources → XHR/fetch Breakpoints 添加请求 URL 关键词,发起请求时自动断到 XHR 发送处,从调用栈往上追溯到 sign 赋值。

方法三:请求头 / 请求体 Hook

// Hook XMLHttpRequest.setRequestHeader(定位自定义请求头的来源)
var _setRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
    if (header.toLowerCase().includes("sign") || header === "X-Token") {
        console.log("[XHR Header]", header, "=", value);
        debugger;  // 断点,查看调用栈
    }
    return _setRequestHeader.apply(this, arguments);
};
// Hook JSON.stringify(定位请求体签名的构造位置)
var _stringify = JSON.stringify;
JSON.stringify = function(obj) {
    var result = _stringify.apply(this, arguments);
    if (result && result.includes("sign")) {
        console.log("[JSON.stringify]", result);
        debugger;
    }
    return result;
};

方法四:Frida 主动调用

对于 App 的 Native 签名,用 Frida 直接 Hook 签名方法:

Java.perform(function () {
    var SignUtil = Java.use("com.example.SignUtil");
    SignUtil.sign.implementation = function (params) {
        var result = this.sign(params);
        console.log("[sign] params:", JSON.stringify(params));
        console.log("[sign] result:", result);
        return result;
    };
});

4. 常见签名模式

模式一:参数排序 + MD5

最常见的简单签名:将所有参数按 key 排序后拼接,加上 appSecret,取 MD5:

// JS 逆向出的签名逻辑
function sign(params) {
    var sortedKeys = Object.keys(params).sort();
    var str = sortedKeys.map(k => k + "=" + params[k]).join("&");
    str += "&key=SECRET_KEY_HERE";
    return md5(str);
}
# Python 还原
import hashlib
from urllib.parse import urlencode

def sign(params: dict, secret_key: str) -> str:
    sorted_params = dict(sorted(params.items()))
    query = urlencode(sorted_params) + "&key=" + secret_key
    return hashlib.md5(query.encode()).hexdigest()

模式二:HMAC-SHA256 + Base64

更安全的签名方式:

import hmac
import hashlib
import base64
import time
import json

def sign(params: dict, secret: str) -> str:
    timestamp = str(int(time.time()))
    sorted_keys = sorted(params.keys())
    sign_str = "".join(f"{k}{params[k]}" for k in sorted_keys) + timestamp

    mac = hmac.new(secret.encode(), sign_str.encode(), hashlib.sha256)
    return base64.b64encode(mac.digest()).decode()

模式三:拼接固定盐值 + SHA1

import hashlib

def sign(data: str, salt: str = "fixed_salt_value") -> str:
    raw = data + salt
    return hashlib.sha1(raw.encode()).hexdigest()

模式四:RSA 私钥签名

App 内置 RSA 私钥,用私钥对请求数据签名:

from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
import base64

# 从 App 中提取的私钥(或逆向出来的)
PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----"""

def sign(data: str) -> str:
    key = RSA.import_key(PRIVATE_KEY)
    h = SHA256.new(data.encode())
    signature = pkcs1_15.new(key).sign(h)
    return base64.b64encode(signature).decode()

模式五:时间戳 + Nonce + HMAC(OAuth 风格)

import hmac
import hashlib
import time
import uuid

def build_signed_headers(url: str, body: str, app_id: str, app_secret: str) -> dict:
    timestamp = str(int(time.time()))
    nonce = uuid.uuid4().hex

    sign_str = "\n".join([timestamp, nonce, url, body])
    signature = hmac.new(
        app_secret.encode(),
        sign_str.encode(),
        hashlib.sha256
    ).hexdigest()

    return {
        "X-App-Id": app_id,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature,
    }

5. 分析技巧

确认参与签名的字段

  1. 改变一个参数的值,观察 sign 是否变化 → 确认哪些字段参与签名
  2. 去掉某个参数,观察 sign 是否变化 → 确认可选字段
  3. 时间戳参与签名时,sign 每次请求都不同

识别加密算法

现象 可能的算法
sign 固定 32 位十六进制 MD5
sign 固定 40 位十六进制 SHA1
sign 固定 64 位十六进制 SHA256
sign 是 Base64 格式 HMAC + Base64 / RSA
sign 每次相同(无时间戳参与) 纯 MD5/SHA,无随机性
sign 每次不同(含时间戳/nonce) 带随机因子的 HMAC/RSA

参考:加密算法/魔法数字速查

定位密钥(secret key)

密钥常见藏身位置:

  1. JS 文件中的字符串常量(全局搜索 secretkeysalt
  2. 接口返回值中(有些应用从服务端下发 key)
  3. App 的 SharedPreferences / 本地配置文件
  4. so 库(需要 Native 逆向)
  5. 根据设备信息动态生成(如 md5(deviceId + appVersion)

6. 完整 Python 还原模板

"""
接口签名还原模板
目标接口:GET /api/goods/list?category=1&page=1&sign=xxx
签名算法:MD5(sorted_params + &key=SECRET)
"""
import hashlib
import time
import requests
from urllib.parse import urlencode


SECRET_KEY = "your_secret_key"
BASE_URL = "https://api.example.com"

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 ...",
    "Content-Type": "application/json",
})


def sign(params: dict) -> str:
    """构造签名"""
    filtered = {k: v for k, v in params.items() if k != "sign"}
    sorted_params = dict(sorted(filtered.items()))
    query = urlencode(sorted_params) + "&key=" + SECRET_KEY
    return hashlib.md5(query.encode()).hexdigest().upper()


def build_params(extra: dict = None) -> dict:
    """构造带公共参数的请求参数"""
    params = {
        "ts": str(int(time.time())),
        "v": "1.0",
        "appid": "com.example.app",
    }
    if extra:
        params.update(extra)
    params["sign"] = sign(params)
    return params


def get_goods_list(category: int, page: int = 1) -> dict:
    params = build_params({"category": category, "page": page})
    r = session.get(f"{BASE_URL}/api/goods/list", params=params)
    r.raise_for_status()
    return r.json()


if __name__ == "__main__":
    result = get_goods_list(category=1, page=1)
    print(result)

7. 时间戳防重放对抗

有些接口校验时间戳,超过一定时间(如 5 分钟)就拒绝请求:

import time
import ntplib

def get_accurate_timestamp() -> int:
    """获取精确时间戳(防止本地时钟偏差)"""
    try:
        c = ntplib.NTPClient()
        response = c.request("pool.ntp.org", version=3)
        return int(response.tx_time)
    except Exception:
        return int(time.time())

如果接口要求时间戳是服务器时间,先请求一个公开接口获取服务器时间:

def get_server_timestamp(session: requests.Session) -> int:
    r = session.get("https://api.example.com/ping")
    # 有些接口在响应头中返回服务器时间
    server_time = r.headers.get("X-Server-Time")
    if server_time:
        return int(server_time)
    return int(time.time())

8. 踩坑与注意事项

字段顺序影响签名

部分签名要求参数按特定顺序(如按首字母升序、按字段名 ASCII 码排序、按固定顺序),排序规则错误会导致签名不一致。

用逆向出的 JS 代码验证:

// 在浏览器控制台中手动调用逆向出的签名函数,比对结果
sign({category: "1", page: "1", ts: "1711680000"})
// 与 Python 输出对比

字段类型(数字还是字符串)

sign({page: 1})sign({page: "1"}) 结果不同。抓包时注意参数是数字类型还是字符串类型。

URL 编码问题

urlencode 会对特殊字符编码,但有些签名要求对值不编码或使用特定的编码方式:

# 标准 urlencode
urlencode({"name": "张三"})  # name=%E5%BC%A0%E4%B8%89

# 不编码,直接拼接
"&".join(f"{k}={v}" for k, v in sorted_params.items())  # name=张三

最佳实践

先用对比法验证理解:固定一组已知的请求参数和对应签名(从抓包获取),在每个还原步骤后对比中间值,确认每一步都正确,而不是只在最后对比最终签名。

逐步去掉动态变量定位签名边界:用固定值替换时间戳、nonce、随机数(如 timestamp=0, nonce='test'),对比 Python 复现结果与浏览器 Hook 日志,排查是动态变量问题还是算法问题。

参数排序方式要完全复现:ASCII 排序、Unicode 排序、按 key 字母序、按 key 长度排序各有不同;JS 对象的 Object.keys() 顺序是插入顺序而非字母序,Python sorted() 默认 ASCII 序,要对齐。

特殊字符编码细节决定签名是否成功:URL 编码大小写(%2F vs %2f)、+ 是否编码、中文用 UTF-8 还是 GBK,这些细节常常是最后一步卡住的原因;用 Wireshark 抓包和 Hook 日志双重验证。

将还原逻辑封装为 Python 函数并写单元测试:以已知的请求参数和签名为测试用例,assert sign(params) == expected,后续签名算法升级时能快速定位变更点。


常见陷阱

陷阱:参数排序后签名仍不对

现象: 按字母排序参数后签名结果与目标不符。
原因: 排序规则可能是按 key 的 ASCII 码排序(区分大小写,大写字母排在小写前),而 Python sorted() 对字符串默认相同行为,但 JS 的 localeCompare 可能返回不同结果。
解决: 在 JS DevTools Console 中直接打印排序后的参数数组对比,确认排序规则;Python 中 sorted(params.items(), key=lambda x: x[0]) 与 JS [...obj.keys()].sort() 对比结果。

陷阱:时间戳精度不对导致签名失败

现象: 每次请求签名都不对,但算法完全相同。
原因: 目标使用毫秒级时间戳(13位),而 Python int(time.time()) 是秒级(10位);或 JS 的 Date.now() 返回毫秒,签名时被截断为秒级。
解决: Python 毫秒时间戳:int(time.time() * 1000);对比 Hook 日志确认时间戳格式(位数)。

陷阱:Base64 编码方式与预期不符

现象: 签名最后一步是 Base64,但 Python 输出比目标多 = 或字符不同(+ vs -)。
原因: URL-safe Base64 用 -_ 替换 +/;部分签名去掉末尾 = padding。
解决: 检查 JS 中的 Base64 输出是否做了字符替换;Python 用 base64.urlsafe_b64encode(data).decode().rstrip('=') 对应处理。


参见

js逆向调试技巧
Frida基础
HMAC
MD5
hook

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog