HTTP/2快速重置攻击:从日志看出被取消的流

一、Rapid Reset 到底重置了什么

2023 年 10 月披露的 HTTP/2 Rapid Reset(CVE-2023-44487)不是某个软件的漏洞,而是协议层面一个可被滥用的设计自由度。它的做法非常朴素:客户端在一条 TCP 连接上不断发起新请求,然后立刻发送 RST_STREAM 帧把这个流取消掉。

HTTP/2 允许多路复用,一条连接上可以同时存在多个并发流,每个流有独立的编号和状态机。客户端取消请求是正常行为——用户点开一个页面又立刻关掉、浏览器切换页面、前端组件卸载时 abort 掉 fetch,都会产生 RST_STREAM。服务端收到取消后要销毁流状态、释放内存、停止处理。Rapid Reset 的问题在于,取消这个动作太便宜了:一个几字节的控制帧,对客户端几乎零成本,而服务端在收到它之前往往已经开始干活了。

具体损失落在三处。第一,创建流本身要分配头部表、解码 HPACK 的头部块、在 stream 表里登记,这些都要内存和时间。第二,很多服务端在解析出请求路径后就会立刻派发到后端应用,等取消帧到达时后端已经在执行 SQL 或者读文件。第三,取消流不代表取消工作,PHP-FPM 已经收下的请求会跑完,Java 线程池里的任务通常也不会被打断。

所以攻击效果是「用小带宽换取大 CPU」。公开的披露信息里,单机可以跑到每秒上百万流,放大器倍数在几十到几百倍不等。对个人站长来说,这条信息的意义不是「我要被国家级攻击了」,而是:你的站只要开了 HTTP/2,就可能被一个廉价 VPS 打到服务不可用,而且流量统计上看起来一点都不异常——因为攻击者根本没下载任何内容。

二、先确认你到底开没开 HTTP/2

很多人的印象来自配置文件,但真正生效的是握手协商结果。用 curl 直接看协议版本:

curl -s -o /dev/null -w '%{http_version}' https://www.example.com/
# 输出 2 或 1.1
curl -sI https://www.example.com/ | head -5
# 看 server 头,确认前面是 Nginx / OpenResty / CDN

只测首页还不够,要测真实页面和静态资源。CDN 回源方式和边缘节点配置可能让你在源站看到 1.1、在边缘看到 2。用浏览器开发者工具的网络面板看 Protocol 列最直观,Chrome 显示 h2 就是 HTTP/2,http/1.1 则没有。

同时要搞清楚链路上有几层会解析 HTTP/2。典型链路是「CDN 边缘 → 源站 Nginx」。如果边缘已经把 HTTP/2 转换成 HTTP/1.1 再回源,那么源站不会直接承受 Rapid Reset,压力会落在 CDN 那一层。这时候你的应对重点就不是改自己服务器的配置,而是去问 CDN 服务商有没有对应的缓解策略、有没有流量清洗阈值。相反,如果 CDN 用的是 HTTP/2 回源(一些 CDN 支持 HTTP/2 to origin),风险就会直接传导到源站。

三、Nginx 侧的四种收口手段

确认开了 HTTP/2 之后,按「性价比」排序,有四件事值得做。

第一件:升级到已修复的版本

Nginx 在 1.25.3 和 1.24.0 的补丁里加入了 keepalive_requests 与流并发比的限制逻辑,OpenResty、Apache、Caddy、Envoy、Go 的 net/http 也都各自修复了。先看版本:

nginx -v
# 输出 nginx version: nginx/1.18.0 (Ubuntu)

如果你的版本低于修复线,升级是唯一的根治手段。Debian 11 自带的 1.18 系列、Ubuntu 20.04 的 1.18 都在影响范围内,需要从官方源或者 backports 装新版。升级前务必确认 nginx -V 里的模块路径与现有第三方模块(比如某些 CDN 厂商定制模块)兼容,最小可用路径是先在一台非生产机上跑一遍完整配置。

第二件:限住单连接上的流创建速率

