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 为空时不生效,白名单与分级处置都可以靠这个特性做得干净利落。