协议与应用基础(六):重放攻击、nonce、timestamp 与 challenge-response
协议与应用基础(六):重放攻击、nonce、timestamp 与 challenge-response
前言
前面几篇文章已经介绍了密码协议的安全目标、TLS 握手、证书链、KDF 和 AEAD。它们解决了身份认证、密钥建立、机密性和完整性等问题,但还有一个经常被忽略的目标:消息新鲜性(freshness)。
假设客户端发送了一条经过正确认证的指令:
向订单 1234567890 支付 100 元
攻击者即使不能解密、不能修改、不能伪造认证标签,也可能把这条合法消息完整复制,再发送一次。如果服务器把第二次请求也当成新指令执行,就发生了重放攻击(replay attack)。
因此,“消息确实由合法客户端生成”与“消息是本次刚生成、以前没有处理过”是两个不同问题:
- MAC、数字签名和 AEAD 主要证明消息完整性与来源;
- nonce、timestamp、序列号、请求标识和 challenge-response 用于证明或约束消息的新鲜性;
- 幂等设计和业务去重用于限制重复执行造成的影响。
本文将系统解释重放攻击的条件与边界,区分 nonce、timestamp、sequence number、request ID 和 AEAD nonce,分析 challenge-response 的完整消息流,并给出协议设计、API 服务端验证和 CTF/代码审计中的排查方法。
文中的协议和代码只用于教学、授权测试与原理说明。生产系统应优先使用成熟认证协议和经过审计的密码库,不要根据示例自行设计登录或支付协议。
现代密码学 专栏:
https://blog.csdn.net/r_feynman_/category_13190241.html
Crypto 密码解析实战靶场:https://blog.csdn.net/r_feynman_/category_13194584.html
一、什么是重放攻击
1.1 攻击者不一定需要破解密码算法
设客户端发送消息:
M = ( action , amount , account ) M=(\text{action},\text{amount},\text{account}) M=(action,amount,account)
并附带认证标签:
T = HMAC ( K , M ) T=\operatorname{HMAC}(K,M) T=HMAC(K,M)
服务器验证 T T T 正确后执行 M M M。攻击者抓到完整的 ( M , T ) (M,T) (M,T) 后,虽然无法计算新消息的合法标签,却可以原样再次发送:
( M , T ) → ( M , T ) → ( M , T ) (M,T)\rightarrow(M,T)\rightarrow(M,T) (M,T)→(M,T)→(M,T)
每一份标签都是真的,因为它们本来就是同一条合法消息。HMAC 没有失效,失败的是协议没有判断“这条消息是否已经处理过”。
1.2 重放攻击需要哪些条件
典型重放通常需要以下条件:
- 攻击者能获得一份合法协议消息;
- 消息在未来一段时间内仍会通过认证;
- 服务端没有记录它已经被使用;
- 重复执行在协议或业务上有意义。
攻击者获得消息的方式不只包括明文抓包,还可能来自:
- 恶意代理、浏览器扩展或终端日志;
- 请求队列、消息中间件和重试记录;
- 泄露的调试日志或崩溃转储;
- 客户端重复提交;
- TLS 终止之后的内部链路;
- 同一合法用户主动重复发送自己的旧请求。
因此,HTTPS 能降低网络窃听风险,但不能代替业务层的防重放与幂等控制。
1.3 重放、伪造和篡改的区别
三类攻击应分开理解:
| 攻击 | 攻击者做了什么 | 完整性算法能否直接发现 |
|---|---|---|
| 篡改 | 修改合法消息中的字段 | 通常可以 |
| 伪造 | 构造一条从未认证过的新消息 | 通常可以 |
| 重放 | 原样再次发送旧的合法消息 | 通常不可以 |
如果协议只验证“标签是否正确”,那么一条昨天生成的合法消息和一条刚刚生成的合法消息可能没有任何密码学区别。
1.4 哪些操作特别需要防重放
重复执行对以下操作尤其危险:
- 转账、扣款、退款和兑换;
- 创建订单、发货或核销;
- 修改密码、绑定设备和授权;
- 开门、启动车辆和执行工业控制命令;
- 云 API 中创建、删除或扩容资源;
- 消息协议中的状态迁移;
- 一次性令牌、找回链接和邀请链接。
对于纯查询操作,重放可能不改变状态,但仍可能造成日志污染、资源消耗、缓存探测或重复计费。
二、为什么加密、MAC 和签名不能单独防重放
2.1 加密只隐藏内容
如果请求被加密为:
C = Enc ( K , M ) C=\operatorname{Enc}(K,M) C=Enc(K,M)
攻击者可能不知道 M M M,但仍可以复制 C C C。只要接收方重复解密并执行,攻击就成功。
2.2 MAC 证明消息没有被无声修改
认证标签:
T = MAC ( K , M ) T=\operatorname{MAC}(K,M) T=MAC(K,M)
证明持有密钥的一方曾经认证过 M M M,但标签本身通常不包含“第几次”“哪个会话”“何时发送”等语义。要让 MAC 参与防重放,必须把新鲜性字段和上下文一起认证:
T = MAC ( K , session_id ∥ seq ∥ M ) T=\operatorname{MAC}(K,\text{session\_id}\|\text{seq}\|M) T=MAC(K,session_id∥seq∥M)
2.3 数字签名也可能被重放
数字签名:
S = Sign ( s k , H ( M ) ) S=\operatorname{Sign}(sk,H(M)) S=Sign(sk,H(M))
证明签名私钥持有者签过 M M M。如果 M M M 没有绑定接收方、会话、用途、时间或唯一请求标识,旧签名同样可以再次提交。
“不可伪造”不等于“一次性可用”。
2.4 AEAD nonce 不自动等于业务防重放
AEAD 使用 nonce:
( C , T ) = AEAD_Encrypt ( K , N , M , A ) (C,T)=\operatorname{AEAD\_Encrypt}(K,N,M,A) (C,T)=AEAD_Encrypt(K,N,M,A)
同一密钥下 nonce 唯一,主要是为了防止加密算法因 nonce 重用而失效。它与业务层防重放相关,但两者不完全相同:
- 发送方保证 nonce 不重复,保护的是加密安全;
- 接收方拒绝已经见过的 nonce 或序列号,才形成防重放;
- 如果接收方不保存状态,同一个合法 ( N , C , T ) (N,C,T) (N,C,T) 仍可能被重复解密和执行;
- TLS 等传输协议通常在连接内处理记录序号,但应用请求可能在新连接、代理重试或消息队列中再次出现。
因此必须明确讨论“发送端唯一性”和“接收端去重”两个方向。
三、Nonce:一次使用的协议值
3.1 nonce 的基本含义
nonce 来自 “number used once”,可以理解为在指定作用域内只使用一次的值。它不一定真的是数字,也不一定需要保密。
常见生成方式包括:
- 足够长的密码学随机数;
- 单调递增计数器;
- 会话标识与序列号的组合;
- 由协议状态确定的唯一值。
关键不是名字叫 nonce,而是协议能说明:
- 在什么作用域内唯一;
- 由哪一方生成;
- 接收方如何检查;
- 何时过期和删除;
- 并发、重启和回滚时是否仍然安全。
3.2 随机 nonce
随机 nonce 的优点是无需全局计数器,适合分布式客户端。若使用 128 位密码学随机值:
import secrets
nonce = secrets.token_bytes(16)
nonce_hex = nonce.hex()
但“随机”只降低碰撞概率,不自动完成防重放。服务端仍需保存已接受的 nonce,或把它与时间窗口、请求 ID 等机制组合。
不应使用:
- 普通伪随机函数的固定种子;
- 当前秒级时间直接作为 nonce;
- 用户名、IP 或设备编号;
- 可预测且会在重启后归零的计数器;
- 只有几位十进制数字的随机值。
3.3 计数器 nonce
客户端和服务端共享会话状态时,可以使用单调序列号:
s e q = 0 , 1 , 2 , 3 , … seq=0,1,2,3,\ldots seq=0,1,2,3,…
并认证:
T = HMAC ( K , session_id ∥ s e q ∥ M ) T=\operatorname{HMAC}(K,\text{session\_id}\|seq\|M) T=HMAC(K,session_id∥seq∥M)
服务端维护已接收范围:
- 严格有序协议可以要求 s e q = l a s t + 1 seq=last+1 seq=last+1;
- 允许乱序的协议可以维护滑动窗口和位图;
- 小于窗口下界的序列号直接拒绝;
- 窗口内已标记的序列号视为重放。
这种方式不依赖时钟,但需要可靠保存状态。
3.4 nonce 的作用域
“唯一”必须有清晰作用域。例如:
- 每个用户、每把 API key;
- 每个会话;
- 每个加密方向;
- 每个设备;
- 每个租户;
- 每个密钥版本。
若服务端只按用户保存 nonce,但不同用户共用一把密钥,作用域可能不够;若只按进程保存,负载均衡到另一台实例时可能失效。
推荐把去重键设计为不可歧义的组合:
(key_id, nonce)
(session_id, sequence_number)
(client_id, request_id)
3.5 nonce 缓存的生命周期
永久保存每个 nonce 往往不可行,因此常见组合是:
- timestamp 限制请求只在短时间窗口内有效;
- nonce 保证窗口内不能重复;
- 过期后安全删除 nonce 记录。
例如允许前后 300 秒时钟偏差,服务端至少要把已接受 nonce 保存到对应请求彻底离开有效窗口为止。若 nonce 缓存比签名有效期更早删除,攻击者可能在签名尚未过期时重新提交。
四、Timestamp:限制请求的有效时间
4.1 基本验证逻辑
客户端把时间戳加入认证数据:
T = HMAC ( K , t s ∥ M ) T=\operatorname{HMAC}(K,ts\|M) T=HMAC(K,ts∥M)
服务端检查:
∣ t s s e r v e r − t s c l i e n t ∣ ≤ W |ts_{server}-ts_{client}|\leq W ∣tsserver−tsclient∣≤W
其中 W W W 是允许的时间窗口。
时间戳的价值是让旧消息在窗口外自动失效,从而控制服务端需要保存的去重状态量。
4.2 timestamp 不能单独阻止窗口内重放
如果有效窗口是五分钟,攻击者在这五分钟内重复提交同一请求,时间检查仍会通过。因此:
timestamp 解决“太旧”,nonce 或 request ID 解决“已经用过”。
典型组合为:
s i g n a t u r e = HMAC ( K , t s ∥ n o n c e ∥ M ) signature=\operatorname{HMAC}(K,ts\|nonce\|M) signature=HMAC(K,ts∥nonce∥M)
服务端依次验证:
- 时间戳格式和范围;
- 签名是否正确;
- nonce 是否已经使用;
- 验证成功后原子地登记 nonce;
- 执行业务操作。
4.3 时钟偏差与时间源
时间戳方案依赖客户端和服务端时钟。工程上需要考虑:
- 客户端时钟错误;
- NTP 校时导致时间跳变;
- 多个服务节点时间不一致;
- 秒、毫秒和微秒单位混淆;
- Unix 时间与本地时区字符串混用;
- 32 位时间表示溢出;
- 未来时间是否允许。
签名输入中优先使用明确单位的整数 Unix 时间,例如秒或毫秒,并在协议中写明。展示给用户的本地时间不应直接作为规范签名字段。
4.4 时间窗口不是越大越好
窗口过小会造成正常请求因网络延迟或时钟偏差被拒绝;窗口过大则增加重放机会和 nonce 缓存规模。应根据业务环境选择,并配合:
- 可观察的“客户端时间偏差”错误;
- 安全的时间同步;
- 短期凭证;
- nonce 或幂等键;
- 对高风险请求进行二次确认。
不要为了“避免报错”把窗口扩大到数小时甚至数天。
五、Sequence number、request ID 与幂等键
5.1 Sequence number
序列号表达消息顺序。它适合长连接、VPN、实时消息或双方维护会话状态的协议。
认证范围必须包含序列号:
T i = MAC ( K , s e s s i o n _ i d ∥ d i r e c t i o n ∥ s e q i ∥ M i ) T_i=\operatorname{MAC}(K,session\_id\|direction\|seq_i\|M_i) Ti=MAC(K,session_id∥direction∥seqi∥Mi)
其中加入 direction 可防止客户端到服务端的数据被反射到相反方向。
5.2 Request ID
request ID 标识一次逻辑请求,通常是足够长的随机值或 UUID。服务端可以保存:
(client_id, request_id) -> processing / succeeded / failed + response
如果同一个请求 ID 再次出现:
- 参数相同:返回第一次结果,而不是重复执行;
- 参数不同:拒绝并记录安全事件;
- 第一次仍在处理中:返回处理中状态或等待结果。
5.3 幂等键不完全等于 nonce
幂等键的目的主要是让客户端安全重试。网络超时后,客户端不知道服务端是否已经完成扣款,于是使用相同幂等键重试:
Idempotency-Key: 7d5c...e912
服务端识别为同一次逻辑操作,并复用第一次结果。
nonce 更强调“第二次出现必须拒绝”;幂等键更强调“第二次出现不能重复产生副作用”。两者可以使用同一字段实现,也可以分开:
- 签名 nonce:每次传输唯一;
- operation ID:同一次业务操作在重试时保持不变。
如果把二者混为一谈,自动重试可能被当成攻击,或者攻击重放可能被错误地再次执行。
5.4 数据库中的原子去重
仅在应用内先查询再插入存在竞态:
请求 A:查询 nonce,不存在
请求 B:查询 nonce,不存在
请求 A:执行业务
请求 B:执行业务
应使用数据库唯一约束、原子写入或事务:
CREATE TABLE accepted_requests (
client_id VARCHAR(64) NOT NULL,
request_id VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (client_id, request_id)
);
只有成功插入去重记录的请求才能继续。对于支付等操作,去重记录与业务状态最好在同一事务或可靠状态机中提交。
六、Challenge-response:由验证方提供新鲜性
6.1 基本消息流
challenge-response(挑战-响应)中,验证方先生成随机挑战 C C C:
Client -> Server: 请求认证
Server -> Client: challenge = C
Client -> Server: identity, response
Server -> Client: success / failure
若双方共享密钥:
r e s p o n s e = HMAC ( K , context ∥ C ) response=\operatorname{HMAC}(K,\text{context}\|C) response=HMAC(K,context∥C)
服务端确认:
- C C C 是自己生成的;
- C C C 尚未使用且未过期;
- response 验证正确;
- 验证成功后立即消耗 C C C。
旧响应绑定的是旧挑战,无法用于新的认证会话。
6.2 challenge 必须不可预测且一次性
不安全的 challenge 包括:
- 递增的四位数字;
- 当前时间的简单格式;
- 固定字符串;
- 进程启动后从零计数;
- 可被客户端自由指定的“挑战”。
推荐由验证方使用密码学安全随机源生成足够长的挑战,例如 128 位随机值,并设置短有效期。
6.3 响应必须绑定完整上下文
只计算:
r e s p o n s e = HMAC ( K , C ) response=\operatorname{HMAC}(K,C) response=HMAC(K,C)
在复杂协议中仍可能不够。更稳妥的形式是:
r e s p o n s e = HMAC ( K , protocol ∥ version ∥ server_id ∥ client_id ∥ purpose ∥ C ) response=\operatorname{HMAC}(K, \text{protocol}\|\text{version}\|\text{server\_id}\|\text{client\_id}\|\text{purpose}\|C) response=HMAC(K,protocol∥version∥server_id∥client_id∥purpose∥C)
这样可以降低以下风险:
- 一个服务的响应被拿到另一个服务使用;
- 登录响应被当成授权交易响应;
- 不同协议版本之间交叉解释;
- 客户端身份替换;
- 反射攻击。
6.4 使用数字签名的挑战响应
拥有公私钥的客户端也可以签名挑战和上下文:
r e s p o n s e = Sign ( s k c l i e n t , H ( c o n t e x t ∥ C ) ) response=\operatorname{Sign}(sk_{client},H(context\|C)) response=Sign(skclient,H(context∥C))
服务端用客户端公钥验证。这证明客户端在本次挑战期间持有私钥,但仍要验证公钥与账户身份的绑定关系。
6.5 challenge-response 不等于安全口令协议
如果客户端直接计算:
r e s p o n s e = H ( p a s s w o r d ∥ C ) response=H(password\|C) response=H(password∥C)
攻击者抓到 ( C , r e s p o n s e ) (C,response) (C,response) 后,可能离线猜测口令:对每个候选口令计算哈希并比较。因此,低熵口令不应直接充当 HMAC 密钥或挑战响应密钥。
真实口令认证应采用成熟的 PAKE 或受 TLS 保护、具备安全口令存储和速率限制的标准登录流程,而不是自制“密码加随机数”协议。
6.6 反射攻击
如果同一密钥和同一计算规则同时用于两个方向,攻击者可能把服务端的挑战转交给服务端另一个会话,让服务端替自己计算响应。
防护方式包括:
- 客户端和服务端使用不同方向密钥;
- 在响应中绑定角色字符串;
- 不提供任意 challenge 的签名或 MAC 服务;
- 绑定会话标识、对端身份和用途。
例如:
r e s p o n s e = HMAC ( K c 2 s , "client-auth" ∥ s e r v e r _ i d ∥ C ) response=\operatorname{HMAC}(K_{c2s},\text{"client-auth"}\|server\_id\|C) response=HMAC(Kc2s,"client-auth"∥server_id∥C)
七、四种机制如何组合
7.1 无状态 API
常见组合:
timestamp + nonce + canonical request + HMAC
- timestamp 限制有效时间;
- nonce 在窗口内去重;
- canonical request 唯一表示方法、路径、参数、正文等内容;
- HMAC 防止攻击者修改上述字段。
7.2 有状态长连接
常见组合:
session_id + direction + sequence_number + AEAD
服务端为每个方向维护接收窗口。序列号既用于构造 AEAD nonce,也用于重放检测,但必须遵守具体协议对 nonce 的构造要求。
7.3 登录或设备认证
常见组合:
server challenge + client identity + protocol context + MAC/signature
challenge 应一次性、短期有效,并在成功或达到失败次数后失效。
7.4 高价值业务操作
仅有密码学防重放还不够,可以再加入:
- 业务 operation ID;
- 数据库唯一约束;
- 订单状态机;
- 金额、收款方和币种的明确绑定;
- 用户二次确认;
- 审计日志和异常检测。
密码学负责证明请求与新鲜性字段没有被修改,业务层负责保证同一业务动作只发生一次。
八、一个带时间窗口和 nonce 缓存的教学示例
下面使用 HMAC-SHA-256 演示基本验证顺序。为了突出防重放逻辑,示例省略了持久化数据库、集群共享缓存、密钥轮换和 HTTP 框架。
8.1 客户端生成请求
import hashlib
import hmac
import json
import secrets
import time
API_KEY = "demo-client"
SECRET = bytes.fromhex("7f" * 32)
def canonical_body(data: dict) -> bytes:
return json.dumps(
data,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def sign_request(method: str, path: str, body: dict) -> dict:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
body_bytes = canonical_body(body)
body_hash = hashlib.sha256(body_bytes).hexdigest()
signing_text = "\n".join([
method.upper(),
path,
timestamp,
nonce,
body_hash,
]).encode("utf-8")
signature = hmac.new(
SECRET,
signing_text,
hashlib.sha256,
).hexdigest()
return {
"api_key": API_KEY,
"timestamp": timestamp,
"nonce": nonce,
"body": body_bytes,
"signature": signature,
}
8.2 服务端验证
import hashlib
import hmac
import time
ALLOWED_SKEW = 300
seen_nonces = {}
def cleanup_nonce_cache(now: int) -> None:
expired = [
key for key, expires_at in seen_nonces.items()
if expires_at <= now
]
for key in expired:
del seen_nonces[key]
def verify_request(method: str, path: str, request: dict) -> bool:
now = int(time.time())
cleanup_nonce_cache(now)
try:
client_ts = int(request["timestamp"])
nonce = request["nonce"]
body = request["body"]
supplied = request["signature"]
api_key = request["api_key"]
except (KeyError, TypeError, ValueError):
return False
if api_key != "demo-client":
return False
if abs(now - client_ts) > ALLOWED_SKEW:
return False
if not isinstance(nonce, str) or len(nonce) != 32:
return False
replay_key = (api_key, nonce)
if replay_key in seen_nonces:
return False
body_hash = hashlib.sha256(body).hexdigest()
signing_text = "\n".join([
method.upper(),
path,
str(client_ts),
nonce,
body_hash,
]).encode("utf-8")
expected = hmac.new(
bytes.fromhex("7f" * 32),
signing_text,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, supplied):
return False
# 教学示例:真实系统必须使用原子写入或数据库唯一约束。
seen_nonces[replay_key] = client_ts + ALLOWED_SKEW + 1
return True
8.3 示例仍然缺少什么
这个示例不能直接用于生产,因为它还缺少:
- 多实例共享、持久化和原子 nonce 存储;
- API key 到 secret 的安全查询;
- 密钥版本、吊销和轮换;
- 对 query、host、content-type 等字段的规范化;
- 请求体大小限制和流式哈希;
- 网关与应用对原始路径的一致理解;
- 失败速率限制和审计;
- 业务幂等键与事务;
- TLS 传输保护。
尤其需要注意:nonce 只能在签名验证成功后登记,否则攻击者可以预先发送垃圾签名占用合法 nonce;但“验证成功、登记 nonce、执行业务”之间又必须避免并发竞态。
九、协议设计中的状态与失败处理
9.1 先验签还是先查重
推荐的大致顺序是:
- 限制请求大小和字段格式;
- 检查时间窗口;
- 查找 key ID 并验证签名;
- 原子登记 nonce 或 request ID;
- 执行业务事务;
- 保存可供幂等重试复用的结果。
如果先进行昂贵密码运算,可能增加拒绝服务成本;如果未经认证就写入 nonce,又可能被攻击者污染缓存。实际系统通常需要分层限流和轻量格式检查。
9.2 失败信息不要成为预言机
外部可以统一返回认证失败,内部日志再区分:
- 时间戳过期;
- 未知 key ID;
- 签名错误;
- nonce 重复;
- challenge 已使用;
- 请求格式错误。
对合法客户端,可以通过受控错误码提示校时,但不要泄露密钥状态、账户是否存在等额外信息。
9.3 集群一致性
如果请求可能落到不同服务节点,本地内存集合不能可靠防重放:
第一次请求 -> Node A -> 接受
重放请求 -> Node B -> 本地未见过 -> 再次接受
可选方案包括:
- 共享数据库唯一约束;
- 支持原子“仅不存在时写入”的分布式存储;
- 按客户端或会话固定路由,并设计故障切换;
- 使用可验证序列号和集中状态;
- 将业务操作本身设计成幂等。
9.4 崩溃恢复
如果服务端在“执行转账”后、“记录请求成功”前崩溃,客户端重试时可能重复执行。仅靠内存 nonce 无法解决这种不确定性。高价值操作需要事务、消息 outbox/inbox、状态机或业务唯一键,使“去重记录”和“副作用”具备一致性。
十、CTF 与代码审计中的排查方法
10.1 画出完整消息流
先记录每一步:
谁生成 nonce/challenge?
谁保存它?
认证覆盖哪些字段?
服务端何时标记已使用?
失败、超时和重试后状态如何变化?
仅看到请求中有 nonce 字段,不代表协议已经防重放。
10.2 搜索关键代码
审计时可以关注:
- 时间检查:time、timestamp、expires、skew;
- 唯一值:nonce、jti、request_id、sequence;
- 去重存储:SETNX、unique index、cache add;
- 签名:HMAC、verify、compare_digest;
- challenge 状态:issued、used、expired;
- 重试:retry、idempotency、deduplicate。
10.3 常见漏洞模式
只有 timestamp,没有 nonce
窗口内可以重复发送。
nonce 没有进入签名
攻击者可以替换 nonce,每次都绕过去重:
T
=
HMAC
(
K
,
M
)
T=\operatorname{HMAC}(K,M)
T=HMAC(K,M)
应改为:
T
=
HMAC
(
K
,
n
o
n
c
e
∥
M
)
T=\operatorname{HMAC}(K,nonce\|M)
T=HMAC(K,nonce∥M)
nonce 只存本机内存
负载均衡、多进程、重启和故障切换后可再次使用。
challenge 验证后没有消耗
同一个响应可以在有效期内反复提交。
先查询后插入
并发请求同时通过检查,需要原子唯一约束。
签名未绑定操作语义
对“查询余额”生成的合法响应可能被另一个端点解释为“确认支付”。
仅依赖客户端生成时间
攻击者如果能修改时间字段而时间没有被认证,就可以延长旧请求寿命。
10.4 授权环境中的最小验证
可以在测试环境中:
- 捕获一份自己账号的合法请求;
- 不修改任何字节,立即重复发送;
- 在同一连接和新连接分别测试;
- 并发发送两份相同请求;
- 切换服务节点或等待缓存过期;
- 检查业务副作用是否只发生一次。
测试重点是确认协议与状态机,不应在未授权系统上重放真实用户请求。
十一、常见误区
11.1 使用 HTTPS 就不会重放
HTTPS 保护传输链路,但合法客户端、终端恶意软件、TLS 终止后的内部组件、代理重试或应用日志仍可能产生重复请求。应用层高价值操作仍需幂等和防重放。
11.2 请求里有随机数就安全
随机数如果未被签名、服务端不检查唯一性、长度太短或作用域错误,就只是一个普通字段。
11.3 timestamp 足以防重放
timestamp 只能拒绝窗口外的旧消息,窗口内仍可重放。
11.4 nonce 必须保密
通常不需要保密,重点是唯一、不可被无声替换并且接收方会检查。某些 challenge 还要求不可预测,但这与保密不是同一个概念。
11.5 challenge 越复杂越安全
challenge 的核心是使用安全随机源、足够长、一次性、短期有效。把日期、用户名和固定前缀拼得很复杂,不等于不可预测。
11.6 验证签名后再执行就不会重复
两份相同合法请求都会通过签名。服务端还必须原子地去重并处理并发、重试和崩溃恢复。
11.7 nonce 与 AEAD nonce 完全相同
它们都强调一次性,但作用和约束由具体协议决定。AEAD nonce 首先关系到加密算法安全;认证协议 nonce 首先用于证明新鲜性。不要因为名称相同就随意复用字段或生成规则。
十二、总结
重放攻击不要求攻击者破解加密、伪造 HMAC 或窃取私钥。攻击者只需把一条真实合法消息再次提交,而接收方误把它当成新消息。
几种常见机制分别解决不同问题:
- nonce:在指定作用域内唯一,接收方记录并拒绝重复;
- timestamp:限制消息有效窗口,控制旧消息和缓存规模;
- sequence number:在有状态会话中表达顺序,并支持滑动窗口去重;
- request ID / 幂等键:把多次传输映射为同一次业务操作;
- challenge-response:由验证方提供一次性挑战,证明响应属于当前认证会话;
- MAC、签名和 AEAD:认证上述新鲜性字段及完整业务上下文,防止攻击者替换。
一个稳健的防重放设计通常满足:
完整上下文认证 + 新鲜性证明 + 原子去重 + 业务幂等 \text{完整上下文认证} +\text{新鲜性证明} +\text{原子去重} +\text{业务幂等} 完整上下文认证+新鲜性证明+原子去重+业务幂等
下一篇将进一步讨论 Web API 中如何把 method、path、query、headers、body hash、timestamp 和 nonce 规范化为 canonical request,并使用 HMAC 完成可验证的 API 签名认证。
更多推荐


所有评论(0)