☰
HTTP/2 的限流为什么挡不住:三种计数错配
2026/10/21 20:54:16 网站建设 项目流程

HTTP/2 的限流机制全部长成同一个样子:一个计数器加一个上限。并发流数由SETTINGS_MAX_CONCURRENT_STREAMS限制,头部占用由SETTINGS_MAX_HEADER_LIST_SIZE一类的设置限制,连接级缓冲由流控窗口限制。这套东西的设计前提是计数器计的那个东西,就是你想限制的那个资源。从 2023 年的 Rapid Reset 到 2026 年的一批记录,被反复利用的正是这个前提的裂缝。而裂缝不止一种形状:计数单位可能与资源的单位不同(按编码字节计,而占用在解码之后才发生);计数器可能因某个协议事件递减,而资源的生命周期由协议之外的因素决定;计数器可能只守一个字段,而分配发生在它旁边的另一个字段。这三种形状各自有明确的记录。把它们分开看,比把它们合成“HTTP/2 DoS”更有用,因为三种形状对应的修法不同。

并发流上限计的是状态,不是工作量

先从最基础的那个计数器看起。RFC 9113 §5.1.2 规定了哪些流会占用并发额度:

Streams that are in the “open” state or in either of the “half-closed” states count toward the maximum number of streams that an endpoint is permitted to open. Streams in any of these three states count toward the limit advertised in the SETTINGS_MAX_CONCURRENT_STREAMS setting. Streams in either of the “reserved” states do not count toward the stream limit.

三个状态:open、half-closed (local)、half-closed (remote)。closed不在这个列表里。规范的措辞很确定,这是“哪些状态计数”的完整清单,没有留余地。也就是说,一条已经关闭的流不占用并发额度,无论它触发的工作是否还在跑。这个规定的合理性在于它的视角:SETTINGS_MAX_CONCURRENT_STREAMS的本意是限制对端同时打开多少条流,这是一个协议状态层面的事情。谁在什么时候打开流,是协议能观测到的;而“这条流引发的后端处理是否结束”,协议看不见。而攻击面就落在这个不可见的地方。2023 年的 Rapid Reset 做的是:客户端快速打开流、发出请求头、然后立刻发RST_STREAM取消。从协议的角度,每条流从 open 走到 closed,耗时极短,因此并发额度几乎不被占用;而服务端在收到请求头之后就已经把请求转给了后端,取消通知到达后端需要时间,后端的工作已经在做了。攻击者用极小的协议资源,换到了极大的后端工作量。

NVD 对 CVE-2023-44487 的描述用的是request cancellation can reset many streams quickly——“快速重置很多流”。这句描述的落点是计数器的行为(重置使流快速离开计数状态),而不是攻击者的手段。CVE-2023-44487 的 CVE 记录本身很薄(没有标题、metrics 为空、受影响产品是占位值),它的实质内容在那份 144 条的厂商引用清单里。要引用它的 CVSS 必须去 NVD,不能引 CVE 记录。

第一次错配:计数单位是编码字节,资源占用在解码之后

第二种形状与 HPACK 有关,而它是三种里最“结构性”的一种。

HPACK 是 HTTP/2 的头部压缩格式,它的设计目标之一是让重复的头部只传一次:静态表与动态表把常见的字段名与值预先编码成索引,后续只传索引号。一个字节的线上数据因此可以展开成很长的头部值。

SETTINGS_MAX_HEADER_LIST_SIZE这个设置的作用是限制头部列表的大小,但它的字面含意是编码后的字节数还是解码后的字节数,各个实现的理解不一致。Envoy 记录里的 CVE-2026-47774(CVSS 3.1 7.5,分类为 CWE-405 与 CWE-770)把这一点写成了两处并存的错配:

cookie header bytes are not fully accounted for during request header size validation

以及

HPACK header block limits … are enforced on encoded bytes without a corresponding limit on total decoded header size

