签名算法还原
最后更新: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. 分析技巧
确认参与签名的字段
- 改变一个参数的值,观察 sign 是否变化 → 确认哪些字段参与签名
- 去掉某个参数,观察 sign 是否变化 → 确认可选字段
- 时间戳参与签名时,sign 每次请求都不同
识别加密算法
| 现象 | 可能的算法 |
|---|---|
| sign 固定 32 位十六进制 | MD5 |
| sign 固定 40 位十六进制 | SHA1 |
| sign 固定 64 位十六进制 | SHA256 |
| sign 是 Base64 格式 | HMAC + Base64 / RSA |
| sign 每次相同(无时间戳参与) | 纯 MD5/SHA,无随机性 |
| sign 每次不同(含时间戳/nonce) | 带随机因子的 HMAC/RSA |
参考:加密算法/魔法数字速查
定位密钥(secret key)
密钥常见藏身位置:
- JS 文件中的字符串常量(全局搜索
secret、key、salt) - 接口返回值中(有些应用从服务端下发 key)
- App 的 SharedPreferences / 本地配置文件
- so 库(需要 Native 逆向)
- 根据设备信息动态生成(如
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('=') 对应处理。