HTTP/2 CC攻击怎么防?连接复用让限流失效

HTTP/2 CC攻击能在一条 TCP 连接里并发几十上百个请求,按连接限流直接失效。本文讲清日志怎么确认协议、Nginx 要改哪几个参数、回源场景怎么拿真实 IP。

Q:HTTP/2 下 CC 攻击到底哪里不一样?

  • 一条 TCP 连接承载几十到上百个并发 stream,攻击者维持一两条连接就能打出几千 QPS。
  • 头部用 HPACK 压缩,单个请求的头部从几百字节降到几十字节,按字节数限速的方案直接失灵。
  • 全站走 TLS 后,中间设备看不到逐请求内容,只能靠边缘解密或行为特征判断。
  • 结果是老指标全废:连接数很少、握手频率很低,但源站 CPU 打满、PHP-FPM 队列堆死。
  • 和 HTTP/1.1 时代的 CC 对比一下就明白:h1 想堆并发只能开更多连接,连接数会暴涨,看连接数就能发现;h2 把并发藏进了一条连接里,监控面板上看不出任何异常。

Q:为什么 limit_conn 在 HTTP/2 下基本没用?

  • limit_conn 数的是连接,不是请求。攻击者维持 2 条连接,计数永远是 2,阈值设成 1 也拦不住。
  • 按 SYN 数量、握手频率识别同样失效:一条连接就能打完几万个请求。
  • 但 limit_req 仍然按请求计数,依然有效,前提是 key 必须是客户端 IP,不能是连接。
  • 所以 HTTP/2 场景要把主防线从连接数移到请求数加单连接请求上限这两个维度。

Q:怎么从日志确认打过来的是 HTTP/2?

  • Nginx 有两个变量:$server_protocol(值是 HTTP/2.0)和 $http2(h2 / h2c / 空)。
  • 再配上 $connection_requests,才能看出少连接、多请求这个典型形态。
# 1) 协议分布:HTTP/2.0 占比突然升高要警觉
awk '{print $(NF-1)}' access.log | sort | uniq -c | sort -rn | head

# 2) 单 IP 在单条连接上最多跑了多少请求($connection_requests 取倒数第 3 列)
awk '{c = $(NF-2) + 0; if (c > max[$1]) max[$1] = c}
     END {for (ip in max) print max[ip], ip}' access.log | sort -rn | head -20
  • 正常浏览器一条连接也就几十个请求,上千就可以定性为复用刷量。
  • 字段位置跟着你的 log_format 走,先用 head -1 核对一遍再跑。

Q:Nginx 上要改哪几个参数?

  • 核心是限制单连接能跑多少请求,把一条连接打十万请求变成每条连接打几百个,攻击者就必须反复做 TLS 握手。
# 单连接允许的并发 stream 上限(默认 128 太宽松)
http2_max_concurrent_streams 16;

# 一条连接处理满这么多请求就发 GOAWAY,强制重建连接
keepalive_requests 200;

# 空闲连接不要长挂
keepalive_timeout   15s;
http2_idle_timeout  10s;

# 顺带把头部缓冲区收紧,防 HPACK 相关的头部膨胀
large_client_header_buffers 4 8k;
  • keepalive_requests 是性价比最高的一招,直接抬高攻击成本;但别设到 20 以下,移动端弱网会明显变慢。
  • stream 上限设太小会拖慢图片多的页面,因为浏览器并发被限死,16 到 32 是比较稳的折中。
  • 改这几个参数必须灰度:先 1000 观察一天,再到 500、200,每次只看一个指标——首屏时间和 429 比例。

Q:限流规则应该怎么写?

  • 请求速率和连接数两级一起上,但分工要清楚:limit_req 是主防线,limit_conn 只负责挡开几千条空连接的那种轰炸。
limit_req_zone  $binary_remote_addr zone=h2_ip:20m   rate=8r/s;
limit_conn_zone $binary_remote_addr zone=h2_conn:10m;