第一处是“某个字段没被计入”(下文会作为第三种形状单独讲),第二处正是这里要说的:限制施加在编码字节上,而内存占用发生在解码之后。一个精心构造的头部块可以在很小的编码长度里展开出很大的解码结果,因为 HPACK 的动态表允许重复引用。同样的形状在 h2o 的 CVE-2026-54340 里有更明确的表述。那份记录的摘要写的是h2o has HTTP/2 state amplification,细节里说明放大的方式是把 HPACK 解压与停滞的流结合起来,并给出结论:需要额外的限制来约束解码后的头部状态。把这两处放在一起,这条错配的定义就清楚了:限制器测量的是“传输量”,被保护的资源是“解码后的驻留量”。两者在正常情况下近似成正比,所以按前者限制后者看起来够用;而 HPACK 的设计目的就是打破这个正比关系,让重复的内容传得更小。压缩比越高,这两者的偏离越大,而压缩比正是攻击者可以选的东西。

Go 在 2026 年的两条记录也落在这个形状附近。GO-2026-6603说的是请求尾部的处理可以绕过Server.MaxHeaderValueCount与MaxHeaderBytes两个上限;GO-2026-6611说的是大量小的 SETTINGS 帧(携带SETTINGS_INITIAL_WINDOW_SIZE)会让 CPU 消耗远超一个帧应有的量。后者的错配方向稍有不同:一个 SETTINGS 帧在计数上是一,而按 RFC 9113 §6.9.2 的规定它要求端点按差值调整所有流的窗口,也就是说,一帧的工作量是 O(流数),而计数器把它记成了 1。这里的计数单位是“帧”,被保护的资源是“由这一帧触发的计算量”,而规范本身要求了这个乘数。

第二次错配:计数器因协议事件递减,资源的生命周期由后端决定

第三种形状是 2025 年的新东西,也是“MadeYouReset”这个名字的由来。它的机制建立在第一次错配的同一块基础上,但触发方向相反。Rapid Reset 里是客户端发RST_STREAM;而 CVE-2025-8671(分类 CWE-404) 的做法是让服务端发RST_STREAM,客户端通过畸形帧或流控错误去引发服务端主动重置。这个区别不是修辞,而是这一整类问题的分界线。看一下 CERT/CC 那份公告的原文:

Streams reset by the server are considered closed, even though backend processing continues… when a stream is reset, the protocol considers it no longer active and no longer accounts for it within this counter.

服务端自己发出的重置,同样会让流从计数里消失。于是出现一个局面:服务端在压力下主动重置流以保护自己,而主动重置这个动作恰好释放了并发额度,让攻击者可以再开新流,重置成了攻击的推进器,而不是它的刹车。这里有一处措辞需要小心,因为两份来源不一致。CERT 公告的概述部分把触发归于客户端“rapidly triggering the server to reset them”,而同一份公告里讲SETTINGS_MAX_CONCURRENT_STREAMS的段落写的是 “when a stream is reset by the attacker”。字面上像是指攻击者发了重置。CVE-2025-8671 的记录给出了澄清式的表述:客户端触发、服务端发出的重置。这个区分是整个机制的关键,如果攻击者自己发重置,那不过是 2023 年的做法;正因为重置由服务端发出,才说明“服务端主动保护自己”这个动作本身可以被变成攻击的一部分。同一份 CVE 记录还带着一句自我声明:This CVE will be updated as affected product details are released.也就是说,它的受影响产品清单是一个会变的快照,引用时必须注明观测日期,不能当作稳定事实。记录里还有一个厂商名写成H20(几乎可以确定是 h2o 的笔误),引用时照抄会传递错误,改写又等于修正他人记录,这一处只能绕开。

RFC 9113 其实给了这一类的对策,而且写得很清楚。§5.1 在讲关闭状态的流时给了一条操作建议:

Endpoints SHOULD NOT use timers for this purpose.

“这个目的”指的是判断对端是否还在处理。规范的建议是不要靠计时器去猜,而是用 SETTINGS 或 PING 的确认作为同步点——§6.5.3 定义了这套确认机制:收到 SETTINGS 的对端必须立即回一个带 ACK 的 SETTINGS,而发方在收到 ACK 之后才能确信自己发的那组值已经生效。规范把这条写成了SHOULD NOT,而实现里靠计时器判断“对端是否还活着”是常见做法。这里存在一个可以单独检查的点:如果某个实现用超时来判断流是否真的结束,那么它对“服务端主动重置”这类事件的资源记账就会偏松;如果它用 SETTINGS/PING 的确认,那么记账与真实状态之间的窗口会窄得多。这条判据可以直接读代码得到答案,不需要构造流量。