Nginx 的 http2_max_concurrent_streams 只限「同时打开」的流数量,不限「单位时间新建」的流数量。Rapid Reset 恰恰是「同时只有几个活跃流,但每秒新建成千上万个」。所以单靠这一个参数挡不住,但把它从默认的 128 降到 32 或 64 仍然值得做——它压缩了攻击者维持的窗口。

http {
    http2_max_concurrent_streams 64;
}

server {
    listen 443 ssl http2;
    server_name www.example.com;

    keepalive_requests 1000;      # 单连接最多处理多少个请求
    keepalive_timeout  65s;
    keepalive_time   1h;
}

keepalive_requests 的作用是:一条连接处理满 N 个请求后就主动关闭,逼客户端重连。攻击者如果被限制在单连接 N 个流,就得不断握手建新连接,成本立刻上升。这个值不能设太小,正常浏览器的并发加载也走同一条连接,设成 100 会把长轮询、SSE 类应用打断。1000 到 10000 是比较稳妥的区间。

第三件:在反向代理层限制连接数与请求速率

如果你前面还有一层 Nginx(或者自己就是边缘),用 limit_conn 和 limit_req 按 IP 收口:

http {
    limit_conn_zone  $binary_remote_addr zone=perip_conn:10m;
    limit_req_zone   $binary_remote_addr zone=perip_req:20m rate=20r/s;
    limit_conn_log_level warn;
    limit_req_status 429;
    limit_conn_status 429;
}

server {
    limit_conn perip_conn 30;
    limit_req  zone=perip_req burst=60 nodelay;
}

关键是把 limit_req_status 改成 429 而不是默认的 503。503 在很多监控体系里被算作「服务端错误」,会让告警大面积误报;429 语义明确,也便于在日志里单独统计。另外攻击常常来自一批 IP 轮换,单 IP 阈值只能抬高成本,必须配合日志聚合才看得出全貌。

第四件:观察被取消的流

这是最有诊断价值、却最少人做的一件事。Nginx 的 $request_completion 变量在请求没有正常完成时会有不同的值,配合日志格式可以统计「被客户端取消」的比例:

log_format rt '$remote_addr - [$time_local] "$request" '
              '$status $body_bytes_sent '
              'cancelled=$request_completion '
              'rt=$request_time';
access_log /var/log/nginx/access.log rt;

正常站点的 cancelled 比例通常在很低的一位数百分比,因为浏览器取消请求确实存在但不多。如果某个时段这个比例跳到 30% 以上,同时请求总量暴涨、平均响应字节数接近 0,基本可以判定是被 Rapid Reset 扫了。这个判据比看带宽可靠得多——攻击的带宽曲线几乎是平的。

四、一个真实形态的排查推演

假设你在某个下午收到告警:站点响应变慢,PHP-FPM 进程数打满,但带宽监控毫无异常,出网流量和平时一样。按前面的判据走一遍:

先看连接数。ss -s 显示 TCP 连接数不高,但 ss -tn state established '( sport = :443 )' | wc -l 数量正常。再看工作进程 CPU,top -H -p $(pgrep -d, nginx) 显示几个 worker 的 CPU 接近 100%。此时如果只看连接数,会得出「没有攻击」的错误结论。

接着看日志里的 499。Nginx 对「客户端在响应前关闭连接」记录 499,但这里有个陷阱:Rapid Reset 引发的取消发生在 HTTP/2 流层,很多情况下 Nginx 根本来不及写一条访问日志,或者日志里是 200 但 $request_completion 为空。所以单看 499 的数量可能并不多,要看 request_completion 为空的比例。

再看后端。php-fpm status 页面(如果开了 pm.status_path)会显示 active processes 打满、listen queue 增长。这一层的信息说明「请求确实被打到了后端」,但后端看到的请求数可能远大于完成数。

最后一环是确认取消行为:用 ngrep 或者 tcpdump 抓一小段 443 的流量,如果走的是明文 HTTP/2 prior knowledge 就能直接看到帧类型;走 TLS 的话看不到明文,只能从「连接建立后极短时间就出现大量数据又迅速断开」的模式推断。更实际的做法是临时在测试机上关闭 HTTP/2 观察 5 分钟——如果 CPU 立刻回落,基本就坐实了。

