TLS 1.3 0-RTT Early Data 的重放攻击防御与代理握手优化实践
0-RTT 在线上真正出问题的场景,几乎都不是“握手没成功”,而是握手成功得太快,快到攻击者可以把同一个 ClientHello 连同 Early Data 原样重放给边缘节点,而边缘节点在第一个字节到达时就执行了写操作。本文从 PSK 派生、密钥调度、RFC 8446 的反重放条款一直讲到 Nginx/Envoy 的落地配置与 ConnProof 的耗时测量,给出一套可以在生产环境直接照做的判断依据。
1-RTT 到 0-RTT 的密码学路径
1.1 完整握手与 PSK 恢复握手的报文差异
TLS 1.3 完整握手(RFC 8446 §4.1.2)在 TCP 三次握手之后还需要一个完整往返才能发出应用数据:
Client Server
|---- TCP SYN --------------------------------->|
|<--- TCP SYN/ACK ------------------------------|
|---- TCP ACK --------------------------------->|
|---- ClientHello (key_share, sig_algs) ------->|
| [计算共享密钥]
|<--- ServerHello (key_share) ------------------|
|<--- {EncryptedExtensions} --------------------|
|<--- {Certificate} ----------------------------|
|<--- {CertificateVerify} ----------------------|
|<--- {Finished} -------------------------------|
| [验证证书链]
|---- {Finished} ------------------------------>|
|---- {Application Data: GET /api} ------------>| ← 第 2 个 RTT 才发出2
3
4
5
6
7
8
9
10
11
12
13
14
PSK 恢复握手(RFC 8446 §2.2)把证书链路整个跳过,服务端在 ServerHello 之后立刻进入应用数据阶段,客户端在收到 ServerHello 后的第一个 RTT 内即可发数据。而 0-RTT 更进一步,把应用数据塞进第一个 flight:
Client Server
|---- TCP SYN --------------------------------->|
|<--- TCP SYN/ACK ------------------------------|
|---- TCP ACK --------------------------------->|
|---- ClientHello (pre_shared_key, early_data)->|
|---- {Application Data: POST /order} --------->| ← 与 ClientHello 同一 RTT
|<--- ServerHello (pre_shared_key) -------------|
|<--- {EncryptedExtensions, Finished} ----------|
|<--- {Application Data: 200 OK} ---------------|2
3
4
5
6
7
8
9
省下的这一个 RTT 在跨洲链路上是 120 到 200 毫秒,在移动网络 RRC 状态切换叠加时能到 400 毫秒以上。这就是所有 CDN 与 API 网关愿意承担 0-RTT 复杂度的唯一理由。
1.2 PSK 的派生链与 early_secret
0-RTT 的密钥不来自 DH 交换,而来自上一次连接协商出的 resumption_master_secret。RFC 8446 §7.1 的密钥调度如下:
PSK (由 NewSessionTicket 下发)
│
├─> Early Secret = HKDF-Extract(0, PSK)
│ ├─> client_early_traffic_secret = Derive-Secret(Early Secret, "c e traffic", ClientHello)
│ │ └─> key / iv (用于加密 Early Data)
│ └─> binder_key = Derive-Secret(Early Secret, "res binder", "")
│
└─> (EC)DHE 共享密钥
└─> Handshake Secret = HKDF-Extract(Derive-Secret(Early Secret,"derived",""), DHE)2
3
4
5
6
7
8
9
关键点在于 client_early_traffic_secret 完全由 PSK 决定,与本次连接的 DHE 无关。前向安全性在这里断了:攻击者只要拿到 PSK(比如从一台被入侵的终端上导出 session ticket 的加密密钥),就能解密所有使用该 PSK 的 Early Data。RFC 8446 对此的表述很直白,0-RTT 数据不具备前向保密性,也不具备抗重放性。
1.3 binder 校验为什么挡不住重放
ClientHello 里携带的 pre_shared_key 扩展附带一个 binder 值,计算方式是 HMAC(binder_key, Transcript-Hash(Truncate(ClientHello)))。它的作用是证明客户端确实持有 PSK,防止攻击者伪造身份。
binder 不提供任何新鲜性。攻击者截获一个完整的 ClientHello(含 binder)后,把整个字节流重放给服务端,binder 校验同样会通过,因为 Transcript-Hash 的输入完全一致。服务端唯一能识别“这是旧包”的手段,是维护一份已见 ClientHello 的记录,或者要求 ClientHello 里带时间戳。这就是 RFC 8446 §8 反重放章节的全部立足点。
重放攻击的实际杀伤面
2.1 攻击模型与抓包证据
中间人不需要解密任何东西。在公共 Wi-Fi、被劫持的家用路由器、或者运营商侧的透明代理上,抓到一个 ClientHello + Early Data 的 TCP 段,原样重发即可。Wireshark 里过滤条件写:
tls.handshake.type == 1 && tls.handshake.extensions.supported_version == 0x0304命中后看 tls.handshake.extensions.early_data 是否存在,再跟随 TCP 流看紧随其后的加密记录。如果客户端在 ClientHello 之后立刻发了 ChangeCipherSpec 与 application_data 记录,这个连接就是 0-RTT。
更隐蔽的变体是“延迟重放”。攻击者不追求实时,而是在几小时后重放,此时服务端的 client hello recording 缓存可能已经过期。RFC 8446 §8.1.2 建议的窗口是“至少几个 RTT”,工程上通常配置为 10 秒到 60 秒,超过这个窗口的重放理论上应被拒绝,但需要服务端真的实现了时间戳校验。
2.2 幂等性边界在哪里断掉
GET 请求天然幂等,重放一次只是多一次读。真正的事故集中在三类接口:
- POST 创建类接口,重放会产生重复订单、重复扣款。
- 带副作用的 GraphQL mutation,一个重放可能触发两次库存扣减。
- WebSocket 或 gRPC 长连接的首包,如果首包是认证或订阅指令,重放会导致重复订阅、重复计费。
RFC 8446 §8 明确要求应用层协议定义哪些请求可以放进 Early Data。HTTP 侧的事实标准是 RFC 8470 定义的 Early-Data: 1 请求头,配合 425 Too Early 状态码。服务端收到带 Early-Data: 1 的请求,如果判定不安全,返回 425,客户端在握手完成后用 1-RTT 重发。
2.3 前向安全缺失带来的审计问题
即使不考虑重放,0-RTT 数据在密钥泄露后的可解密性也是合规审计的硬伤。金融与医疗类系统在等保或 PCI DSS 评估中,通常要求所有传输数据具备前向保密。把敏感字段放进 Early Data 会在审计时被直接标记。工程上的做法是:0-RTT 只承载缓存键、静态资源路径、非敏感的查询参数,任何涉及凭证、金额、个人标识的字段一律推迟到 1-RTT。
RFC 8446 规定的三种反重放机制
3.1 单调计数器与 client hello recording
RFC 8446 §8.1.2 给出两条路径。第一条是服务端为每个 PSK 维护一个单调递增的接受计数器,客户端在 obfuscated_ticket_age 里带上 ticket 签发后经过的毫秒数(加上一个由 ticket 派生的混淆值),服务端据此判断这个 ClientHello 是否落在可接受窗口内。第二条是记录已接受的 ClientHello 哈希,配合时间窗口拒绝重复。
服务端反重放状态表(每 PSK 一行)
┌──────────────┬────────────────┬──────────────────┬─────────────┐
│ PSK identity │ 已接受最大 age │ ClientHello 哈希集 │ 窗口截止时间 │
├──────────────┼────────────────┼──────────────────┼─────────────┤
│ 0x9f3a... │ 18432 ms │ {h1, h2, h3} │ now + 30s │
└──────────────┴────────────────┴──────────────────┴─────────────┘2
3
4
5
6
单调计数器方案的代价是服务端必须为每个活跃 PSK 保存状态,在千万级并发下内存开销不可忽视。哈希记录方案在窗口期内同样需要存储,窗口越长存储越大。两者都无法在无状态负载均衡下直接工作,除非把状态外置到 Redis 之类的共享存储,而这又引入了一次网络往返,抵消了部分 0-RTT 收益。
3.2 单次 ticket 与状态外置的取舍
最省事的实现是单次使用 ticket:服务端下发 NewSessionTicket 时把 ticket 写入 Redis 并设置 TTL,客户端恢复时服务端原子地删除该 key,删除成功才接受 Early Data。这等价于把“记录 ClientHello”简化为“记录 ticket 是否已被消费”。
代价是每次恢复都要访问一次 Redis,跨可用区时这次访问是 1 到 3 毫秒。相比省下的 100 毫秒 RTT,这个代价可以接受,但要注意 Redis 故障时的降级策略:宁可拒绝 0-RTT 退回 1-RTT,也不能在存储不可用时放行。
3.3 时间窗口的工程取值
窗口太短会误杀正常客户端。移动网络下客户端可能因为切基站、进电梯导致 ClientHello 延迟数秒才到达。窗口太长则给延迟重放留出空间。实际配置里 10 秒是常见下限,30 秒是折中值,超过 60 秒基本没有安全意义。
需要特别注意的是 obfuscated_ticket_age 的精度问题。部分客户端实现里这个值基于单调时钟,设备休眠后可能不准确。服务端不能把它当作绝对时间,只能当作“大致经过时间”,配合服务端自己记录的 ticket 签发时间做交叉校验。
反向代理侧的落地实现
4.1 Nginx 的 ssl_early_data 与 425 处理
Nginx 从 1.15.3 起支持 ssl_early_data on。开启后 Nginx 会在 TLS 层接受 Early Data,但默认不会告诉上游这是 0-RTT 请求,上游无从判断幂等性。正确做法是配合 proxy_set_header 把 Early-Data 头透传:
server {
listen 443 ssl http2;
ssl_early_data on;
ssl_session_ticket_key /etc/nginx/ticket.key;
location /api/ {
proxy_set_header Early-Data $ssl_early_data;
proxy_pass http://backend;
}
location /static/ {
# 静态资源天然幂等,无需透传,直接由 Nginx 处理
root /var/www;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ssl_early_data 变量在请求来自 0-RTT 时为 "1",否则为空。后端拿到这个头后,对非幂等方法返回 425:
@app.before_request
def reject_early_write():
if request.headers.get("Early-Data") == "1" and request.method not in ("GET", "HEAD", "OPTIONS"):
return Response(status=425)2
3
4
Nginx 本身不会自动重试 425。客户端必须自己实现 RFC 8470 规定的重试逻辑:收到 425 后在握手完成的连接上重发请求。很多客户端库(包括部分版本的 Go net/http)并未内置这个重试,需要业务代码显式处理,否则用户会看到一个莫名其妙的 425 错误页。
4.2 Envoy 的 early_data 与重放防护
Envoy 在 HttpConnectionManager 里通过 Http3ProtocolOptions 与 TLS 配置控制 0-RTT。HTTP/3 场景下 0-RTT 更常见,因为 QUIC 天然支持。Envoy 的处理方式是给上游请求打上 x-envoy-early-data 标记,同时提供了 early_data_policy 用于拒绝特定方法:
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
early_data_policy:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.DefaultEarlyDataPolicy
max_bytes: 81922
3
4
5
6
7
8
Envoy 的反重放状态默认在内存里,多副本部署时各副本独立记录,攻击者把同一个 ClientHello 重放到不同副本,每个副本都认为是首次。这是分布式部署下最容易被忽略的漏洞。解决办法是让负载均衡层做一致性哈希,把同一个 PSK 的恢复请求固定到同一副本,或者把反重放状态外置。
4.3 代理层必须关闭 0-RTT 的场景
有几类流量不适合在代理层开启 Early Data:
- 跨可用区的内部服务调用。内部链路 RTT 本来就在 1 毫秒以下,0-RTT 收益接近零,反而引入状态同步复杂度。
- 带请求体的 POST。Early Data 有 max_early_data_size 限制,大请求体根本放不进去,客户端会在握手完成后再发,0-RTT 形同虚设。
- 需要客户端证书的双向认证场景。0-RTT 阶段客户端证书尚未验证,放行 Early Data 等于绕过认证。
客户端侧的约束与重试
5.1 幂等性判断必须由客户端驱动
服务端的 425 是被动防御,客户端才是决定哪些请求进 Early Data 的第一道闸。工程上常见的做法是维护一个方法白名单,只允许 GET/HEAD/OPTIONS 走 0-RTT,且 URL 不包含任何写操作语义。对 GraphQL 这类 POST 承载查询的协议,需要解析请求体判断是否为 query 操作,解析失败一律降级。
Go 的 crypto/tls 在 Config 里通过 GetClientSession 与 ClientSessionCache 管理 PSK,但标准库不会自动把请求放进 Early Data,需要业务层显式调用 conn.WriteEarlyData。这反而是好事,强制开发者做幂等性判断。
5.2 425 重试的坑
客户端收到 425 后重试,必须保证重试的请求与原请求完全一致,且不能在重试时再次携带 Early Data。常见的实现错误是重试走了新的连接,又触发了 0-RTT,形成循环。正确做法是在连接对象上标记“已降级”,后续请求强制走 1-RTT。
另一个坑是重试的幂等性。如果原请求是 POST 且服务端在返回 425 之前已经部分执行了副作用(比如写了一半的数据库事务),重试会导致重复。所以服务端返回 425 必须保证“零副作用”,这要求后端在 Early-Data 检查上放在业务逻辑的最前面,早于任何数据库操作。
用 ConnProof 测量握手耗时
6.1 tls 子命令看握手阶段拆分
ConnProof 的 tls 子命令可以拆出 TCP 建连、TLS 握手、Early Data 各阶段的耗时:
$ connproof tls api.example.com --port 443 --verbose
resolving api.example.com ... 203.0.113.42
tcp connect : 12.4 ms
tls client hello : 0.3 ms
tls server hello : 48.7 ms
tls handshake done : 49.1 ms
tls version : TLS 1.3
cipher suite : TLS_AES_128_GCM_SHA256
session resumption : yes (PSK)
early data : accepted (max 16384 bytes)2
3
4
5
6
7
8
9
10
session resumption : yes (PSK) 与 early data : accepted 两行确认了 0-RTT 生效。如果 early data 显示 rejected,说明服务端在 ServerHello 里没有回 early_data 扩展,客户端需要退回 1-RTT 重发。
6.2 tcp 子命令排除网络层干扰
握手慢不一定是 TLS 的问题。先用 tcp 子命令看纯 TCP 建连耗时:
$ connproof tcp api.example.com --port 443 --ipv4
tcp connect : 11.8 ms
tcp rtt min/avg/max : 11.2/11.9/14.3 ms
retransmits : 02
3
4
如果 TCP 建连就要 11 毫秒,那么 1-RTT 与 0-RTT 的差距就是这 11 毫秒加上 TLS flight 的传输时间。在 RTT 为 11 毫秒的链路上,0-RTT 省下的是 11 毫秒,不是某些文章里说的“一个完整往返 100 毫秒”。省多少完全取决于物理 RTT。
6.3 diagnose 子命令做端到端校验
diagnose 会依次执行 DNS、TCP、TLS、HTTP 各层检查,适合在灰度发布后快速确认 0-RTT 是否按预期工作:
$ connproof diagnose api.example.com --port 443 --format json
{
"dns": {"status": "ok", "answers": ["203.0.113.42"], "ttl": 60},
"tcp": {"status": "ok", "connect_ms": 11.8},
"tls": {"status": "ok", "version": "TLS1.3", "resumption": true, "early_data": "accepted"},
"http": {"status": "ok", "status_code": 200, "early_data_header": "1"}
}2
3
4
5
6
7
如果 early_data_header 为 1 而业务返回了 425,说明代理层透传正常,问题在后端的幂等判断逻辑上。如果 early_data 是 accepted 但 early_data_header 为空,说明代理没有透传 Early-Data 头,后端根本不知道这是 0-RTT 请求,这是最危险的状态:既享受了 0-RTT 的速度,又完全失去了幂等保护。
6.4 多轮测量与丢包场景
0-RTT 在丢包链路上的表现需要单独测。用 --timeout 拉长超时并多次执行,观察 Early Data 是否被服务端拒绝:
$ connproof tls api.example.com --port 443 --timeout 5s --verbose
tcp connect : 12.1 ms
tcp retransmit : 1 (SYN)
tls server hello : 214.6 ms
early data : rejected2
3
4
5
出现 SYN 重传时,ClientHello 可能晚于服务端的反重放窗口到达,服务端拒绝 Early Data 是正确行为。这种场景下客户端应该无感降级,而不是报错。测试时要把这种降级路径也覆盖到。
长连接首包的 0-RTT 优化
7.1 WebSocket 与 gRPC 的首包策略
WebSocket 的 Upgrade 请求本身是 GET,幂等,适合走 0-RTT。但 Upgrade 成功后发送的第一帧如果是订阅指令,就不该放进 Early Data。工程上的做法是:Upgrade 请求走 0-RTT,Upgrade 完成后的首帧走 1-RTT。
gRPC 的 stream 首包通常是 unary 调用,方法语义由 proto 定义决定。可以在 proto 里给方法打上 idempotent = true 的注解,客户端据此决定是否走 Early Data。没有注解的方法一律降级。
7.2 与 TLS 基础校验的联动
0-RTT 跳过了证书链验证,因为 PSK 恢复握手根本不传证书。这意味着 0-RTT 连接的安全性完全依赖于上一次完整握手的验证结果。如果上一次握手时证书校验被绕过(比如测试环境关闭了校验),后续所有 0-RTT 连接都会继承这个漏洞。证书校验的完整讨论见 TLS 基础与证书校验,0-RTT 场景下更需要确保首次握手是严格校验的。
7.3 握手异常与重放的关联排查
线上偶发的握手失败有时与 0-RTT 状态不一致有关。比如服务端重启后 PSK 状态丢失,客户端仍带着旧 ticket 尝试恢复,服务端会回 HelloRetryRequest 或者直接拒绝。这类问题的排查思路与 TLS 握手期间异常断开 里描述的 EOF 场景类似,区别在于 0-RTT 的失败往往表现为“连接建立成功但 Early Data 被静默丢弃”,客户端看到的是超时而非 RST。
如果服务端配置了多域名且证书与 SNI 不匹配,0-RTT 恢复可能因为 PSK 绑定的 SNI 与本次请求不一致而被拒绝,这类问题的定位方法可以参考 证书域名不匹配排查。
生产环境的配置清单与验证
8.1 服务端配置核对表
ssl_early_data on仅在确认后端支持 425 的 location 上开启,静态资源区可以独立开启。ssl_session_ticket_key必须显式配置并定期轮换,轮换周期与反重放窗口对齐,避免旧 ticket 在轮换后仍被接受。- 反重放状态存储(Redis 或内存)必须有监控,存储不可用时自动关闭 0-RTT。
- 后端必须实现
Early-Data: 1检查,且检查位置在所有业务逻辑之前。 - 日志里记录每个 0-RTT 请求的 PSK identity 哈希,便于事后审计重放。
8.2 客户端配置核对表
- 方法白名单只含 GET/HEAD/OPTIONS,POST 默认不走 0-RTT。
- 实现 425 重试,且重试时强制降级到 1-RTT。
- 连接对象上维护“已降级”状态,防止重试循环。
- 敏感字段(token、金额、身份证号)在序列化层拦截,禁止进入 Early Data 缓冲区。
8.3 用 ConnProof 做上线前验证
上线前的验证流程建议固化成脚本:
connproof diagnose api.example.com --port 443 --verbose
connproof tls api.example.com --port 443 --verbose
connproof tcp api.example.com --port 443 --ipv42
3
三步分别确认端到端链路、TLS 层 0-RTT 状态、网络层 RTT 基线。把三次输出里的 early data 字段与 early_data_header 字段做交叉比对,两者一致才说明代理透传链路完整。灰度期间对每个新版本重复执行,把结果存档,重放漏洞往往是在某次代理配置变更后被悄悄引入的。
0-RTT 的收益是确定的,一个 RTT 的节省在跨地域 API 调用上就是几十到上百毫秒。代价同样是确定的:前向安全缺失、重放攻击面、分布式状态同步。这三者都无法通过配置开关消除,只能通过幂等性约束与反重放状态管理把风险压到可接受范围。判断标准很简单,如果一个接口被重放两次会导致业务状态错误,它就不该出现在 Early Data 里,无论省下的那点延迟多诱人。