Tomcat 在这一族里有一处修复把后果说得最具体:CVE-2025-48989 的记录里,这个问题的表现是The denial of service typically manifested as an OutOfMemoryError,堆内存耗尽。也就是说,“流关闭了但工作还在”这个错配,最终落在的是内存上,而不是 CPU 上。这一条与前面讲的形状合在一起,说明了同一个计数器可以同时被多种资源绕过。在进入第三种错配之前,先看一处确实管用的计量,因为它提供了一个对照。RFC 9113 第 8.1.1 节规定,一个请求或响应在什么情况下算畸形:

if the value of a content-length header field does not equal the sum of the DATA frame payload lengths

这条规则的做法与前面两种都不同:它不是在给某个量设上限,而是要求两个独立的计量互相对账。声明了一个长度,实际收到的 DATA 帧载荷之和必须与之相等。任何一侧记账出错都会被这个等式发现,因为它不依赖哪个计数是对的,只要求两者一致。把这条与前面两种错配放在一起,可以看到一个区分。前两种错配的共同点是只有一个计量:限制器在计,被保护的资源没有自己的独立计量,所以限制器算错了没有东西能发现。而这里这种互相对账的做法天然免疫单侧记账错误。这也是为什么这一族问题的修法常常是再加一个独立的限制,而不是把现有阈值调准,加独立限制的效果是让两个计量互相约束,与这条对账规则是同一个思路。

第三次错配:计数器守一个字段,分配发生在旁边

第三种形状是三种里最“日常”的一种,也是最容易在代码审查里发现的一种。它的模式是:限制器检查某个字段,而实际分配发生在相邻的另一个字段上。字段之间在语义上相关,在代码里也可能是相邻的两行,但限制器只覆盖了其中之一。

Envoy 记录的第一处错配就是这一个:cookie header bytes are not fully accounted for during request header size validation。头部大小校验在算总长度时没有把 cookie 的字节完全算进去,而 cookie 恰恰是最容易膨胀的头部字段之一,它由服务端自己下发,攻击者只要多访问几次让它们累积起来即可。

Go 的GO-2026-6603是同一形状的另一个实例:请求尾部(trailer)的处理可以绕过两个头部相关的上限。尾部的语义是“跟在正文后面的头部字段”,与头部在语法上同源,因此共享限制器看起来自然;但限制器实现时盯的是头部那一侧,尾部那一侧成了出口。同一形状还有一个更细的变体,落在流量控制这个机制上,GO-2026-6612记录了它:

refund connection-level flow control twice for the same data … bypass the configured connection-level flow control limit

同一次数据被退还了两次额度,于是连接级流控窗口这个计数器不再反映真实缓冲量。这条记录同时给了一个有用的对照:它明确指出即使连接级窗口被绕过,“总缓冲数据仍受并发流上限与流级流控限制”。这句话的价值在于它点出了剩余边界的位置,一个计数器被绕过之后,真正管住资源的是另一个计数器,而那个计数器是否也属于前两种错配之一,是可以逐个检查的。把三处放在一起,这一形状的共性就清楚了:限制器与分配点在代码里的位置相关,而不是在语义上重合。检查头部大小的代码与分配 cookie 内存的代码可能在同一段里;限制 trailer 的代码与分配Request.Trailermap 的代码可能相邻。谁都没写错,只是限制器的覆盖范围与分配的覆盖范围是两个不同的集合。

三种形状为什么修法不同

现在把它们分开,因为分开之后才看得出“为什么同一条修复不能覆盖另一条”。第一种(编码 vs 解码)的修法是改变计量的时机。只要限制器测量的还是线上字节,压缩比就会成为攻击者可以调节的杠杆。Envoy 与 h2o 的记录都指向同一个方向:给解码后的状态加一个独立的限制。这不是把阈值调小,调小只能改变放大倍数的可容忍范围,改不了“计量对象不对”这件事。第二种(事件 vs 生命周期)的修法是把记账的依据从协议事件换成后端的实际状态。而这个方向有一个根本困难:协议层看不到后端的处理是否结束。所以可行的做法不是“记准”,而是别让计数器的释放成为攻击者可用的资源——RFC 9113 §5.1 那条“不要用计时器”的建议正是这个意思,用确认点代替猜测,让协议的同步点与真实的处理边界尽可能贴近。而只要后端处理与协议状态之间没有强制的同步机制,这个窗口就消不掉,只能收窄。第三种(守一个字段,分配在隔壁)的修法最直接:把覆盖范围对齐。它是三种里唯一可以靠“补一处检查”解决的,因为限制器与分配点在同一个实现里,两者都能被读到。它的问题也不是设计上的,而是实现上的,检查与分配由不同的代码路径完成时,两者的字段集合很容易长歪。这里有一处很具体的困难:限制器的计量单位往往在设置项的名字里看不出来。

