迟到 9 + length 字节的检查:CVE-2026-88975,http4s Ember HTTP/2 帧校验为何形同虚设
CVE-2026-88975(CVSS 3.1 7.5,CWE-400)是 http4s Ember HTTP/2 实现的未认证资源消耗漏洞:读循环在缓冲完整 9 + length 字节之前不做任何帧大小校验,对端可以在 9 字节帧头里声明一个接近 24 位上限的单帧长度,并持续发送接近 16 MiB、但仍不足额的负载,使当前未完成帧的累积缓冲 acc 驻留约 16 MiB——约为 16 KiB
本文基于 GHSA-gq9p-f254-h286(CVE-2026-88975)维护者公告、0.23.36/0.23.37 双版本源码比对,以及本仓库 Docker 内网的一手复现实验(服务端 6 连接 OOM、客户端单连接 OOM,补全了公告未复现的客户端链路)撰写。全部动态结论的镜像、命令与原始日志见 repro/README.md 与 research-notes/;证据编号沿用 evidence-inventory.md;代码引用基准见 repos/README.md;文中
E#/S#/X#/F#/D#/R#等证据编号的含义见文末「附录:证据编号速查」。
摘要:CVE-2026-88975(CVSS 3.1 7.5,CWE-400)是 http4s Ember HTTP/2 实现的未认证资源消耗漏洞:读循环在缓冲完整 9 + length 字节之前不做任何帧大小校验,对端可以在 9 字节帧头里声明一个接近 24 位上限的单帧长度,并持续发送接近 16 MiB、但仍不足额的负载,使当前未完成帧的累积缓冲 acc 驻留约 16 MiB——约为 16 KiB 配置上限的 1024 倍(公告容量估算;不是相对实际发送字节数的放大倍数)。以默认 maxConnections=1024 计,公告估算全部连接的单帧容量合计约 16 GiB。本文逐行还原 readLoop → RawFrame.fromByteVector → processFrame 的数据流,指出旧校验层的三重失效——时机(检查位于完整帧解析之后)、可达性(未完成帧永远到不了检查)、方向(Headers/PushPromise 检查误用对端可控的 remoteSettings.maxFrameSize,此点为本文超出公告的增量结论,自 2021 年初始移植即存在)——并补充完整帧在比较量和帧型覆盖上的缺口,分析 peekDeclaredLength 修复如何把校验移回信任边界内侧。文末给出检测特征、缓解决策树与横向审计清单。
1. 影响定位:这是一类什么问题
先定性。CVE-2026-88975 属于 CWE-400(Uncontrolled Resource Consumption,资源消耗失控),不是内存破坏:攻击者不需要破坏指针或取得代码执行,却能控制协议长度声明,使解析器反复走未完成帧的累积分支,把「接收方承诺的内存」变成「发送方声明的函数」。GitHub CNA 给出的 CVSS 3.1 向量为 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H——网络可达、攻击复杂度低、无需权限、无需用户交互,且只影响可用性(A:H)。其中 AC:L 与 PR:N 两维直接来自攻击链:触发只需要一条 TCP 连接上按序到达的帧头与部分负载,不依赖竞争窗口或内存布局,因此 AC:L;不需要任何应用层凭证,因为漏洞位于连接建立的握手阶段之后、应用处理之前,因此 PR:N。
影响范围必须按「工件 + 配置」两个条件收敛,缺一不可。漏洞本体在 http4s-ember-core 模块的 H2 连接实现中,由 ember-server 和 ember-client 两个入口暴露读路径。维护者公告列出的包级范围是 org.http4s:http4s-ember-core_2.12 ≤0.23.36,以及 _2.13、_3 的 ≤0.23.36 与 1.0.0-M1..M47,修复版为 0.23.37 / 1.0.0-M48。但依赖树里出现旧工件,并不等于暴露:HTTP/2 在两端 builder 上都默认关闭,必须显式调用 .withHttp2 才会构建 H2 连接路径。资产盘点时这两个条件要同时核对——只 grep 出 http4s-ember-core 的旧版本号就报警,或只看到没开 H2 就放行,都会误判。
// http4s@6c8f2edd → ember-server/shared/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala:201-202、:351
// ember-client/shared/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala:210-213、:431 完全同构
def withHttp2: EmberServerBuilder[F] = copy(enableHttp2 = true)
def withoutHttp2: EmberServerBuilder[F] = copy(enableHttp2 = false)
// ……同文件 apply 的默认参数列表(:351):
enableHttp2 = false,时间线分两条看——代码线与披露线——不能混成一个「暴露窗口」。
代码线:方向错误的检查最早出现在 2021-12-03 的提交 064c5bcb5("Initial Core Port"),这是 Ember H2 core 代码进入仓库的时刻;该提交先在开发分支上停留近两个月,才经 2022-01-26 的合并提交 6f4666afd("Merge pull request #5657 from ChristopherDavenport/h2Support";该 PR 页面在本次检索中缓存缺失,不能转述其 review)进入主线,三天后(2022-01-29)就是 v0.23.9 的 tag 提交。对 12 个正式 tag 的抽样显示,最早包含 Ember H2 core 模块的发布 tag 是 v0.23.9 / v1.0.0-M31(2022 年初),更早的 v0.23.8 / M30 完整 tree 中没有该模块——所以代码提交日不等于公开发布暴露起点。
这两个最早 tag 携带的旧 Streams 读循环就已具备同一「帧未完整则继续累积」原语与 remoteSettings 方向的晚检查(S13/S17);2022-03-21(作者时间)69a67490d 重写出的现行 readNextFrame 自 v0.23.12 / M33 出现——重写只是实现切换,不是漏洞引入点,也不是修复点;12 个 tag 中也没有出现「只补入口检查」或「只修设置方向」的中间形态,方向错误是随初始移植整体进入、直到 87cf334fa 才与其它检查一并删除。v0.23.36/M47 的核心读帧文件与 v0.23.37/M48 的修复文件 blob 分别相同(S-A1 版本矩阵)。
披露线:修复提交 87cf334fa 的 Git 作者名为 sshevchenko(GitHub 提交页显示 stasimus),作者时间 2026-09-06 03:17:25 UTC,进入维护分支的提交时间 2026-09-08 02:39:23 UTC;两个修复 tag 紧随其后(v0.23.37 tagger 时间 2026-09-08 00:19:14 UTC−04、v1.0.0-M48 00:33:19 UTC−04),v0.23.37 发行说明把本 GHSA 列在 Security fixes,M48 是同日的前向合并。
维护者 2026-09-08 发布仓库 GHSA,公告把 stasimus 记为 Reporter——这与修复提交作者在 GitHub 提交页上是同一可见身份;GitHub 全球库条目于 9 月 15 日收录并 review,CVE 官方记录 2026-09-15 发布(其 dateReserved 为 2026-09-10,ID 预留与公开披露是两个事件)。
公开材料既未定位到修复提交的关联 PR/code review(GHSA 页面把 rossabaker 记为 remediation reviewer、提交页显示其为 committer,属公告署名与提交操作而非修复内容的评审记录),也未找到报告者的原始报告文本、最初报告日或私下讨论——只能写「未定位到/未找到」,不能反推私下报告日期,也不能写成「没有经过 review」;GHSA 正文是否修订过同样无法判定。
从 2021-12-03 到 2026-09 是相关失效模式的代码历史跨度(约 4 年 9 个月),期间读循环经历上述重写而失效模式未变;把它写成「公开发布暴露窗口」或「报告日」都是不准确的。抽样 12 个 tag 也不能穷尽全部中间版本,公告的包级范围与逐 tag 实际代码需要分别对待(R3 时间线)。
评分与收录状态只作定位参考。7.5 分是 GitHub CNA 提供的;截至 2026-09-24,NVD 记录状态为 Deferred——已收录(2026-09-15)并展示该 CNA 分数,但无 NVD 独立评分项,configurations CPE 字段缺失,API 中的 affected/weaknesses 亦为 CNA 来源(R1 NVD 核查)——不能写成「NVD 独立评出 7.5」,也不能据 CNA 的产品级范围扩大或缩小公告范围。CISA ADP 附加的 SSVC 评估(exploitation=none / automatable=yes / technicalImpact=partial)中,automatable=yes 与向量里的 AC:L、「一条连接按序发字节即可触发」互相印证;exploitation=none 与 KEV 未收录(2026-09-23 目录快照)一样是评估时点的判断,只说明尚未达到收录标准或未走完流程,不构成「无在野利用」的证明。
2. 协议视角:RFC 9113 给了早拒绝的权限
理解这个漏洞需要一个协议层面的前提:HTTP/2 的帧长度校验,标准本来就允许在负载到达之前完成。
RFC 9113 §4.1 定义了 9 字节帧头,结构固定(按到达顺序的纵向重排见图 1):

图 1:HTTP/2 帧结构(纵向重排)。3 字节长度声明先于它所描述的负载进入接收方——声明已到、负载未必在。
前 24 位是不含帧头的负载长度,最大可表达 16,777,215 字节。关键在于到达顺序:这 3 个长度字节先于它所描述的负载进入接收方——长度是一份「声明」,接收方在读到第一个负载字节之前就已经知道对端承诺了多少。与之配套的是 §4.2 的两条规则:SETTINGS_MAX_FRAME_SIZE 约束单个帧的负载上限,且这个参数是接收方在自己的 SETTINGS 帧里宣告、约束发送方的;接收方收到超过自己宣告上限的帧,必须报 FRAME_SIZE_ERROR。这个方向性不是引申:§6.5.2 对该设置的定义就是「SETTINGS 发送方愿意接收的最大帧负载」(此处「发送方」即该 SETTINGS 帧的发出者、也就是后续数据帧的接收方),因此入站帧长的合法基准只有本端宣告值一条链,对端宣告值只应出现在本端出站分块器里(R2 横向调研)。
对会改变整个连接状态的帧——包括 HEADERS、PUSH_PROMISE、CONTINUATION、SETTINGS 以及任何 stream ID 为 0 的帧——帧长错误必须按连接错误处理(GOAWAY 而非 RST_STREAM);但反过来说,并非每个 FRAME_SIZE_ERROR 都必须升级为连接错误。
把「帧头先给出长度」与「超限应报错」合读,可以推出一个实现层面的结论:接收方在缓冲完整负载之前,就有足够信息判断这帧是否超限,因而可以提前拒绝、不必先读完 Length 字节再报错。需要强调的是,这是从 §4.1–§4.2 推导出的实现推论,也是维护者公告与修复实现采用的解释;RFC 正文没有「不得读取负载」这样的逐字表述(证据 X6/F1)。修复提交里 peekDeclaredLength 的 scaladoc 援引 §4.2,同样属于这种推导性引用。
由此可以画出本文反复使用的信任边界图(图 2)。三类信息的信任级别不同:

