ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

HTTP/2 的限流为什么挡不住:三种计数错配

HTTP/2 的限流为什么挡不住:三种计数错配 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-47774CVSS 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 的确认作为同步点让协议的同步时刻尽量贴近真实的处理边界。第一种这个限制项设的是编码字节还是解码字节如果设置项的名字里看不出来就去读实现里它被拿去比较的那个变量是什么单位。三处问完会得到一张清单上面写着哪些限制器在给自己计的名哪些在给被保护的资源计量。这张清单本身比任何一个阈值都更有用因为它指出了哪些限制的失效不是“调大调小”的问题。这三个顺序不是按危害排的而是按能否在本项目内查清排的。第三种完全在自己的代码里第二种要对着规范核对第一种要读别人的实现或者把自己的阈值按最坏压缩比重新推一遍。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表