TLS 1.3 0-RTT(early data)天然不抗重放:哪些接口能安全开启、服务端怎么兜底、应用层 nonce 为什么防不住?
该问题的正文与回答。
【背景】
我在给一套 60+ 微服务做传输层安全加固(Go 1.22 / Java 17 混合,K8s 1.28 自建,无云),目标是把 TLS 版本从 1.2 升到 1.3、关掉弱密码套件、开启 HSTS。升级过程中发现一个被广泛忽略的安全窗口:TLS 1.3 的 0-RTT(session resumption + early data)在协议设计上就"允许"重放。
RFC 8446 §2.6 原文:
"0-RTT data is not protected against replay attacks. … A server MAY choose to reject 0-RTT data…"
翻译成人话:0-RTT 的 early data 相当于"还没验完身就把信递进去了"——TLS 层认为这是合法握手的一部分,不会标记、不会拒绝、不会去重。这和 Nginx proxy_next_upstream 重放 POST(id=114)不同——那个是网关"误操作",而 0-RTT 是协议"设计如此"。
打个比方:0-RTT 像小区门禁的"刷脸通行"——你还没走到闸机,脸已经刷了,门就开了。如果有人在电梯里拍到你刷脸那一下,第二天在另一栋楼重放这段视频,门禁系统协议上认为合法,因为它没有"同一张脸 1 小时内只能刷一次"的机制。
【现象 / 卡点】
我们当前有三类接口暴露在这个风险下:
- POST /api/v1/orders(创建订单,非幂等):Go 1.22
crypto/tls默认允许 early data(Config.SessionTicketsDisabled = false+ 服务端未显式SetSessionState拒绝),如果客户端用openssl s_client -early_data重放同一笔 POST,TLS 层握手成功、应用层收到正常 HTTP 200——nonce 是客户端生成的,重放时 nonce 不变,Redis SETNX 判重应该能拦住……但实测发现:如果客户端每次重放时换一个 nonce(攻击者不需要知道原始 nonce,只需要抓包后重新签名),应用层 nonce 判重就失效了,因为 nonce 是"这个请求"的指纹,不是"这笔业务"的指纹。 - GET /api/v1/users/{id}/profile(幂等读):0-RTT 重放无副作用,安全。
- POST /api/v1/payments(支付回调,外部系统发起):最危险——外部系统如果启用了 0-RTT,重放一笔已成功的支付回调,应用层幂等键(merchant_order_id)能拦住,但如果攻击者篡改 merchant_order_id(抓包后改一个字段重新签名),幂等键就失效了。
核心卡点:
- 0-RTT 重放是协议层行为,应用层的 nonce/幂等键机制假设"请求身份 = 业务身份",但 0-RTT 让攻击者可以在保留 TLS 层合法性的同时改变应用层业务参数——这两层防线是脱节的。
- 我需要一个可落地的决策框架:哪些接口能安全开 0-RTT、哪些必须关、服务端怎么检测/拒绝 early data、以及一个可复现的 PoC 来验证风险真实存在。
【已尝试的排查与结果】
- Go 1.22 服务端:
crypto/tls默认行为是"接受但不要求"early data——如果客户端发 early data,服务端静默接受,不会报错。我在tls.Config里加了SessionTicketsDisabled: true来彻底禁用 session resumption(包括 0-RTT),但这就丢掉了 1-RTT 的性能收益。 - OpenSSL 3.0 服务端:
s_server -early_data可以显式开启,默认是关闭的(-early_data需显式指定)。我测了一个最小 PoC:
结果:两次都返回 200,应用层日志显示两笔订单都落库了。如果 nonce 不变,Redis SETNX 第二次会拦住;但如果攻击者换 nonce(重新签名),两笔都成功。# 服务端(Go 1.22,未禁用 session resumption) # 客户端第一次握手 openssl s_client -connect 127.0.0.1:8443 -early_data /tmp/order.json # 客户端第二次(重放,同一 early data) openssl s_client -connect 127.0.0.1:8443 -early_data /tmp/order.json - Nginx 1.25:
ssl_early_data指令默认off,开启后 Nginx 会把 early data 透传给上游,但 Nginx 本身不做去重——去重完全依赖上游应用层。
【期望得到的答案形态】
- 一个决策矩阵:按接口类型(幂等读 / 非幂等写 / 外部回调)给出 0-RTT 开/关建议,以及每条建议的 RFC 依据或 CVE 案例;
- 服务端检测/拒绝 early data 的具体配置:Go
crypto/tls、OpenSSL 3.x、Nginx 1.25+ 三套配置,各自怎么"只允许 1-RTT、拒绝 0-RTT"; - 一个可复现的 PoC 脚本:用
openssl s_client或 Pythonaiohttp构造 0-RTT 重放,验证应用层幂等键在"换 nonce"场景下是否真的失效,并给出修复代码; - 和站内已有防重放方案的边界:id=508 的 nonce+timestamp+幂等键方案、id=114 的 Nginx 重放问题,和 0-RTT 重放的本质区别是什么?哪些防线是重叠的、哪些是 0-RTT 独有的盲区?
(已读相关:《API 签名防重放:timestamp + nonce + 幂等键三者职责分离》id=508 讲了应用层 nonce 机制,但没有覆盖 TLS 1.3 0-RTT 这个协议层重放窗口;《Nginx proxy_next_upstream 把 POST 重放到上游》id=114 讲了网关层误重放,但 0-RTT 是客户端主动发起的合法握手,不是网关 bug。两者都没有覆盖"协议层允许重放"这个角度,故单独提问。)