图 2:Ember 旧版读循环的信任边界与两处误用——第 ① 类对端输入成了完整性门槛的书写者,第 ② 类对端输入被误用作入站上限,第 ③ 类本端权威只有方向正确的 Data 检查在用。
localSettings.maxFrameSize 是本端宣告、约束入站帧的权威上限,两端连接共用同一份默认值:
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Frame.scala:526-533
val default: ConnectionSettings = ConnectionSettings(
tableSize = SettingsHeaderTableSize(4096),
enablePush = SettingsEnablePush(isEnabled = true),
maxConcurrentStreams = SettingsMaxConcurrentStreams(1024),
initialWindowSize = SettingsInitialWindowSize(65535),
maxFrameSize = SettingsMaxFrameSize(16384), // ← 图 2 ③ 的本端权威:入站单帧负载上限
maxHeaderListSize = None,
)漏洞的本质,在这张图上是一句话:旧实现把第 ① 类对端输入(声明长度)当成了第 ③ 类本端约束的守门人——校验要等「帧完整」才运行,而「帧完整」的定义(9 + length)由攻击者书写;同时另一处把第 ② 类对端输入(对端自我宣告的上限)直接当成了入站检查的基准值。这两件事分别对应第 4、5 章的两层根因。
3. 攻击场景与利用分析
3.1 前置条件清单
把利用边界的前半边列清楚,后半边(时间与容量)在 3.4、3.5 展开。攻击成立需要:
条件 | 内容 | 证据 |
组件与配置 | 目标解析 | S7、X1 |
网络位置 | 攻击者能与目标建立一条 HTTP/2 连接(直连,或前置代理不做强制帧长校验) | E1/E3/E7 |
协议交互 | TLS/prior-knowledge 路径:发送连接前言(client preface)与 SETTINGS 后即可注入攻击帧,无需任何合法 HTTP/2 请求;旧版 h2c Upgrade 路径:先提交一个可被接受的 HTTP/1.1 Upgrade 请求与 | S2、S10、E1/E9 |
认证 | 无要求。帧解析发生在流关联与 HttpApp 处理之前,认证中间件位于读循环之下的应用层——这是基于调用链的推断,与公告描述一致 | X1 |
最后一行值得展开:读循环消费的是裸 TCP 字节流,preface → SETTINGS → 任意帧即触发累积,攻击者不需要知道任何路由、不需要通过任何应用层检查。对攻击者而言,这是「协议握手级」的攻击面,比找一条未授权 API 路径要原始得多。
3.2 服务端场景
典型的适用环境是互联网(或不可信内网)可达的 Ember 服务端入口。攻击动作极简:建立 H2 连接,发送 9 字节帧头 ff ff ff 00 00 00 00 00 01——24 位长度 16,777,215、类型 0(DATA)、流 ID 1——然后持续发送不足额的负载并保持连接;要让单连接的 acc 接近 16 MiB,仍须实际发送接近 16 MiB 的负载。本仓库复现(E1):withMaxConnections(8)、96 MiB 限堆的 JVM,6 条并发连接各发 15 MiB 未完成帧,堆升至 90–92 MiB 后 OutOfMemoryError: Java heap space,退出码 1。实验连接上限 8 低于默认 1024,仍发生 OOM。若其它占用与回收行为近似不变,更大的可用堆预计需要更多同类连接或更多驻留字节;本实验未量化 OOM 所需连接数与堆大小的函数关系。
负载的发送速率与驻留时长是两个不同变量:未完成帧持续补发时,acc 随已收字节增长;停止发送后若连接仍保持,已收字节仍可能驻留(时间边界见 3.4,D9 的保持与关闭对照见 6.4)。本文未测不同速率在生产监测系统中的可见性。
3.3 客户端场景:反向攻击
公告当时对客户端影响的表述是「共用读循环,推断受影响,未复现」;本仓库 2026-09-24 的 Docker 实验补全了这条公开缺口(E3)——注意区分证据主体与时间点:公告(2026-09-08)是推断,本仓库(2026-09-24)是实测,后者不代表互联网上存在在野利用。
客户端的适用环境是启用 .withHttp2 的 ember-client 被引导访问攻击者控制的源站。真实暴露面包括:SSRF-ish 场景(用户输入决定抓取 URL)、代理/网关把上游响应当作可信、feed 拉取器、健康检查器抓取用户注册的地址、爬虫框架。触发条件按 H2Client.createSocket 分两类——明文与 TLS 走不同判定:
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Client.scala:137-167(节选)
def createSocket(
key: RequestKey,
useTLS: Boolean,
priorKnowledge: Boolean, // 来自请求属性:req.attributes.contains(Http2PriorKnowledge)(:281)
enableEndpointValidation: Boolean,
enableServerNameIndication: Boolean,
): Resource[F, (Socket[F], SocketType)] = for {
address <- Resource.eval(RequestKey.getAddress(key))
baseSocket <- Network[F].connect(address)
socket <- {
if (useTLS) {
/* TLS:握手参数带 H2 ALPN,随后 parseSocketType(tlsSocket) 按协商出的
协议返回 Http2 或 Http1——协商结果由服务端控制 */
} else {
val socketType = if (priorKnowledge) Http2 else Http1 // :162 明文连接只看本端请求属性
val out = (baseSocket, socketType)
Resource.pure[F, (Socket[F], SocketType)](out)
}
}
} yield socket明文 http:// URL 需要请求属性带 H2Keys.Http2PriorKnowledge(162 行直接按 Http2 处理);TLS URL 由 ALPN 协商决定,而协商结果由服务端控制。复现(E3):恶意源站收到完整 HTTP/2 前言(505249202a20485454502f322e30,即 PRI * HTTP/2.0),发送单连接 15 MiB 未完成帧,20 MiB 限堆的客户端从约 6 MiB 升至 19 MiB 后 OOM。
在本实验的 20 MiB 限堆条件下,客户端单连接即可 OOM;服务端实验则用 96 MiB 限堆与 6 条连接,不能跨这两种配置比较单连接的普遍杀伤力。客户端攻击另需目标访问攻击者控制的源站,服务端攻击则需攻击者能连到 H2 入口,实际暴露面要分别盘点。
3.4 三条连接建立路径与两类时间边界
0.23.36 的服务端有三条已动态验证的入口,最终全部汇入同一个 H2Connection.readLoop(S8),见图 3:

