TLS1.3 0-RTT 重放攻击风险,如何在压测脚本里开启并验证安全性
解读
面试官想知道三件事:
- 你是否理解 0-RTT 重放攻击的本质——攻击者把第一次握手时客户端发出的 early-data 原封不动地重放给同一服务器,服务器若无条件接受,就会重复执行“有副作用”的业务动作。
- 你是否知道压测脚本里“开启 0-RTT”不等于“制造重放”,而是要在脚本里主动制造重放场景,并观察服务器是否按预期拒绝或幂等处理。
- 你是否能把“安全验证”量化成可落地的断言:响应码、业务流水号、幂等键、early-data 拒绝计数器、应用日志关键字等,最终体现在压测报告里。
知识点
- TLS1.3 0-RTT 流程:ClientHello + early-data → Server 若允许则回 Pre-Shared Key 扩展并直接开始 early-data 传输。
- 重放攻击成立条件:early-data 被原样重放且服务器无额外去重或幂等机制。
- 国内常见 Web 容器:OpenResty 1.21+、Tengine 2.3+、Envoy 1.18+、.NET 6+、JDK 11+(通过 Jetty 10、Netty 4.1.75、Tomcat 10.1)均已支持 0-RTT,但默认关闭 early-data。
- 压测工具链:
- JMeter 5.5 通过 JSSE + JDK11 可启用 early-data,需把
jdk.tls.client.enable0RTT=true写入 system.properties,并在 HTTP 采样器前加 JSR223 PreProcessor 手动复用同一 PSK。 - Gatling 3.9 基于 Netty,需在 gatling.conf 打开
enable0RTT=true,并用exec(ws("replayed").sendText("sameBody"))两次发送同一 payload 模拟重放。 - k6 0.45 通过
k6/experimental/tls扩展,设置enableEarlyData: true,再用http.post两次复用同一 TLS session ticket。
- JMeter 5.5 通过 JSSE + JDK11 可启用 early-data,需把
- 服务端防护手段:
- OpenResty 配置
ssl_early_data on;同时加set $replay_id $ssl_early_data_ext;与共享字典做 5 秒滑动窗去重; - Envoy 在
early_data_policy里配replay_protection: true,并挂外部 Redis 去重; - 业务层幂等键(订单号、UUID)写入 early-data,服务器收到后先查幂等表。
- OpenResty 配置
- 压测断言指标:
- 重放请求应返回 425 (Too Early) 或 400 + 自定义“Replay-Detected”头;
- 业务库订单数应等于首次成功数,重放请求不应落库;
- Nginx 指标
ssl_early_data_rejected在重放阶段应递增; - 应用日志出现 “reject replay sn=xxx” 次数 = 重放请求数。
答案
示范用 JMeter 在 Linux 压测机(JDK11,阿里云 ECS)对 OpenResty 网关做 0-RTT 重放验证,步骤如下:
-
服务端准备
OpenResty 编译参数--with-openssl=/opt/openssl-3.0.9,nginx.conf 片段:ssl_early_data on; ssl_conf_command Options WaitBeforeRetry; lua_shared_dict replay_dict 10m; access_by_lua_block { local sn = ngx.var.ssl_session_reused local ext = ngx.var.ssl_early_data_ext or "" if sn == "r" and ext ~= "" then local key = "r:" .. ext if ngx.shared.replay_dict:get(key) then ngx.exit(425) end ngx.shared.replay_dict:set(key, 1, 5) end } -
客户端脚本
在 JMeter 的 system.properties 追加:jdk.tls.client.enable0RTT=true jdk.tls.enableSessionTicketExtension=true线程组:1 并发,循环 2 次,每次均复用同一 SSL Session。
HTTP 采样器路径/order,POST 体{"id":"${UUID}"},Header 带Early-Data: 1。
第一次采样器后置 BeanShell 把响应中的orderId保存到 JMeter 变量oid。
第二次采样器把同一oid原样再发一次,模拟重放。 -
断言与监控
- 第一次期望 201,JSON 断言
$.status==success。 - 第二次期望 425,若返回 201 则标记安全失败。
- 用 JMeter Backend Listener 把结果打到 InfluxDB,Grafana 看板对比“0-RTT 首次成功率”与“重放拒绝率”,后者必须 100%。
- 同时 ssh 到服务器
tail -f /var/log/nginx/error.log | grep replay应出现一次 reject 记录。
- 第一次期望 201,JSON 断言
-
报告输出
压测结束导出 HTML 报告,在“安全验证”章节给出:- 重放请求数 = 1000,被拒绝 1000,拒绝率 100%;
- 订单表落库数 = 1000,无重复主键冲突;
- Nginx
ssl_early_data_rejected计数器增长 1000。
结论:0-RTT 已开启,重放攻击被有效拦截,满足上线 SLA。
拓展思考
- 如果业务必须允许 0-RTT 且无法改代码,如何把“去重”下沉到网关层而不影响吞吐?
答:用 Redis Cluster 存 <session-id, timestamp>,Lua 脚本保证原子 compare-and-set,压测时把 Redis 延迟也纳入 SLA(p99 < 2 ms)。 - 当压测目标为 QUIC(基于 TLS1.3)时,0-RTT 重放风险同样存在,工具链可切到 k6+xquic 或 aioquic,验证思路不变,但需关注 CID 与 Token 变化对重放识别的影响。
- 长期稳定性场景:让脚本持续 6 小时、每 30 秒批量重放早期抓到的 early-data,观察服务器是否会因为“时间窗过大”或“内存泄漏”导致误判放行,最终输出“重放误接受率”曲线,作为是否缩短时间窗或升级 OpenSSL 的依据。