server {
    listen 443 ssl;
    http2 on;                       # 1.25.1 之前写 listen 443 ssl http2;

    location / {
        limit_req  zone=h2_ip   burst=20 nodelay;
        limit_conn zone=h2_conn 10;
        limit_req_status  429;
        limit_conn_status 429;
        add_header Retry-After 30 always;
    }
}
  • 对刷量用 429:可观测、前端能退避、误杀统计还能算;444 断连虽然也能逼重连,但你的日志和归因全丢了。
  • 只有确认恶意的固定连接才用 444,而且要在影子日志里留一条记录。

Q:前面套了 CDN,源站看到的全是 CDN 的 IP 怎么办?

  • CDN 回源也走长连接,源站所有请求的 $remote_addr 都是回源节点 IP,这时候按 IP 限流等于在限 CDN。
  • 必须开 real_ip,并且只信任你自己 CDN 的回源段,别把整个内网都写进去。
set_real_ip_from 203.0.113.0/24;    # 换成你 CDN 的回源 IP 段
set_real_ip_from 198.51.100.0/24;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;
  • 配置放 server 段即可,real_ip 在 rewrite 阶段生效,早于 access 阶段的 limit_req,所以限流拿到的是真实客户端 IP。
  • 注意一个坑:CDN 用 HTTP/2 回源时,源站看到的 $server_protocol 也是 HTTP/2.0,别因此误判成攻击来自 h2 客户端。
  • 如果 CDN 不传真实 IP 或只在特定头里传,先在边缘加一条日志核验,别靠猜。

Q:该盯哪些指标判断是不是被刷?

  • 单 IP QPS:正常低于 2,持续高于 10 就有问题。
  • 单连接请求数:正常几十,异常上千,这是 HTTP/2 场景最有辨识度的指标。
  • 429 比例:平时 0 到 1%,突增说明规则在起作用,也可能是规则设太紧误伤。
  • TTFB 与 PHP-FPM 队列:QPS 涨而连接数不涨、队列堆积,就是复用型刷量。
  • 出口带宽:HPACK 压缩后带宽涨幅很小,靠带宽告警基本抓不到,必须看请求数。
  • 上游错误率:upstream timed out 与 502 的比例同步上升,说明压力已经穿透到后端,这时候先限流再谈优化。
  • 把这些指标做成一张同屏面板,攻击发生时的形态是:单 IP QPS 涨、单连接请求数涨、连接数不动、带宽几乎不动。四个数里三个涨一个不涨,就是复用刷量的签名。

Q:攻击者为什么偏爱 HTTP/2?

  • 握手成本被摊薄:一次 TLS 握手换几万请求,TCP 慢启动只经历一次。
  • 高延迟链路上也不掉速:一个 RTT 内并发多个 stream,跨境肉鸡也能打出效率。
  • 头部压缩省带宽,同样的成本能发更多请求,防守方按流量计费时更吃亏。
  • 所以连接数监控的时代已经过去了,请求数才是主指标。

Q:HTTP/3 会不会更难防?

  • 会更麻烦:QUIC 支持连接迁移,客户端换 IP 连接不断,按 IP 做的统计天然被打散;0-RTT 还带来重放面。
  • 加固思路是一致的——限请求数、限 stream、限单连接请求量,但要放在边缘(CDN/WAF)做,源站看不到 QUIC 的细节。
  • 源站这一侧的现实做法是:只允许 CDN 回源,边缘把 HTTP/3 收掉再转成 HTTP/1.1 或 h2 回源,把复杂度留在边缘。

Q:还有什么补充建议?

  • 先把日志补齐 $server_protocol、$connection_requests、$msec 三个字段,没有数据所有判断都是猜。
  • 上线前用 h2load 自测:单连接开 100 并发打首页,看限流规则到底有没有触发,别等真被打了才发现规则写错。
  • 规则要留回滚路径,改 keepalive_requests 这类全局参数时准备两套配置,出问题五分钟内能切回去。
  • 记住 limit_req 的 key 为空时不生效,白名单与分级处置都可以靠这个特性做得干净利落。