攻击者抓到合法请求包后反复重放,能在不破解密钥的情况下刷接口、重复下单、重复扣款。最稳妥的防护是在网关层做”时间戳时效 + Nonce 一次性 + HMAC 签名防篡改”三道拦截,再用接口幂等兜底合法重试。请求在进入业务前就被拦掉,不消耗后端算力。
一、重放防护的四道防线对比
单一手段都有缺口,组合使用才能覆盖”时效、唯一、可信、安全重试”四个层面。
| 手段 | 防什么 | 状态 | 关键参数 | 短板 |
|---|---|---|---|---|
| 时间戳 Timestamp | 窗口期外的旧包 | 无状态 | 时效窗口 15–60s | 窗口内仍可被重放 |
| 随机数 Nonce | 窗口内重复请求 | 有状态 | 唯一串 + TTL | 需共享存储去重 |
| HMAC 签名 | 篡改与伪造来源 | 无状态 | 密钥 + 规范化串 | 不含时效则难挡重放 |
| 幂等键 Idempotency-Key | 合法重试副作用 | 有状态 | 键 + 首响缓存 | 仅护核心写接口 |
时间戳提供粗粒度快速拦截,Nonce 提供细粒度唯一标识,签名把两者绑进不可篡改的凭证。这套组合是生产环境防重放的主流基线。
二、网关层拦截的落地步骤
把校验做成横切关注点,放在流量入口处尽早 Fail Fast。按四步落地:
- 客户端 SDK 生成
timestamp与nonce,按字典序拼接参数做规范化,用SecretKey算 HMAC-SHA256 签名,写入请求头; - 网关按
AccessKey取出对应SecretKey,复现规范化串并重算签名,做恒定时间比较防时序攻击; - 网关校验
abs(now - timestamp) <= 窗口,超窗直接拒绝; - 网关查 Nonce 去重存储,已存在则判为重放拒绝,通过后转发后端。
2.1 请求头与签名要素
客户端发出的头结构如下,timestamp 与 nonce 必须纳入签名域,否则攻击者可以单独改这两个值绕过时效检查:
X-App-Key: ak_12345
X-Timestamp: 1741593600000
X-Nonce: 9b1c2f3a-7d4e-4c8b-9a6f-2e1d0c5b8a47
X-Signature: base64(HMAC-SHA256(SecretKey, StringToSign))
阿里云 API 网关把时间戳精度定为毫秒,并要求客户端与服务端时差不超过 15 分钟,超限即判为重放。
2.2 网关侧验签与去重(Go 片段)
func verify(r *http.Request) error {
ts := atoi(r.Header.Get("X-Timestamp"))
if abs(time.Now.UnixMilli-ts) > 60_000 { // 60s 窗口
return errors.New("timestamp expired")
}
nonce := r.Header.Get("X-Nonce")
if used := nonceStore.SetNX(nonce, 1, 60*time.Second); !used {
return errors.New("replay detected")
}
secret := keyDB.Get(r.Header.Get("X-App-Key"))
sign := hmacSHA256(secret, canonicalize(r)) // 复现 StringToSign
if !hmac.Equal(sign, decode(r.Header.Get("X-Signature"))) {
return errors.New("bad signature")
}
return nil
}
恒定时间比较用 hmac.Equal,避免签名比对被时序攻击撬开。Nonce 用 SetNX 写入,天然带 TTL,窗口过后自动清理,不会无限膨胀成数据库。
三、Nonce 存储与 TTL 设计
网关通常水平扩展、无状态,Nonce 去重必须落在共享、低延迟、高可用的存储上。Redis 因内存低延迟和原子操作成为首选,用 SET key value NX PX ttl 一把完成”查重 + 写入 + 过期”。
这是 OpenResty/Lua 的落地写法:
-- 网关校验 nonce 唯一性(Redis + Lua 原子)
local nonce = KEYS[1]
local ttl = ARGV[1] -- 毫秒,等同时效窗口
local ok = redis.call("SET", nonce, "1", "NX", "PX", ttl)
if not ok then
return 0 -- 已存在,判为重放
end
return 1 -- 首次,放行
要点:Nonce 只存于窗口期内,绝不永久保存;去重键按客户端维度隔离,避免跨客户端碰撞;窗口结束 Redis 自动淘汰,运维零负担。
四、幂等兜底与两个易错点
时间戳和 Nonce 解决”非法重放”,但网络抖动、前端重复点击、网关重试造成的合法重复写,要靠 Idempotency-Key 兜底。对 POST /orders、POST /charges 这类接口,按 (client_id, idempotency_key) 缓存首响,重复键直接返回原结果,重试变安全事件。
只验时间戳不存 Nonce 是高频错误,攻击者在窗口内照样能重放。另一个坑是时钟漂移:客户端时钟乱偏会误杀正常请求,或被迫放宽窗口削弱防护。服务端应在错误响应里回传服务器时间让客户端校正,并对高风险接口收紧窗口、低风险放宽。
常见问题(FAQ)
Q1:只上 HTTPS 能防重放吗?
不能。TLS 护传输,但抓到的合法包仍可重放,必须靠时效与唯一性校验。
Q2:Nonce 要永久保存吗?
不必。只在时效窗口内用 TTL 缓存,过期自动清理,永久存反而拖垮性能。
Q3:高风险的写接口怎么兜底?
要求 Idempotency-Key,按客户端加键缓存首响,把重试变成安全事件。