TLS1.3 0-RTT 重放攻击风险,如何在压测脚本里开启并验证安全性

解读

面试官想知道三件事:

  1. 你是否理解 0-RTT 重放攻击的本质——攻击者把第一次握手时客户端发出的 early-data 原封不动地重放给同一服务器,服务器若无条件接受,就会重复执行“有副作用”的业务动作。
  2. 你是否知道压测脚本里“开启 0-RTT”不等于“制造重放”,而是要在脚本里主动制造重放场景,并观察服务器是否按预期拒绝或幂等处理。
  3. 你是否能把“安全验证”量化成可落地的断言:响应码、业务流水号、幂等键、early-data 拒绝计数器、应用日志关键字等,最终体现在压测报告里。

知识点

  1. TLS1.3 0-RTT 流程:ClientHello + early-data → Server 若允许则回 Pre-Shared Key 扩展并直接开始 early-data 传输。
  2. 重放攻击成立条件:early-data 被原样重放且服务器无额外去重或幂等机制。
  3. 国内常见 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。
  4. 压测工具链:
    • 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。
  5. 服务端防护手段:
    • OpenResty 配置 ssl_early_data on; 同时加 set $replay_id $ssl_early_data_ext; 与共享字典做 5 秒滑动窗去重;
    • Envoy 在 early_data_policy 里配 replay_protection: true,并挂外部 Redis 去重;
    • 业务层幂等键(订单号、UUID)写入 early-data,服务器收到后先查幂等表。
  6. 压测断言指标:
    • 重放请求应返回 425 (Too Early) 或 400 + 自定义“Replay-Detected”头;
    • 业务库订单数应等于首次成功数,重放请求不应落库;
    • Nginx 指标 ssl_early_data_rejected 在重放阶段应递增;
    • 应用日志出现 “reject replay sn=xxx” 次数 = 重放请求数。

答案

示范用 JMeter 在 Linux 压测机(JDK11,阿里云 ECS)对 OpenResty 网关做 0-RTT 重放验证,步骤如下:

  1. 服务端准备
    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
    }
    
  2. 客户端脚本
    在 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 原样再发一次,模拟重放。

  3. 断言与监控

    • 第一次期望 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 记录。
  4. 报告输出
    压测结束导出 HTML 报告,在“安全验证”章节给出:

    • 重放请求数 = 1000,被拒绝 1000,拒绝率 100%;
    • 订单表落库数 = 1000,无重复主键冲突;
    • Nginx ssl_early_data_rejected 计数器增长 1000。
      结论:0-RTT 已开启,重放攻击被有效拦截,满足上线 SLA。

拓展思考

  1. 如果业务必须允许 0-RTT 且无法改代码,如何把“去重”下沉到网关层而不影响吞吐?
    答:用 Redis Cluster 存 <session-id, timestamp>,Lua 脚本保证原子 compare-and-set,压测时把 Redis 延迟也纳入 SLA(p99 < 2 ms)。
  2. 当压测目标为 QUIC(基于 TLS1.3)时,0-RTT 重放风险同样存在,工具链可切到 k6+xquic 或 aioquic,验证思路不变,但需关注 CID 与 Token 变化对重放识别的影响。
  3. 长期稳定性场景:让脚本持续 6 小时、每 30 秒批量重放早期抓到的 early-data,观察服务器是否会因为“时间窗过大”或“内存泄漏”导致误判放行,最终输出“重放误接受率”曲线,作为是否缩短时间窗或升级 OpenSSL 的依据。