请求签名与防篡改:HMAC、时间戳、nonce 到底各防什么(附本地可复现 demo)

张 张站长 · 2026-09-15 19:14 · 技术-逆向 · 44 次浏览 · 0 条评论

照抄参数重放却报「签名错误」?因为接口带了签名。这篇用一个自己写的本地签名服务器当靶子,把 HMAC/时间戳/nonce 各防什么讲透,再给一套逆向分析签名的通用四步法。

很多接口你直接照着抓包重放会发现"参数一模一样,服务器却返回签名错误/请求非法"。原因就是它带了请求签名——一段用来证明"这个请求是合法客户端按规矩发出来、而且中途没被改过"的字符串。这篇我把签名这件事从"为什么要它""它由哪几块拼成""每一块防的是什么"讲透,然后再从逆向的角度讲一套通用的分析方法。

为了能复现,我全程用一个自己写的本地签名服务器当靶子——先把它跑起来,你才能对着一个"已知答案"的东西去练分析手法。分析真实系统不在本文范围,方法是相通的。

一、签名到底防什么

先想清楚一个签名要同时挡住三种坏事,你才知道它为什么长那样:

  1. 防篡改:请求在路上(或被人用工具改)被动了手脚,服务端要能发现。
  2. 防伪造:不知道"密钥"的人,拼不出一个能通过的签名。
  3. 防重放:别人把你一个合法请求原封不动录下来、过一会儿再发一遍,也不能成功。

对应的三样材料就是:HMAC(一个带密钥的哈希) 管前两条,时间戳 + 随机数(nonce) 管第三条。下面挨个拆。

一次带签名的请求:从组装到校验

二、先跑起来:一个最小签名服务器

新建 server.py(用 Flask,pip install flask):

import hmac, hashlib, time
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = b"my-demo-secret-key"          # 真实系统里这叫"盐/密钥",是核心机密
seen_nonces = set()                     # 内存里记已用过的 nonce(demo 用,生产要用带过期的存储)

def canonical(method, path, body, ts, nonce):
    # 规则必须两端完全一致:字段顺序、分隔符、编码,差一个字节签名就对不上
    return "\n".join([method.upper(), path, body, str(ts), nonce])

def make_sign(method, path, body, ts, nonce):
    msg = canonical(method, path, body, ts, nonce).encode()
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()

@app.post("/api/echo")
def echo():
    ts = request.headers.get("X-Ts", "")
    nonce = request.headers.get("X-Nonce", "")
    sign = request.headers.get("X-Sign", "")
    body = request.get_data(as_text=True)

    # ① 时间窗口:超过 ±300 秒就拒(重放的包时间戳会过期)
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return jsonify(err="时间戳过期"), 401
    # ② nonce 去重:同一个随机数只认一次
    if nonce in seen_nonces:
        return jsonify(err="重放请求"), 401
    # ③ 用同样规则+同样密钥重算,常数时间比对
    expect = make_sign("POST", "/api/echo", body, ts, nonce)
    if not hmac.compare_digest(expect, sign):
        return jsonify(err="签名错误"), 401

    seen_nonces.add(nonce)
    return jsonify(ok=True, youSent=body)

if __name__ == "__main__":
    app.run(port=5000)

再写一个合法客户端 client.py:

import hmac, hashlib, time, uuid, requests

SECRET = b"my-demo-secret-key"

def make_sign(method, path, body, ts, nonce):
    msg = "\n".join([method.upper(), path, body, str(ts), nonce]).encode()
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()

body = '{"msg":"hello"}'
ts = int(time.time())
nonce = uuid.uuid4().hex
sign = make_sign("POST", "/api/echo", body, ts, nonce)

r = requests.post("http://127.0.0.1:5000/api/echo", data=body,
                  headers={"X-Ts": str(ts), "X-Nonce": nonce, "X-Sign": sign,
                           "Content-Type": "application/json"})
print(r.status_code, r.json())

python server.py 起服务,另开一个终端 python client.py,你会看到 200 {'ok': True, ...}。

现在做三个"坏客户端"实验,亲手验证每块料防的是什么:

  • 改 body 不改 sign:把 body 改成别的、sign 还用旧的发过去 → 401 签名错误。这就是防篡改。
  • 不知道 SECRET:把 client.py 里的 SECRET 改成瞎猜的 → 401 签名错误。这就是防伪造(你算出来的 HMAC 跟服务端不一样)。
  • 重放:把 client.py 生成的那一整套 ts/nonce/sign/body 抄下来,5 分钟内发第二遍 → 第一遍 200、第二遍 401 重放请求(nonce 撞了);过 5 分钟再发 → 401 时间戳过期。这就是防重放。

三个实验做完,签名的设计意图你就彻底懂了。

三、逐块拆:为什么是这几样

HMAC 而不是普通哈希。 如果只用 sha256(canonical),那任何人都能算——因为没有秘密。HMAC 是"带密钥的哈希",公式可以理解成把密钥揉进哈希过程里,没有 SECRET 就复现不出同一个结果。所以 HMAC 同时给了你"防篡改"(内容变了哈希就变)和"防伪造"(没密钥算不出)。这也是为什么找到那个 secret/盐是逆向签名的关键——算法通常是公开的标准(HMAC-SHA256),真正藏起来的是密钥和"拼字符串的规则"。