SETTINGS_MAX_HEADER_LIST_SIZE里的 “SIZE”指的是什么?RFC 9113 给它的定义是“对端愿意接受的头部列表的最大尺寸”,而这个“尺寸”按 §6.5.2 的说明是未压缩的大小,规范的意图是解码后的量。但实现里这个设置常常被拿去和一个按编码字节计的内部缓冲区比较,因为那个缓冲区是实现的代码里真实存在的东西,而“解码后的量”要等解码完成才知道。于是规范定义的语义与实现里的现成量之间有一处缺口,而填这个缺口的方式是各实现自己选的。这也解释了为什么第一种错配在这批记录里出现得最频繁:它不是某个实现写错了,而是“按现成的量记账”这个做法在压缩存在时就不成立。要发现它,需要问一个不在配置项名字里的问题:这个限制是在解码之前生效还是之后生效。三种形状里,前两种的根在协议设计的边界(协议能观测什么、观测不到什么),第三种在实现的覆盖范围。这个区分决定了一个部署能自查到什么程度:第三种读自己的代码就能发现;第二种要对着规范的同步机制检查(有没有用计时器、有没有用确认点);第一种需要判断自己的限制器到底在给什么计量,而这个问题往往要读依赖库的配置语义才能回答,因为“限制的是编码字节还是解码字节”在设置项的名字里通常看不出来。

一处需要保留的不一致

最后把材料里的几处矛盾记下来,因为它们会影响引用。

Tomcat 有一份记录 CVE-2026-78437 在厂商页上的等级是Low,而 NVD 给它 7.3 HIGH。看具体内容,厂商把它描述为“一个用户的请求可能让另一个用户的请求失败”,分类是 CWE-459(未完成的清理),它是跨请求隔离问题,不是资源放大问题。把这一条算进“HTTP/2 限流”这一族是不对的。

nginx 的 CVE-2026-95101 也有归类问题。它在两份 CVSS 里的影响评级都是I:LOW, A:NONE,分类是 CWE-172(编码处理不一致),实质是 HTTP/3 的请求体注入。它不属于放大类。而这处记录的正文在 F5 的登录页之后不可读,只引用可读的等价来源,因此这里不对其细节做描述。Tomcat 在这一族里还有另一份记录 CVE-2026-68763,它的形态是第三种错配的一个变体:记录写的是An allocation leak in the HTTP/2 backlog tracking when a stream was reset,在流被重置时,为积压跟踪分配的对象泄漏了。这里计数与分配都没有算错,出问题的是“流被重置”这个事件触发了一条清理路径,而那条路径漏掉了这个对象。前两种错配是“账记错了”,这一种是“东西没还”,但它同样落在同一个交汇点上:重置这个协议事件被当成了资源生命周期的终点,而实际上有一个分配对象没有被这个事件覆盖。还有一处是规范内部的:RFC 9113 §5.1 说收到关闭状态流上的帧应当“最小处理然后丢弃”,而 §6.1 说在 open 或 half-closed 之外的流上收到 DATA 帧要报STREAM_CLOSED流错误。同一个帧、两处不同的处置建议。这一处张力是本文从两段原文对照读出的,规范没有把它标成一个问题,但它说明“关闭”这个状态上的行为在规范里本身就不是完全单义的,而实现要在其中选一种。操作层面还有一处困难:这一族问题很难有统一的版本排查方式。同一条错配形状在不同实现里的修复版本互不相关,而且修复往往落在各自的内部计数器上,不会出现在对外接口的变化里。CVE-2025-8671 的记录自己声明受影响清单会随产品细节的发布而更新,这等于承认它给不出一个稳定范围;而 CVE-2023-44487 的 CVE 记录里受影响产品是占位值,任何范围都得回到厂商公告。实际可行的做法是按形状去查,而不是按 CVE 去查:把自己的实现按那三种错配顺序过一遍,看每一处限制器计的是什么,然后对照该实现对应版本的发布说明。按 CVE 查的前提是有人已经把你的实现对应到某条记录上,而这一族的记录在这一点上恰恰是最不可靠的部分。