图 3:旧版服务端三条入口全部汇入共用读循环;客户端的明文 prior knowledge 与 TLS+ALPN 路径也汇入该读循环,未包含服务端的 h2c Upgrade 入口。0.23.37 移除内建服务端 h2c Upgrade 入口,并在共用读入口增加帧长预检。
三条入口的 0.23.36/0.23.37 对照(E7/D3):
入口 | 0.23.36(8 MiB 未完成 DATA) | 0.23.37 对照 |
TLS+ALPN | TLSv1.3 协商成功,堆约 7–8→19–20 MiB,无提前拒绝 | GOAWAY error 6 |
明文 prior knowledge | 堆增长至 OOM(主复现 E1) | 超限日志 + GOAWAY,无 OOM(E4) |
h2c Upgrade | HTTP 101 后进入 H2,堆约 6→17–18 MiB | 同一 Upgrade 请求返回 HTTP 200,未进入 H2 |
最后一行的 HTTP 200 不是帧长检查的结果:0.23.37 的内建 h2c Upgrade 入口由独立提交 f910d7c11(PR #7935)在发布前移除(F7)——删除对象是 Upgrade 路由、h2cUpgradeMiddleware、H2Keys.H2cUpgrade 与 101 后切 H2 的分支,明文 prior-knowledge 与 TLS+ALPN 分支保留——请求被当作普通 HTTP/1.1 处理。把「200」写成「修复后拒绝超限帧」是错误判读。这条 h2c 攻击面也不是后来才长出来的:9f4bf32d4(2021-12-04,"Complete h2c mechanism")与 H2 core 同在 v0.23.9 / M31 首入发布 tag,同样默认关闭(S-A1)。归纳攻击面:旧版的三条入口都可达,不限于「原生 H2 客户端」——能完成一次 HTTP/1.1 Upgrade 的中间件背后的 Ember 服务,同样暴露。
时间边界取决于帧的状态,这是慢速注入(slow-rate)可行性的关键,0.23.36 服务端上存在两类截然不同的计时器行为(S15/E10/D8):
- 未完成单帧的
acc:无读侧计时器。readNextFrame对未完成帧没有任何超时;声明 16,384 字节(合规长度)的 DATA 帧每秒只补 1 字节,到 30.251 秒仍无 GOAWAY、无断开。旧版 H2 读循环也没有逐次读取的 idle timeout。实验只测到 30 秒,不能外推无限时长;它表明在这组输入与配置下,慢速补发未触发连接终止。 - 已解析的半开 header 块:一次性 5 秒计时器。 完整解析
END_HEADERS=false的 HEADERS 后,ContinuationProgress启动一次性计时(服务端默认requestHeaderReceiveTimeout=5s),后续非终止型 CONTINUATION 不重置它。每秒续一片的滴灌仍在 5.014 秒收到GOAWAY(ENHANCE_YOUR_CALM)、5.034 秒断开。客户端的同一槽位由idleConnectionTime默认填 60 秒——仅静态确认,未做客户端滴灌实验(S15)。
两类边界并排见图 4:

图 4:帧的状态决定时间边界——未完成单帧的 acc 无读侧计时器(类 1),已解析的半开 header 块受一次性 5 秒计时器约束(类 2)。
两类状态的区别在于计时器何时启动:未完成单帧停在 readNextFrame,尚未创建 ContinuationProgress;完整解析 END_HEADERS=false 的起始帧后,才由这个进度对象承载一次性计时器。后者的结构是:
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Connection.scala:629-651
// 与 :402-404(processFrame 中 END_HEADERS=false 的 HEADERS 分支调用点)
final class ContinuationProgress[F[_]: Applicative, A](
val first: A,
rest: List[H2Frame.Continuation],
val size: Long,
timeout: Fiber[F, Throwable, Unit], // 一次性计时纤程:构造后引用不再更换
) {
def addContinuation(next: H2Frame.Continuation): ContinuationProgress[F, A] =
new ContinuationProgress(first, next :: rest, size + next.headerBlockFragment.size, timeout)
def complete(last: H2Frame.Continuation): F[(A, List[H2Frame.Continuation])] =
timeout.cancel *> Applicative[F].pure(first -> (last :: rest).reverse) // 唯一显式取消点
}
object ContinuationProgress {
def start[F[_]: Temporal, A](
first: A,
initialSize: Long,
timeout: Duration,
cancel: F[Unit],
): F[ContinuationProgress[F, A]] =
(Temporal[F].sleep(timeout) >> cancel.attempt.void).start // 到期执行 cancel,异常被吞
.map(new ContinuationProgress(first, List.empty, initialSize, _))
}
// 调用点(receiveHeadersTimeout 即服务端 requestHeaderReceiveTimeout,默认 5 秒):
ContinuationProgress
.start(h, headerBlock.size, receiveHeadersTimeout, goAway(H2Error.EnhanceYourCalm))
.flatMap(headers => state.update(s => s.copy(headersInProgress = Some(headers))))addContinuation 把同一个 timeout 纤程原样带进新进度对象——非终止型 CONTINUATION 到达时计时器照走不误;complete(同流 END_HEADERS=true)是这里可见的显式取消点。
两类状态的终止机制并不相同。header 计时器到期时调用 goAway:它先把 GOAWAY 放入写队列,再抛 KillWithoutMessage,而计时纤程用 .attempt.void 吞掉该异常——计时器本身并不取消读循环;真正终止的是写循环:它见到 GOAWAY 后把连接状态标记为 closed,读循环退出,H2Server 的 holdWhileOpen 每秒轮询该状态后关闭 socket。D8 观测到 GOAWAY 与 EOF 相隔约 20 毫秒,与这条异步关闭链相符;具体间隔取决于轮询相位及调度,不能由源码单独推出。源码检索还显示:只有同流 END_HEADERS=true 的 complete 会显式取消该计时器,RST_STREAM 与连接关闭路径未见取消调用;非终止型续帧同时受累计 maxHeaderBlockSize 检查,所以「5 秒」并不是续帧状态的唯一边界(S15)。这条终止链的三个环节在源码里各占几行:
// http4s@6c8f2edd → H2Connection.scala:169-174(goAway)、:218-223(writeChunk 内)与
// H2Server.scala:197-198(holdWhileOpen)
def goAway(error: H2Error): F[Unit] =
state.get.map(_.remoteHighestStream).flatMap { i =>
val g = error.toGoAway(i)
outgoing.offer(Chunk.singleton(g)) // ① GOAWAY 先进入写队列
} >>
H2Connection.KillWithoutMessage().raiseError // ② 再抛出——被计时纤程的 .attempt.void 吞掉
val firstGoAway = chunk.collectFirst { case g: H2Frame.GoAway =>
mapRef.get.flatMap { m =>
m.values.toList.traverse_(connection => connection.receiveGoAway(g))
} >> state.update(s => s.copy(closed = true)) // ③ 写循环见到 GOAWAY 才置 closed
}
firstGoAway.getOrElse(F.unit) >> go(chunk)
def holdWhileOpen(stateRef: Ref[F, H2Connection.State[F]]): F[Unit] =
F.sleep(1.seconds) >> stateRef.get.map(_.closed).ifM(F.unit, holdWhileOpen(stateRef)) // 每秒轮询这条终止链的组件视角见图 5:

图 5:计时器到期后并不直接取消读循环,而是经「GOAWAY 入写队列 → 写循环置 closed → holdWhileOpen 轮询关 socket」间接终止;D8 观测到 GOAWAY 与 EOF 相隔约 20 毫秒,间隔不是源码保证值。
两组实验的构造各有一处刻意设计:HEADERS 组的初始块为 4096 字节,内含一个声明 8192 字节、始终未完成的 x-probe 值,初始块加 30 个滴灌字节共 4,126 字节,刻意低于服务端宣告的 40,960 块上限——滴灌因此只触碰「计时器」这一个变量,不触碰块限额;DATA 组刻意声明 16,384(合规长度)而非 16 MiB——超限声明会混入「无界缓冲」这个变量,而两种声明在每秒 1 字节滴灌下对 fromByteVector 同样持续返回 None,「无计时器」的结论因此独立于超限利用本身(D8)。
也就是说,攻击者想拖时间,应该让帧停留在「未完成」状态(类 1),而不是让它完整解析后进入续帧等待(类 2)。0.23.37 叠加了两个独立的 timeout 提交:f35f69223 在连接级/写侧引入 withStallTimeout 包装(stallStart 状态字段 0.23.36 已有,出自 6e8eccd64),补上写入阻塞及等待 WINDOW_UPDATE 的终止;aba0f35d8 才把 idle timeout 应用到读路径——按每次 socket.read 等待计时、有进度即重置(peerAwaitingResponse 为真时定时分支还会延后),客户端读循环固定为 Duration.Inf,preface 读取也被单独包裹。读路径的包裹长这样:
// http4s@aba0f35 → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Connection.scala:289-300
val readFromSocket: F[Option[Chunk[Byte]]] = {
val read = socket.read(localSettings.initialWindowSize.windowSize)
readIdleTimeout match {
case timeout: FiniteDuration =>
F.race(read, F.sleep(timeout).untilM_(peerAwaitingResponse.map(!_))).flatMap {
case Left(chunk) => F.pure(chunk) // 读先完成:本轮有进度,计时随下一次调用重新起算
case Right(_) => // 计时先到:且仅在不再等待对端响应时才会走到
logger.debug(s"$addrStr readLoop idle timeout exceeded ($timeout)") >>
goAway(H2Error.ProtocolError).as(Option.empty[Chunk[Byte]])
}
case _ => read // 客户端 readIdleTimeout 固定 Duration.Inf:行为同旧版
}
}读侧机制与上面的一次性 header 计时器是两回事,且本 CVE 的超限声明在新版先被帧长预检拒绝,轮不到它出场;对合规长度的未完成帧,静态推导给出的维持代价下界约为每个读超时周期 1 字节(默认 60 秒周期即约 1/60 B/s;存在等待响应的流时定时分支延后,连这个正下界都不保证)——这是新版残余慢速面的唯一量化下界,未做动态验证(S-A3、S-A5)。
3.5 放大算术与容量边界
单帧声明的上界由 24 位长度字段决定:L = 16,777,215。帧维持在未完成状态时,当前 acc 的逻辑输入上界是 9 + L − 1 = 16,777,223 字节;跨过完成阈值的那一次默认读(每次至多 65,535 字节,S3)最多再带入 65,535 字节——即超出 9 + L 的完成阈值至多 65,534 字节——acc 暂达 16,842,758 字节。相对 16 KiB 的配置帧上限约 1024×——这是算术,不是测量。
以默认 MaxConnections = 1024(http4s@6c8f2edd → server/shared/src/main/scala/org/http4s/server/package.scala:93)计,维护者公告给出约 16 GiB 的单帧攻击容量估算。1024× 与 16 GiB 都是公告容量估算而非实测总堆量,且只描述「各连接当前未完成帧」这一项:整连接内存还包括已解析并排队在各流 channel 中的 DATA(每流 Channel.bounded(128)、每项默认最多 16 KiB 逻辑数据,即每流最多约 2 MiB,多流分别排队——流数也有默认门:远端建流前检查 mapRef 流数不超过 4 × maxConcurrentStreams,默认约 4,096 条,4096 × 2 MiB ≈ 8 GiB 的最粗乘积同样只是组件容量算术而非可达性或实测界,S16),以及节点开销、其他连接状态——这些可以与新 acc 并存(S16/E11/E14)。
S16 给出的条件式组成是「当前未完成帧 acc + Σ活跃流队列 DATA + 处理中的至多一帧/余量 + header/HPACK、输出与控制队列、运行时与网络缓冲」——它是保守加总,不表示各项最大值可同时达到,也不是通用堆界;连接另有 createdStreams/closedStreams 两个 Queue.unbounded[Int],但每项只是流 ID、消费并发,不能仅凭 API 推断远程无界增长。
实际可利用性受 JVM 堆配置、连接上限、应用消费速率和容器内存限制约束;D12 在应用不消费请求体的 8/64/256 流实验中,测到接近输入量的全进程 byte[] 叠加(详见 6.4),那是有限测量而非通用整连接堆界。同样来自 D12 的负结果值得记录:三档堆积期间均无 RST、GOAWAY、Java 或容器 OOM,也未触及每流 128 项、远端建流门或堆限——在这组输入规模下,未观察到使连接终止或阻止队列继续增长的门限;流控回补与有界 channel 仍在运行(S19/E14)。
3.6 暴露面决策树
综合入口、角色与网络位置,可直接用于资产盘点(图 6):

图 6:暴露面决策树——Q1/Q2 沿主线向下,「否」分支就地终止;Q4/Q5 按角色逐行核对;Q6 作用于全部「可达」分支。
4. 根因解剖 I:读循环数据流——完整性门槛如何变成内存承诺
协议视角指出「可以早拒绝」;本章回答实现为什么没有。服务端的完整调用链与「未完成帧」累积回路见图 7:

图 7:服务端调用链。红色节点为攻击者可控或构成驻留的环节——声明长度书写完整性门槛;未完整帧沿 acc ++ chunk 无上限循环累积;五处大小检查只位于完整帧才可达的 processFrame。
客户端对应 EmberClientBuilder.withHttp2 → H2Client.createSocket:137-167 → 同一 readLoop。漏洞全部发生在 readNextFrame 与 processFrame 之间的交界处。
4.1 readLoop 逐行
以下节选保留完整的数据流主干:从 socket 读取、两次解析到 processFrame 的驱动循环全部在列,只折叠了 processFrame 约 320 行的分派体、各分支的日志字面量与 readLoopAux 外层的异常收尾。本文代码片段均为节选——中文注释标示省略或强调之处,未标注的语句与源码逐字一致,完整实现见对应提交与行号。
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Connection.scala:240-269、:590-599
def readLoop: F[Unit] = {
def connectionTerminated: String = s"Connection $addrStr readLoop Terminated"
val readFromSocket: F[Option[Chunk[Byte]]] =
socket.read(localSettings.initialWindowSize.windowSize) // 每次至多 65,535 字节(默认)
def readNextFrame(acc: ByteVector): F[Option[(H2Frame, ByteVector)]] =
if (acc.isEmpty) {
readFromSocket.flatMap {
case Some(chunk) => readNextFrame(chunk.toByteVector)
case None => /* 对端关闭:debug 日志后返回 None,结束 */
}
} else
H2Frame.RawFrame.fromByteVector(acc) match {
case Some((raw, leftover)) =>
H2Frame.fromRaw(raw) match { // 帧型解析(不是帧长检查)
case Right(frame) => F.pure(Some((frame, leftover)))
case Left(e) => /* 无效帧型:warn + goAway(e),返回 None */
}
case None => // 帧未完整——9 + length 门槛未过
readFromSocket.flatMap {
case Some(chunk) => readNextFrame(acc ++ chunk.toByteVector) // :264 继续累积
case None => /* 对端关闭:debug 日志后返回 None,结束 */
}
}
def processFrame(frame: H2Frame, s: H2Connection.State[F]): F[Unit] = (frame, s) match {
/* ……约 320 行按帧型分派的处理逻辑,五处大小检查全部位于其中(第 5 章逐处展开)…… */
}
def readLoopAux(acc: ByteVector): F[Unit] =
readNextFrame(acc).flatMap {
case Some((frame, nacc)) =>
logger.debug(s"$addrStr Read - $frame") >>
state.get.flatMap(processFrame(frame, _)) >>
readLoopAux(nacc) // 剩余字节递归,继续下一帧
case None => F.unit
}
readLoopAux(acc) // :599 唯一入口;acc 是连接构造参数(建连时已读到的字节),
// 外层的 recoverWith/guaranteeCase 异常收尾从略按五个问题走一遍这段代码:
- 输入从哪里产生:TCP socket 上对端发来的字节,以
Chunk[Byte]形式每次至多 65,535 字节进入(initialWindowSize默认值,S3)。 - 经过哪些函数和数据结构:
acc: ByteVector是跨调用传递的累积缓冲;fromByteVector尝试把它变成RawFrame,成功后再经H2Frame.fromRaw转成带类型的H2Frame,由readLoopAux交给processFrame。 - 哪些字段可被攻击者控制:帧头前 3 字节的声明长度
length完全由对端控制,且它直接参与完整性判断。 - 哪一步执行危险操作:
fromByteVector返回None时,264 行的acc ++ chunk.toByteVector无条件、无上限地把新字节并入acc。 - 为什么校验没有阻止:大小检查全部在
processFrame里,而进入processFrame的前提是fromByteVector返回Some——对未完成帧,这个前提永远不成立。
4.2 fromByteVector:帧完整的定义由攻击者书写
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Frame.scala:56-90
final case class RawFrame(
length: Int, // 3 bytes is within int range -- 16,777,216 16 mb max frame, bigger isn't necessarily better
`type`: Byte,
flags: Byte,
identifier: Int, // 31 bit Integer
payload: ByteVector,
)
object RawFrame {
def fromByteVector(bv: ByteVector): Option[(RawFrame, ByteVector)] =
if (bv.length >= 9) {
val length = (bv(2) & 0xff) | ((bv(1) & 0xff) << 8) | ((bv(0) & 0xff) << 16) // 对端声明
if (bv.length >= 9 + length) {
val `type` = bv(3)
val flags = bv(4)
val s0 = bv(5) & 0xff
val s1 = (bv(6) & 0xff) << 16
val s2 = (bv(7) & 0xff) << 8
val s3 = (bv(8) & 0xff) << 0
val modS0 = (s0 & ~(1 << 7)) << 24
val identifier = modS0 | s1 | s2 | s3
(
RawFrame(
length,
`type`,
flags,
identifier,
bv.drop(9).take(length), // 负载此刻必须已全部驻留在 acc 里,这里只是切分
),
bv.drop(9 + length), // 剩余字节,作为下一轮的 acc
).some
} else None // ← 「帧未完整」,继续等
} else None9 + length 是解析的完整性门槛。length 来自对端,于是这个门槛的数值由攻击者书写:声明 16,777,215,攻击者只要发送不足该数的负载,fromByteVector 就一直返回 None,读循环就一直走 264 行的累积分支。安全检查以解析完整性为前置,解析完整性以攻击者履约为前置——校验的存在性被交给了输入方控制。这就是「检查存在但形同虚设」的机制核心,也是「先缓冲后校验」(buffer-then-validate)模式在二进制协议解析里的标准形态:把「凑齐协议单元」放在「检查协议单元合法性」之前,且前者的完成条件含有后者的应检字段。
顺带一个量级细节:上面 RawFrame.length 的注释里写 "16,777,216 16 mb max frame",24 位上限实为 16,777,215(差一)。这只是注释瑕疵,不构成安全问题(S12),但成文引用数字时须用 16,777,215。
4.3 内存驻留模型:线性,不是平方
acc ++ chunk.toByteVector 的成本结构决定放大效率。项目所用 scodec-bits 1.2.5 的 ByteVector.++ 实现:
// scodec-bits@af8d92c8 → core/shared/src/main/scala/scodec/bits/ByteVector.scala:171-174
def ++(other: ByteVector): ByteVector =
if (this.isEmpty) other
else if (other.isEmpty) this
else Chunks(Append(this, other)).bufferBy(64)——树形结构共享,追加 O(1)-ish,驻留随累积字节近似线性增长,外加节点与 JVM 对象开销;fs2 3.13.0 的 Chunk.toByteVector 依具体 Chunk 类型返回 view 或原向量,也不能笼统算作一次完整拷贝(S16)。因此正确的模型是「每连接驻留 ≈ 已收字节 + 开销」,而不是每次追加复制全部历史的 O(n²)。三组数据印证线性:8 MiB 未完成负载伴随约 9 MiB 堆采样增量(E2);D9 的直方图量级(6.4 节);D12 三档 pure 轮的基线校正增量稳定在 15.23–15.24 MiB——输入恰为 15 MiB 且与流数无关,是「驻留 ≈ 已收字节 + 少量开销」最贴输入的一组实测(E14)。D9 直方图里 6 连接保持期恰有 6 个 ByteVector$Chunks 实例,与结构共享模型一致(不能按实例数定位 acc,E11)。
除 acc 外,连接内存还包括各流的 DATA 队列:每流入站 channel 为 Channel.bounded(128)(H2Connection.scala:87、:132 的建流处),正常 DATA 帧每项默认最多 16,384 字节,应用不消费时读循环会在 channel 再次发送处反压,但已排队数据可与后续未完成帧并存(S16/S19)。串行读循环意味着反压发生在「入队」而不是「收帧」——这正是 3.5 节容量估算要区分「当前未完成帧」与「已排队 DATA」的代码依据。入队点在 H2Stream.receiveData:
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Stream.scala:338-374(节选)
def receiveData(data: H2Frame.Data): F[Unit] = state.get.flatMap { s =>
s.state match {
case StreamState.Open | StreamState.HalfClosedLocal =>
import localSettings.initialWindowSize.windowSize
val newSize = s.readWindow - data.data.size.toInt // 流级读窗口同样先减
/* ……newState / sizeReadOk / isClosed 三个局部值,从略…… */
val needsWindowUpdate = newSize <= (windowSize / 2)
for {
_ <- state.update(s =>
s.copy(
state = newState,
readWindow = if (needsWindowUpdate) windowSize else newSize,
contentLengthCheck = s.contentLengthCheck.map { case (max, current) =>
(max, current + data.data.size)
},
)
)
_ <-
if (sizeReadOk) s.readBuffer.send(Right(data.data)).void // 入队点:应用不消费时,反压发生在这里
else rstStream(H2Error.ProtocolError)
_ <-
if (needsWindowUpdate && !isClosed && sizeReadOk) {
enqueue.offer(Chunk.singleton(H2Frame.WindowUpdate(id, windowSize - newSize)))
} else Applicative[F].unit // 流级回补在 readBuffer.send 之后(S19 的时序依据)
/* ……endStream 收尾与 onClosed,从略…… */4.4 异常位置不是漏洞位置
复现中的一个观测值得蓝队注意:OOM 的栈帧落在 fs2.io.net.SocketCompanionPlatform 的读缓冲释放处,而不是任何「分配 16 MiB」的代码行(E1)。原因很简单——结构共享的 ByteVector 累积不产生一次性的大分配,堆是在多次小追加中蚕食尽的;压垮骆驼的最后一根稻草恰好是 fs2 内部一次普通分配,异常就在那里抛出。从栈顶反查此类资源漏洞会误判根因位置:真正的漏洞在「谁让这些字节留了下来」,即 264 行的累积分支与 4.2 节的完整性门槛。
5. 根因解剖 II:三重失效的校验层——时机、可达性、方向
第 4 章解释了「未完成帧为什么无人检查」;本章回答另一半:就算帧完整到达了,旧校验层也不能统一保障入站帧长。旧版五处基于 SETTINGS 帧上限的分支检查都位于 processFrame(http4s@6c8f2edd → H2Connection.scala:363,396,413,440,534),其中四处方向错误,且这四处比较的是 header 块而非整个帧负载;DATA 虽使用本端上限,也只比较去掉 padding 后的数据。未知类型帧则没有对应的分支检查。下文从时机、可达性与方向三个维度分析;前两者是同一检查顺序造成的两种后果,先行总览见图 8:

图 8:时机与可达性是「完整解析后才检查」的两种后果;完整帧还存在设置方向、比较量及帧型覆盖缺口。图中的五处是旧版基于设置上限的分支检查,不是所有帧型的统一入口。
5.1 失效一(时机):检查在内存承诺之后
旧版这五处基于 SETTINGS 上限的检查都在 processFrame 里,即帧已完整解析、负载已全额驻留之后才运行。以 DATA 分支为例:
// http4s@6c8f2edd → H2Connection.scala:532-571(processFrame 的 DATA 分支,节选)
case (d @ H2Frame.Data(i, data, _, _), _) =>
val size = data.size.toInt
if (size > localSettings.maxFrameSize.frameSize) { // ← 五处检查中唯一方向正确的一个
logger.warn(
"Receive Data Size Larger than Allowed Frame Size - Frame Size Error - Issuing GoAway"
) >>
goAway(H2Error.FrameSizeError)
} else {
mapRef.get.map(_.get(i)).flatMap {
case Some(s) =>
/* 连接级读窗口先减、按已收字节回补 WINDOW_UPDATE,随后 s.receiveData(d)
把 DATA 交给流——完整流控段见 6.4 节 */
case None =>
/* 空闲/已关闭流:warn 日志 + goAway(ProtocolError) */
}
}到这里时,超限 DATA 帧的字节已经全部驻留在解析缓冲中;该分支的大小检查先于 s.receiveData(d),超限帧不会经此分支进入流队列。检查只能「止损」(拒绝交付、关闭连接),不能阻止先前的驻留。对 16 MiB 级别的声明,两者之间隔着的正是被吃掉的堆。
这里的 size 还不是完整 DATA 帧负载长度。Data.fromRaw 会在 PADDED 帧中剥离 1 字节 Pad Length 和 Padding(http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Frame.scala:172-186);上面分支只检查剥离后的 data.size。例如在默认 16,384 字节上限下,原始负载由 16,384 字节数据、1 字节 Pad Length 和 1 字节 Padding 组成时,帧头声明 16,386 字节,但该处分支的比较量仍为 16,384 字节。这是静态代码路径的比较量缺口(S20),未作为独立动态实验复测;它不改变主复现所利用的未完成帧缓冲机制。
5.2 失效二(可达性):检查写对了也轮不到它跑
对未完成帧,fromByteVector 永远返回 None,控制流根本到不了 processFrame——维护者公告自己也写明了这一点("an incomplete frame never reaches processFrame",X1)。时机与可达性合起来构成公告所说的「校验过晚」;主复现以 DATA 为例,缺口本身不限于该帧型。第三个维度是公告没有提的设置方向。
5.3 失效三(方向):用对端的上限检查对端的输入(本地新发现,公告未提及)
看 HEADERS 分支的同一类检查:
// http4s@6c8f2edd → H2Connection.scala:361-393(processFrame 的 HEADERS 分支,节选)
case (h @ H2Frame.Headers(i, sd, _, true, headerBlock, _), s) =>
val size = headerBlock.size.toInt
if (size > s.remoteSettings.maxFrameSize.frameSize) { // ← 对端宣告的上限(方向错误)
logger.warn("Header Size too large for frame size - FrameSizeError - Issuing GoAway") >>
goAway(H2Error.FrameSizeError)
} else if (sd.exists(s => s.dependency == i)) {
goAway(H2Error.ProtocolError)
} else {
mapRef.get.map(_.get(i)).flatMap {
case Some(s) =>
s.receiveHeaders(h, List.empty) // 已有流:进入 HPACK 解码
case None =>
/* 校验流 ID 奇偶与上限后 initiateRemoteStreamById 建流,再 receiveHeaders */
}
}除上述分支外,另有三处用的是同一个 s.remoteSettings.maxFrameSize——END_HEADERS=false 的 HEADERS、单帧 PUSH_PROMISE 与续帧起始形态的 PUSH_PROMISE:
// http4s@6c8f2edd → H2Connection.scala:394-396、:406-416、:438-440(节选)
case (h @ H2Frame.Headers(i, sd, _, false, headerBlock, _), s) => // :394 END_HEADERS=false
val size = headerBlock.size.toInt
if (size > s.remoteSettings.maxFrameSize.frameSize) goAway(H2Error.FrameSizeError)
/* ……优先级依赖与块上限检查、ContinuationProgress 启动,见 5.6 节…… */
case (h @ H2Frame.PushPromise(_, true, i, headerBlock, _), s) => // :406 单帧 PUSH_PROMISE
val size = headerBlock.size.toInt
if (connectionType == H2Connection.ConnectionType.Server) {
logger.warn(
"Encountered Push Promise Frame a a Server - Protocol Error - Issuing GoAway"
) >>
goAway(H2Error.ProtocolError) // 类型门在帧长检查之前(5.5 表的「不可达」)
} else if (size > s.remoteSettings.maxFrameSize.frameSize) {
logger.warn("Header Size too large for frame size - FrameSizeError - Issuing GoAway") >>
goAway(H2Error.FrameSizeError)
} else { /* 交付 receivePushPromise */ }
case (h @ H2Frame.PushPromise(_, false, _, headerBlock, _), s) => // :438 续帧起始形态
val size = headerBlock.size.toInt
if (size > s.remoteSettings.maxFrameSize.frameSize) goAway(H2Error.FrameSizeError)
/* ……块上限检查、ContinuationProgress 启动,见 5.6 节…… */问题在于语义:remoteSettings 是对端通过 SETTINGS 帧宣告的、对端愿意接收的上限——它约束的是本端发送时的分块,拿它来检查本端收到的帧,等于让输入方自己给自己划线;且按 §6.5.2 的定义(第 2 章),这连「合理误解」的空间都没有——入站帧长比较的唯一合法基准就是本端宣告值。对照图 2,这是把第 ② 类对端输入当成了第 ③ 类本端权威。
攻击者利用它只需要一个合法动作:在连接前期的 SETTINGS 帧里宣告 SETTINGS_MAX_FRAME_SIZE=0x00FFFFFE(16,777,214,在 RFC 允许的 214–224−1 范围内、贴近上限)——线上字节即 00 00 06 04 00 00 00 00 00 00 05 00 ff ff fe(D6)。此后「帧完整到达」的 HEADERS/PUSH_PROMISE 也越过帧长检查。旧版 h2c Upgrade 入口还多一条通道:initialRemoteSettings 直接由 HTTP/1.1 请求的 HTTP2-Settings 头(base64url 的 SETTINGS payload)构造,攻击者在升级请求头里就能预设这个值:
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Server.scala:102-117
val settings: Option[H2Frame.Settings.ConnectionSettings] = if (upgradeCheck) {
req.headers
.get(ci"http2-settings")
.collectFirstSome(settings =>
settings.map(_.value).collectFirstSome { value =>
for {
bv <- ByteVector.fromBase64(value, Bases.Alphabets.Base64Url) // Base64 Url
settings <- H2Frame.Settings
.fromPayload(bv, 0, ack = false)
.toOption // This isn't an entire frame
// It is Just the Payload section of the frame
} yield H2Frame.Settings
.updateSettings(settings, H2Frame.Settings.ConnectionSettings.default) // ← 对端头写进设置初值
}
)
} else None这份 settings 随升级响应的 H2Keys.H2cUpgrade 属性传递,最终作为 H2Server.fromSocket 的 initialRemoteSettings 参数(:178)进入连接状态。D7 的恶意头 payload 为 00 05 00 ff ff fe(base64url AAUA___-),中性对照为 00 04 00 00 ff ff(AAQAAP__)(S10)。
仅有 DATA 分支(534 行)用 localSettings,设置方向正确——但受失效一/二约束,同样过晚、同样可能到不了;PADDED DATA 的比较量缺口见 5.1 节。四处 HEADERS/PUSH_PROMISE 检查则比较 headerBlock.size(帧内 header 块字节数),而不是帧声明的完整负载长度。以旧版单帧 HEADERS 为例,Headers.fromRaw 会把 PRIORITY 的 5 字节从 header 块中剥离(http4s@6c8f2edd → H2Frame.scala:244-304);一个声明负载 16,389 字节、其中 header 块恰为 16,384 字节的帧,即使对端保持默认 16,384 帧上限,也不会因 headerBlock.size > remoteSettings.maxFrameSize 被该处分支拒绝。这只证明旧帧长比较未覆盖完整负载,后续 HPACK 与应用处理并未在此构造下动态验证。修复版直接比较帧头声明长度,才覆盖这 5 字节。
旁证有二:修复提交把这四处连同 DATA 检查一并删除、统一到读入口并以 localSettings 为准——修复后的取值方向消除了旧方向错误(F2/F3);git 考古显示该错误自 2021-12-03 064c5bcb5("Initial Core Port")即存在(S11),历经 2022-03-21 的 readLoop 重写(69a67490d)未被纠正。
5.4 三维失效的关系与增量结论
三个维度的关系:时机决定了「完整帧到达后检查只能止损」;由同一顺序产生的可达性问题决定了「不完整帧连止损都没有」;方向决定了「即使攻击者发完整帧,部分 HEADERS/PUSH_PROMISE 检查仍使用对端宣告的上限」。旧版的有效覆盖因而不能只用「校验过晚」概括。按 5.1、5.3 节的比较量分析,即使对端没有调高 SETTINGS,PADDED DATA 和四处 HEADERS/PUSH_PROMISE 分支也未完整比较帧负载。还有一类帧型覆盖缺口:H2Frame.fromRaw 将未知类型变为 Unknown,processFrame 直接忽略它,没有本端 maxFrameSize 比较(http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Frame.scala:125-137、H2Connection.scala:584-587)。因此,完整到达的超限未知类型帧也不会经这五处分支报 FRAME_SIZE_ERROR;这是源码结论(S20),尚无该帧型的线缆实验。RFC 9113 §4.1 要求忽略未知类型,§4.2 仍对帧负载规定接收方宣告的大小上限。动态证据的范围较窄:D1/D6/D7 证明所测完整超限 HEADERS 与客户端 PUSH_PROMISE 越过旧帧长检查,不能推断它们会越过任何独立的代理检查,也不证明所有合法 HPACK 或其它帧形态均会交付应用;服务端单帧 PUSH_PROMISE 则先被类型门拒绝(见 5.5 表)。
5.5 方向绕过的动态对照
方向绕过的四格矩阵(服务端/客户端 × HEADERS/PUSH_PROMISE)已有三格动态证实;第四格(服务端 PUSH_PROMISE)的单帧完整形态被类型门截断,续帧起始形态见该行与表后说明:
通道 | 实验 | 0.23.36 + 恶意 SETTINGS | 0.23.36 + 默认 SETTINGS | 0.23.37 + 恶意 SETTINGS |
服务端 HEADERS(常规 SETTINGS) | D1/E5 | 完整 64 KiB/1 MiB HEADERS 进入 HPACK,因刻意非法索引报 | 同帧报 | 入口即 |
服务端 HEADERS(h2c | D7/E9 | HTTP 101 后完整 64 KiB HEADERS 进入 HPACK,报 | 中性头同样 101,同帧报 | 入口已移除,返回 HTTP 200 |
客户端 PUSH_PROMISE | D6/E8 | 完整 64 KiB PUSH_PROMISE 进入 | GOAWAY 6 | 读入口 GOAWAY 6 |
服务端 PUSH_PROMISE | — | 单帧完整形态( | 单帧形态同左;续帧起始形态的块 >16,384 时在 | 超限声明在读入口即 |
判读纪律:COMPRESSION_ERROR 是实验刻意注入非法 HPACK 索引(单字节 0x80,即非法索引 0,后接零填充)制造的「到达证明」——它的出现说明帧越过了帧长检查、进入了解码器,仅此而已;不证明合法 HPACK 的后续处理,也不证明内存放大。「默认 SETTINGS」列则是单变量对照:同版本、同一帧、只改对端 SETTINGS 一项——排除「大小检查一律未运行」的解释,把差异锁定在「方向」上。
h2c 行的两个细节把绕过条件压到最小:实验组升级后只发空 SETTINGS 帧,证明整个绕过仅需 Upgrade 请求头一个动作、无需升级后再发 SETTINGS;中性对照刻意用非空的 INITIAL_WINDOW_SIZE=65535(语义惰性)而非空头——空头可能自身导致升级失败或解析歧义。D7 另以 131,072 / 16,777,213 / 16,777,214 三个合法值探索,均复现「101 + 进入 HPACK」,非法值 0xFFFFFFFE 则连升级都不完成、直接 HTTP 200(探索输出未另存档,正式留档以 16,777,214 为准,S10/E9)。
D10 进一步用合法 HPACK 补了语义终点:旧版恶意设置下,32 KiB 合法请求的 x-pad(32,735 字符)实测到达服务端应用并得到响应;而旧版客户端收到 32 KiB 合法大值响应时,帧虽越过旧检查进入 HPACK(debug 日志先打印 Read - Headers(...32768 bytes...)、随后报 MessageTooLong,两段文本直接钉住「越过帧检查 → 进 HPACK → 被列表限额拒」的顺序),却被默认 4096 字节的解码后 header list 限额以 GOAWAY 11 拒绝、未交付应用(E12)——那是另一个维度的限额,不能与帧长或压缩块限额混为一谈,也不能由这组拒绝外推其它合法块。
表中服务端 PUSH_PROMISE 一格的「不可达」仅指 D6 形态的单帧完整 PUSH_PROMISE。续帧起始形态(END_HEADERS=false)在两版 H2Connection 分支里都缺少即时类型门(旧版 :438-440 的帧长检查方向同样错误;修复提交对应分支保留块检查),但终止续帧完成后,H2Stream.receivePushPromise 的 Server 分支仍会 goAway(PROTOCOL_ERROR)(http4s@6c8f2edd → H2Stream.scala:300-334)。这一提前拒绝时机的差异及其驻留范围尚未动态验证;不能将它列为已证实的第四条应用交付路径(S20)。
5.6 相邻但独立的边界:header 块限额
为避免把「帧长」与「header 块」两个维度搅在一起,单独说明。H2Connection.maxHeaderBlockSize 在原始 maxHeaderListSize=None 时回退 65,536 字节;常见 Ember server/client builder 分别传 40,960/4,096(S14)。按 S-A2 的逐分支矩阵,块检查的覆盖是:起始 HEADERS/PUSH_PROMISE(END_HEADERS=false)与非终止型 CONTINUATION 在加入进度列表前比较累计尺寸,终止型续帧旧版漏检、由 b6e881e31 在 0.23.37 发布前补上(F8),单帧 END_HEADERS=true 路径两版均无该比较(S18)。定义与非终止型 CONTINUATION 的检查形态:
// http4s@6c8f2edd → H2Connection.scala:60-61(定义)与 :335-347(非终止型 CONTINUATION 分支)
private[this] val maxHeaderBlockSize: Long =
localSettings.maxHeaderListSize.fold(65536L)(_.listSize.toLong)
case (
c @ H2Frame.Continuation(id, false, _),
H2Connection.State(_, _, _, _, _, _, _, Some(headers), None, _),
) =>
if (headers.first.identifier != id) {
logger.warn("Invalid Continuation - Protocol Error - Issuing GoAway") >>
goAway(H2Error.ProtocolError)
} else if (headers.size + c.headerBlockFragment.size > maxHeaderBlockSize) {
logger.debug("Header block exceeds maxHeaderListSize - Issuing GoAway") >>
goAway(H2Error.EnhanceYourCalm) // 起始块 + 已积累续帧的累计字节数
} else {
state.update(s => s.copy(headersInProgress = headers.addContinuation(c).some))
}另一个易混点是两个 4096 级限额的 None 回退并不相同:压缩块上限回退 65,536 字节,而 HPACK 解码后列表的 None 回退是 Int.MaxValue,且解码发生在块已构建之后。单帧缺口的实际暴露面限于「本端块上限 < 本端帧上限」的配置:默认客户端(4,096 < 16,384)在此列,默认服务端(40,960 > 16,384)的单帧在默认配置下本来就越不过块阈值,调低了 withMaxHeaderSize 的部署才落入范围(S18)。
D10 的合法大值响应撞上的是解码后列表限额,无法用它隔离「原始压缩块上限」这个变量——要单独检验单帧块策略,必须构造「编码块 >4,096、解码列表 <4,096、单帧 <16,384」三者并立的输入,这正是 D11 的由来。D11 的动态对照显示:默认 SETTINGS 下,0.23.36 与 0.23.37 客户端各两轮均把 4893 字节合法 Huffman 单帧 HEADERS(理论解码列表 3112 字节)交付应用;2293 字节的小块对照在两版本各两轮也全部正常交付——有了这块阴性对照,下述差异才能归因到尺寸与拆分变量;同一字节串拆成 2447 字节 HEADERS + 2446 字节终止 CONTINUATION 后,旧版两轮交付、0.23.37 两轮 GOAWAY 11(E13)。这组数字之所以能构造,是因为 HPACK 允许编码块大于**解码列表:实验值用可见 ASCII ~ 构造,其 RFC 7541 附录 B Huffman 码为 13 位(0x1ffd),1,500 字符的值编码后达 2,438 字节——是扩张而非压缩——于是「编码块 >4,096、解码列表(按 name+value+32 计)<4,096、单帧 <16,384」三者可同时满足(S-A8 构造推导)。这是一个有限的单帧/续帧块策略差异**:默认 16,384 帧限与 4096 解码后列表限额仍然约束该路径,未测得独立资源危害,不定性为新 DoS,也不等于本 CVE 的帧长修复失效。
5.7 已核查测试中的覆盖盲区:测试考古
初始提交 064c5bcb5 的 H2Connection.scala 已同时携带上述失效结构——不完整帧先累积(当日文件的 :151-175)、HEADERS/PUSH_PROMISE 检查用 remoteSettings(:250-319 间的四处)、全部检查晚于完整帧(DATA 在 :390-395)(S17)。它们不是逐年演化出来的;已核查用例为何没有检出这些问题,需要按具体输入和断言分别分析。
专用套件出现得较晚:H2ConnectionSuite 直到 2026-06-22(f9dcca4f5,作者时间;提交时间 2026-06-29)才首次进入仓库,距 GHSA 发布不足三个月;H2StreamSuite 于 2022-12-28(00c7d7f91)加入。这只能说明专用套件在此前版本的 tag tree 中不存在,不能排除其它测试曾间接运行读循环。v0.23.36/M47 已检查的测试里,H2ConnectionSuite 有两例真正运行读循环,但测的是 header list 限额——两组 HEADERS/CONTINUATION 各 40+40 与 60+60 字节,对照 maxHeaderListSize=100;该套件在 45 个修复前 tag 的 tree 中只出现于 v0.23.35/.36/M47。已读的 H2StreamSuite 帧大小用例测的是写侧 DATA 分块;最终受影响版的 FrameSpec 测解析与设置往返。已核查的最终受影响版用例没有设置非对称的本端/远端帧上限,也没有未完成超限负载的对照——方向错误与时机错误在这些用例下不可见(S17)。修复提交新增的 H2FrameSizeSuite 首次提供专门的读入口早拒绝回归,但它也没有显式把 remoteSettings.maxFrameSize 设得大于本端值,方向回归仍靠 D1/D6/D7 这类非对称对照(7.2 节)。早期若干测试 blob 缺失,历史内容搜索未完整执行;不能断言全历史零覆盖,也不能据此猜测评审或 CI 的原因。
6. 动态复现:两条链路走到 OOM
6.1 实验设计原则
复现的公信力来自四个约束(E1,repro/README.md):其一,用真实 Maven Central 工件(http4s-ember-server_3 / http4s-ember-client_3 0.23.36 与 0.23.37),不是源码补丁版;其二,攻击对端是真实 TCP 程序(Python 探针 / rogue 源站),不是对漏洞代码的 mock;其三,JVM 堆限到 96/20 MiB,让「实际堆耗尽」在分钟级可观测,且每秒打印 heap_used_mib 插桩;其四,同时记录容器 oom 标志与退出码,区分「Java 堆 OOM」(OutOfMemoryError、exit 1、oom=false)与「内核 OOM-kill」。全程仅在 Compose internal: true 的 Docker 内网运行,不映射宿主机端口。
6.2 链路 A(服务端)与链路 B(客户端)
链路 A:ROLE=server 显式 .withHttp2,探针连接后发 preface、SETTINGS、超限帧头,随后各发 15 MiB 负载并保持未完成。6 连接并发下,多数探针各发送 15,728,640 字节,heap_used_mib 升至 90、92,随后 java.lang.OutOfMemoryError: Java heap space,JVM 退出码 1、容器 oom=false。单连接细观测(E2):堆约 6 MiB → 15 MiB,服务端对超限声明长度无任何拒绝动作。注意 OOM 栈落在 fs2 读缓冲释放处——异常位置不是漏洞位置(4.4 节)。
链路 B:rogue 源站监听,客户端请求带 H2Keys.Http2PriorKnowledge。rogue 收到 505249202a20485454502f322e30 开头的客户端前言后,发送单个 15 MiB 未完成帧;客户端堆 6 → 19 MiB 后 OOM,退出码 1。这条链路补全了公告「仅推断未复现」的公开缺口——再次强调证据归属:公告时间点的证据状态是推断,本实验是其后的一手动测。
6.3 修复版对照与成功判据
0.23.37 在相同输入下:服务端与客户端均记录 Received Frame Size Larger than Allowed Frame Size - Frame Size Error - Issuing GoAway,探针侧约 245,760 字节、rogue 侧约 1,392,640 字节后连接断开,无 OOM。已发字节数是 TCP 缓冲与关闭时序的产物,不可当作校验阈值;验证成功的判据是「提前拒绝且无 OOM」,不是退出码——修复版客户端因对端被拒而报 Stream Was Closed、退出码 1,这不是失败。
D2 对修复版读入边界做了十轮对照(两速率 × 5 轮):全部提前 FRAME_SIZE_ERROR;发送端仍有 212,992–557,056 字节被 sendall 完整写入——那只说明发送端内核接受了写入,不是 Ember 实际读取量。区间内部的分布本身就是证据:恒速(约 1 MiB/s)五轮的写入量一字不差同为 212,992 字节,全速五轮在 458,752–557,056 之间波动——同一服务端、同一声明,唯一变量是发送节奏,已发字节数随之相差一倍以上,坐实它由发送端与时序决定。服务端每轮在发送起始后约 0–9 毫秒即打印拒绝警告;D1 的 0.23.37 恶意设置 + 1 MiB 轮里探针 sendall 尚未写完就先收到 GOAWAY——「拒绝先于发送完成」有两个独立实例。源码推得的条件上界:默认读块下,超限声明帧到达拒绝分支入口时 acc.length ≤ 65,537 字节(上次 acc 不足 3 字节至多留 2 字节,再追加一个 65,535 字节读块)。这是静态条件界,不是实测缓冲量;修复版逐轮实际 acc 驻留的仪表化尝试(D13)因开发工件构建门止损:旧版埋点 JAR 虽然发布成功(0.23.36.1-accprobe),但修复版两次定点构建分别失败于 amd64 容器解包(tar -xzf 报 Function not implemented)与 arm64 上 sbt 加载项目(站点插件因固定提交归档缺少 Git 标签元数据抛 NoSuchElementException: key not found: (0,23)),按两次失败即止损的纪律停止,未进入任何协议轮次,也就没有 E15 测长数据(D13)。
6.4 扩展实验索引
E5–E14 按主题归组,全部细节见 repro/README.md 第四节与各笔记:
- 方向绕过矩阵:D1(服务端 HEADERS,常规 SETTINGS)、D7(服务端 HEADERS,h2c 头)、D6(客户端 PUSH_PROMISE)、D10(客户端响应 HEADERS + 合法 HPACK 语义终点)——见 5.5 节表格。
- 入口版本差异:D3 的 TLS+ALPN 与 h2c Upgrade 双版本对照——见 3.4 节表格。
- 时间边界:D8 的双计时器滴灌(半开 HEADERS 5.014 秒终止;未完成 DATA 30.251 秒存活)——见 3.4 节。
- 驻留组成(D9):GC 直方图显示 6×15 MiB 保持期全进程
byte[]增 94,899,136 字节(约 90.5 MiB);单连接 15 MiB 对照 18,192,336 字节,先排队 32×16 KiB DATA 再挂同一未完成帧则 18,799,240 字节(多 606,904 字节,而额外输入 524,288 字节)——支持「队列可叠加」与线性量级;连接关闭并 GC 后,两组的[B均回落到约 2.4 MiB 基线(2,412,216 / 2,406,056 字节),说明驻留以连接存活为前提、攻击者必须保持连接。为容纳输入,D9 把堆放宽到-Xmx256m/容器 384 MB,并用延迟 60 秒响应、不读请求体的实验 app——「应用不消费」是本实验稳定保留队列所用的构造条件,并非队列出现的必要条件:最初的先导用立即响应的 app,stream 随响应关闭,后续 DATA 得到Received Data Frame for Idle or Closed Stream 1与 BrokenPipe;真实部署里队列是否形成及保持,取决于请求体消费速率、响应与流关闭时机。 - 多流驻留(D12):把队列面扩到 8/64/256 流、每流 96 帧的未消费 app:逐批 PING ACK 确认读循环处理了 12/96/384 MiB DATA,「队列+未完成帧」相对「纯未完成帧」的全进程
byte[]基线校正差为 12,617,920 / 100,897,960 / 403,464,360 字节,三者比值约 7.996 / 3.999 / 31.98 与流数比一致(对照地,queue 轮单独增量的 64/8 比值只有约 7.51——基线与伴随对象在低档不可忽略,这正是采用「同环境 combo−pure 校正差」作指标的原因);该差值与已确认处理 DATA 的残差分别为 +35,008 / +234,664 / +811,176 字节(约 0.20–0.28%),queue 轮自身的全进程[B增量则是 13,458,344 / 101,126,024 / 403,731,368 字节,同轮连接级WINDOW_UPDATE为 384 / 3,072 / 12,288 个、每流均 48 个;单流先导另有[B增 2,427,096 字节、32 个ByteVector$Chunks实例与堆 6→9–10 MiB 的旁证。堆预算按档放宽(低/中/高档-Xmx256m/512m/768m、容器 384 MB/768 MB/1 GB),保留队列的轮次用延迟 90 秒响应的实验 app。 - 直方图判读限制:
GC.class_histogram是全进程按类浅大小,且执行时会触发 GC——所有直方图数字都是 GC 后快照,不能当作运行峰值或自然负载下的内存时间序列;已回收的瞬时分配不会出现在快照里,但本文没有同口径数据量化两者差额。这些数字也不能直接读出acc.length、不能把数组归属到具体队列或连接。单流先导中 200 次sendall全成功但流级窗口更新只覆盖前 128 帧、尾随 PING 无 ACK,说明 socket 写入成功不等于对端处理进度。「停在 128 帧」还有算术闭环:先导收到的流级回补共 64 × 32,768 = 2,097,152 字节,恰等于 128 × 16,384 字节,「每两帧一次 32,768 更新」可由默认 65,535 窗口 + 16,384 帧长直接导出——更新停在 128 帧不是计数巧合,而是 128 项通道反压的算术必然(第 129 次send阻塞是源码与该算术的推断,未抓线程栈)。 - 流控时序:旧版接收 DATA 时
readWindow先减、低于半窗即按已收到字节发WINDOW_UPDATE回补,流级回补在readBuffer.send之后(流级一侧的代码见 4.3 节末的receiveData节选);未见接收窗口欠额的中止分支(S19)。D12 在 app 不消费 body 的轮次仍记录到持续回补,与源码时序相符——连接级一侧:
// http4s@6c8f2edd → H2Connection.scala:540-571(processFrame DATA 分支的流控段)
mapRef.get.map(_.get(i)).flatMap {
case Some(s) =>
for {
st <- state.get
newSize = st.readWindow - d.data.size.toInt // 连接级读窗口先减
needsWindowUpdate = newSize <= (localSettings.initialWindowSize.windowSize / 2)
_ <- state.update(s =>
s.copy(readWindow =
if (needsWindowUpdate) localSettings.initialWindowSize.windowSize
else newSize.toInt
)
)
_ <-
if (needsWindowUpdate)
outgoing.offer( // 按已收到字节回补连接级 WINDOW_UPDATE
Chunk.singleton(
H2Frame.WindowUpdate(
0,
st.remoteSettings.initialWindowSize.windowSize - newSize.toInt,
)
)
)
else Applicative[F].unit
_ <- s.receiveData(d) // 之后才交给流级(4.3 节的 receiveData)
} yield ()
case None =>
/* 空闲/已关闭流:warn 日志 + goAway(ProtocolError) */
}RFC 9113 §6.9.3 允许接收端在缩小窗口后继续处理超额数据,因此不能仅凭未发 FLOW_CONTROL_ERROR 断定普遍违规或新的无界漏洞——这里限定为「检查与回补时机的缺口,有有限动态对照」。
- 单帧 header 块策略:D11 的 12 轮对照——见 5.6 节。
6.5 环境边界
全部动态结论限定于:Docker 内网、arm64 宿主(构建镜像 linux/amd64 模拟)、Scala 3(_3)工件、0.23.36/0.23.37 两个版本点。_2.12/_2.13 与 1.0.0-M 系列依赖公告的版本范围声明(核心文件 blob 与 0.23 对应版本相同,但无动态复现);Docker 内网结论不外推互联网可达性与实际流量条件。主链路命令:
HTTP4S_VERSION=0.23.36 docker compose -f repro/compose.yaml up -d --build victim
PROBE_MIB=15 HTTP4S_VERSION=0.23.36 docker compose -f repro/compose.yaml up -d --scale probe=6 probe
HTTP4S_VERSION=0.23.36 docker compose -f repro/compose.yaml logs --tail=40 victim
docker inspect --format '{{.State.Status}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}' repro-victim-17. 修复分析:把校验移回信任边界内侧
7.1 修复的三个动作
修复提交 87cf334fa("Reject oversized HTTP/2 frames before buffering the payload",作者时间 2026-09-05 23:17:25 UTC−04,即 2026-09-06 03:17:25 UTC)做三件事:
动作一,H2Frame.scala 新增 peekDeclaredLength:
// http4s@87cf334 → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Frame.scala:66-75
/** The payload length a frame declares, decoded from the first three bytes.
* Available long before the payload it describes. RFC 9113 4.2 allows an
* oversized frame to be rejected without reading that payload, so a caller
* can consult this instead of buffering `9 + length` bytes to find out.
*/
def peekDeclaredLength(bv: ByteVector): Option[Int] =
if (bv.length >= 3)
Some((bv(2) & 0xff) | ((bv(1) & 0xff) << 8) | ((bv(0) & 0xff) << 16))
else None动作二,readNextFrame 在 fromByteVector 之前插入新分支:
// http4s@87cf334 → ember-core/.../h2/H2Connection.scala:246-273(修复后的 readNextFrame,节选)
def readNextFrame(acc: ByteVector): F[Option[(H2Frame, ByteVector)]] =
if (acc.isEmpty) {
readFromSocket.flatMap {
case Some(chunk) => readNextFrame(chunk.toByteVector)
case None => /* 对端关闭,结束 */
}
} else if (
H2Frame.RawFrame.peekDeclaredLength(acc).exists(_ > localSettings.maxFrameSize.frameSize)
) { // ← 新增分支:帧头可得即判断
logger.warn(
"Received Frame Size Larger than Allowed Frame Size - Frame Size Error - Issuing GoAway"
) >> goAway(H2Error.FrameSizeError) >> F.pure(None) // 终止该连接的读循环
} else
H2Frame.RawFrame.fromByteVector(acc) match {
case Some((raw, leftover)) =>
H2Frame.fromRaw(raw) match {
case Right(frame) => F.pure(Some((frame, leftover)))
case Left(e) => /* 解析失败:goAway(e) 后返回 None */
}
case None =>
readFromSocket.flatMap {
case Some(chunk) => readNextFrame(acc ++ chunk.toByteVector)
case None => /* 对端关闭,结束 */
}
}动作三,删除 processFrame 全部 5 处分散检查(含 4 处 remoteSettings 方向错误)。
三个动作合成一句话:一个入口(读循环)、一个权威(localSettings)、一个时机(≥3 字节即可判断)。对照图 2 的信任边界:校验从「完整性门槛之后」移到「帧头可得之时」,基准从对端声明翻转为本端权威——三条失效轴在同一个分支里被同时校正,且完整帧不再依赖各帧型分支用局部字段比较长度,新旧读循环的逐框对照见图 9。这就是「一行 peekDeclaredLength 修复为什么是对的」的完整答案:它不改变解析器、不加状态机,只是把同一个比较从攻击者控制的时点提前到协议保证的时点(长度字段先于负载到达),并把比较基准放回唯一有资格定义入站上限的一方。

图 9:修复前后对照。旧版五处分支检查位于完整性门槛之后(且 4/5 方向错误),DATA 比较量不完整、未知类型没有对应检查;修复版在帧头 ≥3 字节可读时即以 localSettings 预检。首次 socket.read 可能已带入部分负载,但读循环不再为超限声明继续累积完整负载。
7.2 回归测试的设计:测「不读」,不是测「拒绝」
修复附带的新套件 H2FrameSizeSuite(216 行)值得单独讲,因为它是资源类漏洞回归测试的范式样本。其核心装置 countingSocket 统计实际交给读循环的字节数,断言的对象不是「最终报了 FRAME_SIZE_ERROR」,而是拒绝后负载未被继续消费——对「声明超限但负载未完整到达」的用例,同时断言 GOAWAY 与「读入量未超过首次 socket.read」。用例覆盖恰好等于上限的边界值,以及 DATA/HEADERS/PUSH_PROMISE/CONTINUATION 四类帧型(F4)。装置与首个用例的核心断言:
// http4s@87cf334 → ember-core/shared/src/test/scala/org/http4s/ember/core/h2/H2FrameSizeSuite.scala
// :43-46、:50-82(countingSocket,节选)与 :139-171(首个用例)
/** How much one `socket.read` can hand back, so how much the read loop may
* legitimately hold before it has seen a frame header at all.
*/
private val oneRead = localSettings.initialWindowSize.windowSize.toLong // 65535
/** Replays `bytes` and records how many of them it handed over. */
private def countingSocket(bytes: ByteVector): IO[(Socket[IO], IO[Long])] =
(Ref[IO].of(bytes), Ref[IO].of(0L)).tupled.map { case (remaining, consumed) =>
val socket = new Socket[IO] {
def read(maxBytes: Int): IO[Option[Chunk[Byte]]] =
remaining
.modify { bv =>
if (bv.isEmpty) (bv, None)
else {
val (h, t) = bv.splitAt(maxBytes.toLong)
(t, Some(Chunk.byteVector(h)))
}
}
.flatTap(c => consumed.update(_ + c.fold(0L)(_.size.toLong))) // 只统计交给读循环的字节
/* ……Socket 其余方法为样板实现…… */
}
(socket, consumed.get)
}
test("a frame declaring more than SETTINGS_MAX_FRAME_SIZE is rejected before its payload") {
// Declares the 24-bit maximum, 1024x what we advertised, and supplies 1 MiB
// of it. Without the length check the read loop drains all of it waiting for
// a frame that would only then be rejected.
val supplied = 1024L * 1024L
val input = dataFrame(0xffffff, ByteVector.fill(supplied)(0))
for {
t <- mkConnection(input)
(h2, consumed) = t
_ <- h2.readLoop
n <- consumed
frames <- drainOutgoing(h2)
goAway = firstGoAway(frames)
_ = assert(goAway.nonEmpty, clue(frames))
_ = assertEquals(
goAway.get.errorCode.toInt,
H2Error.FrameSizeError.value,
clue(frames),
)
// The payload must not have been drained. One socket read is unavoidable,
// since the header only becomes readable once some bytes have arrived.
_ = assert(
n <= oneRead,
clue(s"buffered $n bytes, expected no more than one read of $oneRead"),
)
_ = assert(n < supplied, clue(s"buffered $n bytes of the $supplied byte payload"))
} yield ()
}为什么这个设计是对的:对一个「先缓冲后校验」漏洞,最危险的回归方式是「检查又变晚了但最终还是报错」——功能测试(断言错误码)全绿,资源承诺悄悄回来了。只有计数字节才能锁住「不读」这个行为。方法论的推论(呼应 5.7 节):这类测试必须包含未完成超限负载(攻击者的真实形态)与非对称本端/远端设置(方向回归);现有套件做到了前者、未显式做后者,方向错误的直接对照仍依赖 D1/D6/D7 这类实验。
版本考古补一笔(S17):45 个已枚举的修复前正式 tag 的 tree 均无该套件;它由 87cf334fa 首次加入,填补的是读入口早拒绝的测试空白,但不能单靠它验证旧方向错误。
7.3 版本线与修复纯度
87cf334fa 经 git merge-base --is-ancestor 验证同时含于 v0.23.37 与 v1.0.0-M48(F5)。但 0.23.37 发布态还叠加了三个独立变更,评估缓解时不可混为一谈(F6/F7/F8):
f910d7c11(2026-09-04)移除 ember-server 的内建 h2c Upgrade——所以新版对旧 Upgrade 请求返回 HTTP 200 是入口消失,不是帧长拒绝;b6e881e31给终止型 CONTINUATION 补累计maxHeaderBlockSize检查——header 块维度,单帧END_HEADERS=true路径仍未加同一比较(5.6 节);f35f69223在连接级/写侧引入withStallTimeout包装(stallStart字段 0.23.36 已有;补写入阻塞与等待WINDOW_UPDATE的终止),aba0f35d8才把 idle timeout 应用到 H2 读路径并包裹 preface 读取——服务端按每次socket.read等待计时、有进度即重置,客户端读循环为Duration.Inf;它是另一机制,且本 CVE 的超限声明先被帧长预检拒绝。
因此「升级到 0.23.37」获得的保护是帧长早拒绝本身;不能把发布态的所有行为差异(比如对慢速续帧的 GOAWAY 11)都归功于 87cf334fa,也不能反过来用 h2c 入口的消失去论证帧长修复。
7.4 修复版的量化行为
D12 的 64 流对照给出修复版的队列边界:0.23.37 正常 DATA 的 PING ACK 仍确认处理 96 MiB,保持期全进程 [B 增 101,134,328 字节,与 0.23.36 同档的 101,126,024 字节同量级——正常 DATA 队列在发布版依然存在,早检终止的是「队列 + 超限未完成帧」的保持态:combo 轮在非法帧头前快照 +101,130,440 字节,帧头后约 9 毫秒即 GOAWAY 6、连接关闭后 GC 快照仅 +230,688 字节(E14);无队列基线的 pure 轮是第二实例——约 18 毫秒 GOAWAY、探针写 196,608 字节后 BrokenPipe、拒绝后 GC 快照仅 +190,632 字节。判读限制同前:拒绝后快照不能定位对象释放语句、不能实测拒绝瞬间的 acc.length;探针仍能写入几十万字节只是本地 socket 的接纳量。
这组对照对缓解评估的直接含义:升级消除了超限帧的驻留原语,但没有也不打算消除应用不消费请求体时的正常流控队列——不能把升级后的服务描述成「单连接内存永不高于 16 MiB」。容量排查须把两个面分开看。
8. 检测、缓解与修复
8.1 修复优先
升级至 0.23.37 / 1.0.0-M48。操作要点是核对最终依赖解析出的 http4s-ember-core 版本:传递依赖常被 BOM 或锁定文件固定在旧版,构建脚本里写了新版本号不等于运行时解析了新工件。
8.2 升级前的缓解决策树

图 10:缓解决策树——能立即升级则升级并按 8.3 节的修复版日志验证生效;不能升级时依次评估 ①②④⑤(可叠加),③ 的两项不得作为缓解依据。
8.3 检测特征(蓝队视角)
以下特征提炼自本地实验与源码(E1–E4、S8),是推导线索而非官方 IoC——公告未发布任何检测规则(X1;2026-09-28 复查公告页亦无检测规则或 IoC)。
已修复侧的拒绝信号最可靠:日志文本 Received Frame Size Larger than Allowed Frame Size - Frame Size Error - Issuing GoAway,伴随线缆上的 GOAWAY(error code 6)。它的出现说明预检在工作;可用作升级生效的验证。
未修复侧的异常信号:单连接持续上行、接收方堆单调增长、长寿命连接伴随巨量未完成帧字节(D9 的释放后快照印证驻留绑定连接存活——连接关闭、GC 后 byte[] 回落基线);fs2.io.net.SocketCompanionPlatform 栈型的 Java 堆 OOM(记住 4.4 节:栈顶不是根因位置)。网络侧可优先排查帧头声明长度接近 0xFFFFFF 且负载长期未完成的连接;声明值 0xFFFFFF 的前三个字节为 ff ff ff。本仓库没有正常生产流量基线,不能据此断言该长度罕见或单凭长度判定恶意;还需结合本端宣告的帧上限、连接状态与后续负载判读。另有一个日志盲区(D8):旧版对半开 header 块超时发出的 GOAWAY(11) 走 debug 日志路径,实验全程服务端日志只有启动 INFO 与堆采样、无任何 GOAWAY 文本——未修复侧的终止信号在默认日志级别下可能完全不落日志,判读以线缆帧为准,「日志无异常」不能当作未受攻击的证据。补充一条反向提醒(D12):未消费请求体时正常 DATA 也会在多流队列驻留并持续触发 WINDOW_UPDATE,不能把 WINDOW_UPDATE 频率单独当漏洞指纹,须结合流数、请求体消费状态与是否存在未完成超限帧一起判读。
8.4 资产盘点清单
对应 3.6 节决策树的四个采集项:.withHttp2 出现点(服务端 builder、客户端 builder、代理/转发路径);最终解析的 http4s-ember-core 工件版本;上游是否存在 H2 终止及其实际校验行为;客户端出口 URL 的可信度(是否用户可影响)。
8.5 横向审计 checklist
这个漏洞的失效模式不限于一个项目,审计任何 HTTP/2 实现的入站帧长处理时(R4/X7 的方法论提炼):
- 从 9 字节帧头追到首次单帧声明长度判断,记录它与帧型 parser、负载读入的先后顺序——检查应在负载驻留之前;
- 分别追两条赋值链:本端 SETTINGS → 入站帧上限;对端 SETTINGS → 出站编码器/分块器。两条链若在任何入站检查处交叉,就是本案的方向错误;
- 测试用非对称配置(本端 16 KiB、对端更大值)验证方向,用未完成超限负载验证时机,记录接收方实际读入量——只断言错误码或只用对称设置测不出这两类失效(S17 的教训);
- 覆盖所有帧型及比较量:用 PADDED DATA、带固定字段的 HEADERS/PUSH_PROMISE 和未知类型完整超限帧验证比较对象确为帧头声明的整个负载(S20);帧长之外按资源维度分别设限、分别测试:压缩 header 块累计字节、CONTINUATION 帧数、HPACK 解码后列表、控制帧/流重置速率——字节上限替代不了帧数上限(Netty 的 CONTINUATION 洪泛教训);帧内 padding、固定字段与剩余负载的算术关系用畸形输入与 fuzzing 覆盖(SwiftNIO 的 padding 崩溃教训)(X6);
- 只搜
FRAME_SIZE_ERROR字符串不能替代上述追踪。
七个外部栈的固定 tag 抽样(R4,静态)不止给结论,还给逐栈的「首次判断位置 + 设置方向做法」,可直接当上面第 1、2 条的对照样本——前六个都在完整负载之前判断单帧长度:
栈(固定 tag) | 首次单帧长度判断 | 设置方向的做法 |
Go x/net v0.38.0 | 读 9 字节头后即比 |
|
Netty 4.1.132.Final |
| reader 初值为协议默认( |
Jetty 12.0.35 | 完整帧头后先比 | 发本端 SETTINGS 时 |
OkHttp 4.12.0 |
| 实现明说不宣告更大接收帧(类注释 |
nghttp2 v1.67.1 |
| 比本端 |
Bandit 1.11.0 | 修复后的 | 调用方传 |
Undertow 2.3.22.Final | 共同帧长判断在帧型 parser |
|
两个判读口径随表:其一,「完整负载前判断」指解析器在需要完整负载前按帧头声明拒绝,不等于底层 socket 从未预读负载;其二,ErrFrameTooLarge、IOException 等是库级报告,不能据此推定线缆上的 GOAWAY 行为。Undertow 的 PADDED DATA 路径另有一个待验证的比较量候选——共享 length 先被减 1,帧长判断所用的 getActualLength() 可能比原始声明少 1 字节;第八个样本 hyperium/h2 v0.4.12 的设置方向链已定位,但底层 codec 的精确拒绝时机未按固定依赖版本核对,未计入完整样本。七个完整样本均未见第二个「以对端设置作入站上限」的案例——这既是 Ember 方向错误的孤例性提示,也是抽样的负结论边界,不外推到其它版本。
相邻历史案例中,Bandit CVE-2026-42788(GHSA 2026-05-01,影响 >0.3.5、<1.11.0,修复 1.11.0)值得展开,因为它是「检查时机」的直接先例:旧版 frame.ex:56-64 先匹配 _payload::binary-size(length)、再跑 when length > max_frame_size 的 guard——「先凑齐负载、后跑检查」与 Ember 的 9 + length 门槛结构逐行对应,一个未完成超限帧同样让检查永远等不到执行;修复把长度 guard 挪到负载匹配之前,与 peekDeclaredLength 同向。但两案只有「时机」一根轴重合:Bandit 的比较基准一直是 local_settings,方向本来就是对的——这反过来衬托 Ember 的方向错误在可查先例中是孤例(X6/R2)。维护者公告另附 1.10.4 上的 h2c 演示输出(未独立复测,只能称「公告所附演示」)。Netty CVE-2026-33871(影响 ≤4.1.131.Final / ≤4.2.10.Final,修复 4.1.132.Final / 4.2.12.Final)的 CONTINUATION 洪泛(帧数/CPU)与 SwiftNIO CVE-2022-0618(影响 1.0.0–1.19.2,修复 1.20.0,由 fuzzing 发现)的 padding 问题(帧内长度关系)是不同维度的邻居(X6/R2)。
9. 边界与适用条件
版本与工件:动态结论覆盖 Scala 3 工件的 0.23.36/0.23.37;_2.12/_2.13 与 1.0.0-M 系列依赖公告范围声明与 blob 相同性,未做运行时复现——不做的原因是显式的:M47 与 0.23.36、M48 与 0.23.37 的核心读帧文件 blob 逐字节相同,加上已有 _3 动态证据,再跑一轮 M47/_2.13 的增量价值只剩 Scala 版本差异本身(S-A1 的 D5 决策)。旧版三条入口(prior knowledge、TLS+ALPN、h2c Upgrade)均有动态对照;新版 TLS+ALPN 与 prior knowledge 拒绝超限声明,内建 h2c Upgrade 已移除。旧版 h2c 头部 MAX_FRAME_SIZE 方向绕过由 E9 单独验证;0.23.37 无内建 Upgrade 入口,未对该头部变体做同路径对照。
数字的性质:16 GiB 与 1024× 是公告容量估算;16,777,223/16,842,758 是静态条件界;E4/D2 的发送端字节数是时序产物;D9/D12 的直方图是全进程类浅大小,未直接仪表化 acc.length(D13 止损,无测长数据);每连接驻留随已收字节近似线性,但 OOM 所需连接数与堆大小的函数关系未在同条件下测定,实验值(8 连接上限、96/20 MiB 堆)不外推。
证据状态:客户端复现是本地实验对公告推断的补全,不代表互联网上存在在野利用;KEV 未收录不构成反证。以下事项未验证,不作为本文结论:合法 HPACK 推送处理、未测尺寸/配置下其它合法大压缩块的客户端语义终点、D11 单帧块策略差异的独立安全影响、客户端 TLS 入口的动态对照、修复版每轮实际 acc 驻留、D8 两类帧之外的合规长度慢连接与客户端滴灌、M 系列运行时行为。
写侧对称面:自然的问题是「Ember 作为发送方有没有同构原语」。静态核查(S-A4 写侧核查)在常规路径上未发现:0.23.36 的常规 DATA 写路径按对端宣告的 maxFrameSize 做 chunkLimit 分块,并与流窗口、容量 128 的出站帧队列、连接窗口串联形成背压——不存在「入站声明长度驱动无界累积」的写侧镜像。分块取值恰好是 remoteSettings 的正确用法(对照 5.3 节的误用):
// http4s@6c8f2edd → ember-core/shared/src/main/scala/org/http4s/ember/core/h2/H2Stream.scala:82-96
def sendMessageBody(mess: Message[F]): F[Unit] = {
val noTrailers = !mess.attributes.contains(Message.Keys.TrailerHeaders[F])
val maxFrameSize = remoteSettings.map(_.maxFrameSize.frameSize) // ← 写侧按对端宣告值分块:方向正确
maxFrameSize.flatMap { maxFrameSize =>
val sendBody =
mess.body
.chunkLimit(maxFrameSize)
.foreach(c => sendData(c.toByteVector, endStream = false))
.compile
.drain >> sendData(ByteVector.empty, endStream = true).whenA(noTrailers)
sendBody.onError { case _ =>
rstStream(H2Error.InternalError)
}
}
}两个限定词防止误读:出站队列的 128 是项数不是字节——对端给大流窗口时单个待发块可达约 16 MiB,128 项的字节量可观;且 0.23.36 写侧 idleTimeout 检查位于 writeBlock.get 返回之后,并非所有写阻塞都有硬超时(0.23.37 由 f35f69223 的 withStallTimeout 包装补上写侧终止,属独立加固)。连接窗口等待也值得一提:窗口不足时实现先写非 DATA 帧、再等连接 writeBlock,并不把 DATA 切到剩余窗口。这里的超时要分两层:流级窗口耗尽时 sendData 等待 writeBlock 受 sendTimeout 约束,这条连接级等待则无同类超时,且窗口可由后续 WINDOW_UPDATE 回补——所以「存在背压」不应读成「整条连接有硬字节上限」。该负结论限于所追踪的常规 body 路径:sendHeaders/sendPushPromise 把整个编码块装入单帧不拆分、服务端推送响应体不走 chunkLimit,这些是未验证的局部候选,不能据此写「出站安全」。
不涉及的领域:Ember 的 HTTP/1.1 解析器、TLS 实现本身、http4s 其他后端——Blaze/Netty 后端不受此 CVE 影响(漏洞在 ember-core 独有的 H2 代码路径,S4 位置可证)。
把全文收束成四问。为什么成立:RFC 9113 把 24 位长度放在负载之前、把入站上限的宣告权留给接收方,Ember 却把「帧完整性」设为校验的前置条件、又在四个分支把对端的自我宣告当作本地权威——时机、可达性、方向三重失效,使一份「存在的大小校验」在攻击者选择的维度上逐条落空。何时可用:目标解析受影响的 ember-core 工件、显式启用 .withHttp2、攻击者能建立一条 H2 连接(服务端任意可达入口,客户端需能引导其访问恶意源站),无需认证与合法请求。影响多大:未认证远程堆耗尽,单帧约 16 MiB 承诺(公告估算约 1024× 配置上限;默认连接数下约 16 GiB 容量估算),服务端 6 连接、客户端单连接即在受限堆实测 OOM。如何阻断:升级 0.23.37 / 1.0.0-M48(入口早拒绝,以 localSettings 为准);临时关闭 H2 或以强制帧长校验的代理终止 H2;检测盯住接近 0xFFFFFF 的声明帧头与修复版 GOAWAY 日志;审计则沿「首次长度判断的时机 + 本端/对端设置的赋值方向」两条线走。
附录:证据编号速查
正文证据编号沿用仓库 evidence-inventory.md(含完整的「结论—证据」映射与事实/推断边界),专题笔记位于 research-notes/。编号族含义与高频条目如下,便于脱离仓库阅读时定位每个结论的出处:
编号 | 内容提示 |
E1/E2 | 服务端复现:6 连接 15 MiB 未完成帧 → 96 MiB 限堆 OOM;单连接堆 6→15 MiB、无拒绝 |
E3/E4 | 客户端被恶意源站单连接 OOM(补全公告未复现链路);0.23.37 对照:入口即 GOAWAY 6、无 OOM |
E5/E8/E9/E12 | 方向绕过动态对照:服务端 HEADERS(恶意 SETTINGS)/客户端 PUSH_PROMISE/h2c |
E6/E7 | 修复版读入边界十轮(D2);三条入口双版本对照(D3) |
E10 | 双计时器滴灌:半开 header 块 5.014 秒终止,未完成 DATA 30.251 秒存活(D8) |
E11/E14 | 驻留组成与多流的全进程 GC 直方图(D9/D12) |
E13 | 单帧 header 块策略 12 轮对照(D11) |
S1–S4 |
|
S7/S8/S10 | HTTP/2 两端默认关闭;服务端三条入口;h2c 头直接构造 |
S11–S13 | 方向错误自 2021 初始移植即存在;注释「16 mb」差一瑕疵;12-tag 版本矩阵(S-A1) |
S14/S15/S18 | header 块限额矩阵(S-A2);双计时器源码(S-A5);单帧块判定门(S-A8) |
S16/S17/S19/S20 | 驻留组成静态依据(S-A6);读侧帧长测试考古(S-A7);多流驻留静态依据(S-A9);旧版完整帧比较量、未知帧和服务端续帧 PUSH_PROMISE 的补充源码核查 |
X1 | GHSA 维护者公告(2026-09-24 与 09-28 两次复核) |
X6/X7 | RFC 推论边界与相邻案例调研(R2);七栈横向抽样(R4) |
F1–F4 | 修复三动作( |
F5–F8 | 修复提交的版本包含关系;0.23.37 叠加的独立变更(h2c 入口移除、终止续帧块检查、stall/idle timeout) |
R1/R3 | NVD 记录核查;代码与披露时间线考古 |
D1–D13 | 扩展实验专题笔记;D13 为埋点构建止损记录,无 E15 测长数据 |
参考
- GHSA-gq9p-f254-h286 维护者公告(CVE-2026-88975,CVSS 3.1 7.5,CWE-400)
- RFC 9113 §4.1–4.2、§6.5.2、§6.9、RFC 7541 附录 B
- 修复提交 87cf334fa、h2c Upgrade 移除 f910d7c11、v0.23.37 发行说明、v1.0.0-M48 发行说明
- 本仓库:证据清单、复现说明、源码基准及 research-notes/ 各专题笔记
- 相邻案例:Bandit GHSA-q6v9-r226-v65f(CVE-2026-42788)、Netty GHSA-w9fj-cfpg-grvv、SwiftNIO GHSA-q36x-r5x4-h4q6