canonical(规范串)是最容易翻车的地方。 服务端要重算签名,就必须和客户端逐字节拼出同一个字符串。字段顺序、用 \n 还是 & 分隔、body 要不要去空格、URL 要不要 encode、大小写……任意一处两端不一致,签名就对不上。逆向时你会花大量时间在"到底是按什么顺序、什么分隔符拼的"上,而不是算法本身。

时间戳 = 一个粗过滤。 它把"能重放的时间窗"从无限缩到几分钟。但光有时间戳不够——5 分钟内还是能重放,所以要配 nonce。

nonce = 一次性随机数。 服务端记下每个见过的 nonce,撞了就拒。它堵死了"时间窗内的重放"。注意 demo 里我用内存 set,生产必须用带过期时间的存储(比如 Redis,过期时间 = 时间窗大小),否则内存会无限涨。

hmac.compare_digest 而不是 ==。 普通 == 比较字符串是"从头比到第一个不同就返回",攻击者能通过测量比较耗时一点点猜出正确签名(时序侧信道)。compare_digest 是常数时间比较,堵这个洞。这是个很多人忽略但很重要的防守细节。

四、逆向视角:拿到一个不认识的签名,怎么下手

真实场景里你手上是"一个会签名的客户端 + 抓到的包",但不知道规则。通用方法论就四步,我用上面 demo 的"混淆版"客户端来演示每一步该看什么:

第 1 步 · 定位哪些字段是签名、哪些参与了签名。
多抓几个请求对比,盯着"每次都变"的字段:
- X-Ts 每次递增 → 时间戳。
- X-Nonce 每次是一串随机的 32 位十六进制 → 大概率是 nonce/uuid。
- X-Sign 每次是 64 个十六进制字符 → 64 hex = 32 字节 = SHA256 输出,基本能断定是 SHA256 家族(HMAC-SHA256 或裸 SHA256)。
- body/path 改了 X-Sign 就变、body/path 不变 X-Sign 也不变(同一 ts/nonce 下)→ 说明它们参与了签名。

第 2 步 · 确认算法与"料"的组合。
先猜最常见的:HMAC_SHA256(secret, 某种拼接)。难点是"拼接顺序"和"secret"。做法是在客户端把签名函数找出来、看它的入参——纯前端就用上一篇讲的 hook 手法(document_start 时 hook 住那个签名函数,打印它收到的字符串和吐出的结果);App 就用下一篇讲的 frida hook。关键是看它到底把哪个字符串喂给了哈希——那个字符串就是 canonical,看一眼顺序和分隔符全清楚了。

第 3 步 · 找 secret。
hook 到签名函数后,secret 通常就在它的另一个入参里,或者是个写死在代码里的常量。如果签名函数长得就是标准 HMAC(key, msg),那第一个参数就是 key。很多"看起来很复杂"的签名,扒到最后就是 HMAC-SHA256(一个硬编码字符串, 一个拼出来的串)。

第 4 步 · 用拿到的规则本地复现,和抓包里的签名比对。
把 canonical 规则和 secret 抄成你自己的 make_sign,喂同样的 ts/nonce/body,看算出来的和抓包里的 X-Sign 是不是一字不差。对上了才算真懂——这一步能立刻暴露你在"拼接顺序/编码"上的所有理解偏差。这也是为什么我强烈建议先在自己的 demo 上练:因为你手里有标准答案(server.py 里的规则),能马上判断对错。

五、常见变体和坑

  • 加盐位置花样多:有的是 HMAC(secret, msg),有的是 sha256(msg + secret),有的先 md5 再 sha1 叠一层。特征还是看输出长度(32 hex=MD5,40 hex=SHA1,64 hex=SHA256)。
  • body 参与前先做了处理:常见的是把 JSON 的 key 排序后再拼、或者只取部分字段。签名对不上时,先怀疑 body 的规范化。
  • 时间戳单位/时区:秒还是毫秒、UTC 还是本地,差一个数量级签名就全错。
  • URL encode 的时机:是 encode 前拼还是 encode 后拼,两端必须一致。
  • 大小写:hex 输出是大写还是小写、header 名大小写,都可能是坑。

六、回到防守:如果你是设计接口的那一方

反过来,写签名的时候记住这几条就不容易被绕:
- 密钥别下发到客户端明文里——纯前端其实藏不住密钥(都能扒出来),真要强,敏感签名放服务端做。
- canonical 一定要包含 method、path、关键 body 字段、ts、nonce,少一样就多一个可篡改面。
- 时间窗 + nonce 去重两个都要,缺一个重放就有空子。
- 比较用常数时间函数。
- nonce 存储要带过期,过期时间对齐时间窗。


签名说破了就是"HMAC 管防伪防篡改,时间戳+nonce 管防重放,canonical 是两端必须逐字节一致的拼接规则" 这么一句话。把上面的 server.py/client.py 跑起来、把三个坏客户端实验做一遍、再照着四步方法论在自己 demo 上复现一次签名,你就掌握了分析任何签名接口的通用套路。核心永远是那两样:拼接规则和密钥。

评论 0

还没有评论
← 全部文章

找站长

站长微信二维码 扫码加微信
售后群二维码 扫码进售后群
站长收款码 建站不易,感谢打赏
微信 18581469390
本站不支持自助充值;需要充值或开通额度,请直接联系站长。
加微信可以找我重置密码、充值、问工具怎么用