这个推演的价值在于顺序:先看 CPU 再看连接数,不要反过来。Rapid Reset 的整套特征就是「低连接数、低带宽、高 CPU、低完成率」,用传统 DDoS 的指标(带宽、pps、连接数)去找会一无所获。

五、该不该直接关掉 HTTP/2

最省事的止血手段是把 listen 443 ssl http2 改成 listen 443 ssl,让所有连接回落 HTTP/1.1。这确实有效:HTTP/1.1 没有多路复用,取消一个请求就要么关掉整条连接,要么等响应读完,攻击者的成本大幅上升。但它有真实代价。

HTTP/1.1 下浏览器对单域名只有 6 个并发连接,页面上的图片、CSS、JS 会排队。一个典型的图片较多的页面,首屏加载时间可能增加 20% 到 50%,这对 SEO 和转化都有影响。另外 HTTP/2 的头部压缩能省下可观的请求字节数,移动网络下收益更明显。

所以这是一个「攻击窗口期内临时降级、攻击结束后恢复」的操作,而不是长期方案。执行时要写进应急预案,明确谁有权降级、降级后多久复评、恢复前要看哪几个指标。没有预案的临时改动,最大的风险是改完忘了改回来,几个月后你发现站点莫名其妙比同行慢。

六、长期防线:把成本推回给攻击者

单机配置只能抬高成本,真正的长期防线在链路更前面。

第一层是 CDN。绝大多数 CDN 在 2023 年底之后都上线了 Rapid Reset 的缓解,边缘节点会在流创建速率异常时直接拒绝整条连接。如果你的站流量不大,把源站藏在 CDN 后面、并且严格限制源站只接受 CDN 回源 IP(用 allow/deny 或者安全组),攻击者连不到源站,问题就转移给了更有能力的服务商。

第二层是防火墙的 HTTP/2 层检测。云厂商的 WAF、以及一些开源方案(比如 Coraza 配合支持 h2 的网关)能对「单位时间 RST_STREAM 数量」做阈值告警。这一层的价值主要是告警而不是拦截,因为阈值定低了会误杀正常用户。

第三层是业务侧的兜底。真正被攻击时,能让站点「至少首页还能打开」的,往往是静态化:把首页、栏目页缓存成 Nginx 的静态文件或者 CDN 边缘缓存,让攻击流量打不到 PHP。这一步和 HTTP/2 无关,但它是所有 DDoS 场景里最普遍的降损手段。

七、给个人站长的取舍

说实话,绝大多数个人站不会成为 Rapid Reset 的目标——它更适合刷排行榜、打竞品、给勒索做筹码。但它有几个特点让「顺手被打到」的概率并不低:工具已经公开且易用,一次攻击的成本可能只有几美元,攻击者往往批量扫一批域名。你的站如果有点流量、又跑在一台小 VPS 上,被打中一次就是几小时的不可用。

措施成本有效性建议
升级 Nginx 到修复版本低高必做
keepalive_requests 降到 1000极低中必做
http2_max_concurrent_streams 降到 64极低中建议做
按 IP 限速(429)低中建议做
日志记录 request_completion极低—(观测)必做
源站只允许 CDN 回源中高流量上来后必做
临时关闭 HTTP/2中(性能损失)高仅应急预案

顺序很重要:先做「升级 + 日志观测」,这两件事加起来不到十分钟,却能让你在真被打时立刻判断出发生了什么。限速和 CDN 收口放在后面,因为它们会改动线上行为,需要观察一段时间。

八、小结

Rapid Reset 的教训不在具体漏洞,而在于它暴露了一个普遍现象:我们的监控体系是为「流量型攻击」设计的,带宽、连接数、QPS 这三个指标对新型攻击几乎全部失效。真正有效的观测是「完成率」——请求有没有正常写完、流有没有被取消、后端任务有没有被打断。

对个人站长而言,把 $request_completion 加进日志格式、把 Nginx 升到修复版本、把 keepalive_requests 显式写出来,这三件小事花不了半小时,却把「被打时两眼一抹黑」变成了「十分钟内定位」。安全工作的性价比,往往就在这种低成本高信息的观测能力上。

Last modification:November 5th, 2026 at 07:26 pm

Leave a Comment