Rapid Reset 与 MadeYouReset 都没有对应的 IETF RFC。后者在 CVE 记录的引用清单里有一份个人的 Internet-Draft(关于 HTTP/2 流限制),但它从未成为 RFC,这意味着这一族问题上没有一份规范文本可以当作判据,能用的只有 RFC 9113 里那几条关于状态与同步的建议。

有一件事对三种形状都成立:这些机制的失效方式都不是“某个实现写错了”。

Rapid Reset 与 MadeYouReset 利用的是规范对“流何时算结束”的定义;HPACK 的放大利用的是压缩本身的设计目标;Envoy 与 h2o 的头部分配利用的是“编码字节与解码字节不必成比例”这一语言事实;Go 的 SETTINGS 放大利用的是规范要求“一帧调整所有窗口”这条 MUST。四处都是规范或设计原本就允许的可能性,而不是实现偏离了规范。 这个区分决定了后面三种形状的分析方式——要找的不是谁违反了规定,而是规定本身在哪里留下了记账与资源之间的缺口。

三种形状为什么会在同一族里同时出现

这三种错配不是三个独立的缺陷类别,它们在同一个协议族里集中出现有共同原因。共同原因是 HTTP/2 的限流机制全部建立在“用协议可见的量去约束协议不可见的资源”这个做法上。协议能观测到的是帧、流的状态迁移、线上的字节;而真正被消耗的是内存、CPU、后端连接。这两组量之间没有恒定的换算关系,压缩比让字节与内存脱钩,后端处理时间让状态迁移与工作量脱钩,实现的分配结构让字段与内存脱钩。三个脱钩点各自产生一种错配形状。这个解释也说明为什么修法是“加独立限制”而不是“调准阈值”:阈值调准只在一个换算比下成立,而三个脱钩点都是攻击者可以调节的。Envoy 与 h2o 分别给解码后的头部状态加了独立限制,道理在这里,独立的限制等于在不可见的资源侧也放一个计量,让两个计量互相约束。反过来看,这也解释了 §8.1.1 那条对账规则为什么是有效的:它同时拥有两个计量。这一族里所有真正管住的限制,都是两个计量互相约束的形式;所有被绕过的限制,都是单个计量加一个阈值。这个区分可以当成一个通用判据用。

一个可以直接用的检查顺序

三种形状给了一个可以照着走的检查顺序。先查第三种,因为成本最低:把自己的头部大小校验、尾部分配、cookie 处理、流控额度退还这几处的字段集合列出来,比对限制器的覆盖面与分配的覆盖面是否一致。这一步只需要读自己的代码。再查第二种,因为它决定了第一种的后果有多大:找出所有“用超时判断对端状态”的地方,看能不能换成 SETTINGS 或 PING 的确认。规范在 §5.1 与 §6.5.3 里已经把这两条路径的区别写清楚了。最后查第一种,因为它最依赖对依赖库的理解:把每一个“头部大小”类的限制项找出来,确认它计的是编码字节还是解码字节;如果答案是前者,那么它约束的不是内存。Envoy 与 h2o 的记录都指向同一个结论,解码后的头部状态需要一个自己的上限,而不是靠编码字节的上限去近似。具体到每一处,可以问的问题是这样。第三种:这个限制器校验的字段集合,与它后面那次分配的字段集合,是不是同一个?如果不是,差的那几个字段由谁负责。第二种:这里的超时是为了判断什么?如果是判断对端是否还在处理,那 RFC 9113 §5.1 已经给了替代做法,用 SETTINGS 或 PING 的确认作为同步点,让协议的同步时刻尽量贴近真实的处理边界。第一种:这个限制项设的是编码字节还是解码字节?如果设置项的名字里看不出来,就去读实现里它被拿去比较的那个变量是什么单位。三处问完,会得到一张清单,上面写着哪些限制器在给自己计的名,哪些在给被保护的资源计量。这张清单本身比任何一个阈值都更有用,因为它指出了哪些限制的失效不是“调大调小”的问题。这三个顺序不是按危害排的,而是按能否在本项目内查清排的。第三种完全在自己的代码里;第二种要对着规范核对;第一种要读别人的实现或者把自己的阈值按最坏压缩比重新推一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询