我们如何在 hyper HTTP 库中发现了一个 bug

Lobsters Hottest 新闻

摘要

Cloudflare 工程师在调试其 Images 服务间歇性故障时,发现 hyper HTTP 库中存在一个竞态条件 bug,该故障导致大图像转换返回截断的响应。修复只需要四行代码。

<p><a href="https://lobste.rs/s/pvdvww/how_we_found_bug_hyper_http_library">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/24 01:52

# 我们如何发现 hyper HTTP 库中的一个错误 来源:https://blog.cloudflare.com/hyper-bug/ 2026-06-22 · 12分钟阅读 Images (https://developers.cloudflare.com/images/) 服务在 Workers (https://developers.cloudflare.com/workers/) 上使用 Rust 构建,运行于 Cloudflare 边缘网络的每台机器上。为处理客户端连接,我们使用了 hyper (https://github.com/hyperium/hyper),一个用于 Rust 的开源 HTTP 库。去年,我们引入了 Images 绑定 (https://blog.cloudflare.com/improve-your-media-pipelines-with-the-images-binding-for-cloudflare-workers/),以便在 Workers 中实现自定义、可编程的远程图像处理工作流。2025 年底,我们重构了该绑定,使 Workers 运行时与 Images 服务之间建立更直接的本地连接。部署后不久,我们收到报告称来自绑定的转换请求会失败——但只是间歇性地出现,且仅针对较大的图像。更奇怪的是,这些请求的响应返回了 `200` 状态,且没有任何错误日志。图像数据仅仅是截断了:本应为两兆字节的响应,可能只到达了几百千字节。 我们花了六周时间追寻一个几乎看不见的错误——一个仅在特定条件下出现的竞态条件——位于 hyper 库中,它影响了 Images 绑定如何将处理后的图像数据返回给客户端。最终,修复它只需要四行代码。 ### 跳转、交接与 hyper 当开发者在 Cloudflare 上构建应用时,他们通过一组平台服务组成全栈应用,这些服务通过绑定对 Workers 开放。绑定 (https://developers.cloudflare.com/workers/runtime-apis/bindings/) 提供了直接的 API 来访问开发者平台上的资源,如计算 (https://www.cloudflare.com/products/#compute)、存储 (https://www.cloudflare.com/products/#storage)、AI 推理 (https://www.cloudflare.com/products/#ai) 和媒体处理 (https://www.cloudflare.com/products/#media)。 Images 绑定将图像优化与交付解耦;你可以对图像进行转码、合成或操作,而无需将输出作为 HTTP 响应返回。它还允许你按任意顺序应用优化参数 (https://developers.cloudflare.com/images/optimization/features/),而无需遵循 URL 接口 (https://developers.cloudflare.com/images/optimization/features/#url-interface) 所强加的固定顺序。在这里,Worker 可以直接将图像数据传递给 Images API,将操作串联起来,并将处理后的结果以流形式返回: ```js const result = await env.IMAGES .input(image) .transform({ width: 800, rotate: 90 }) .output({ format: "image/avif" }); return result.response(); ``` 在较高层面上,这就是图像数据如何通过我们各种服务的方式: *管道代表中介与 Images 之间的套接字连接,数据通过内核缓冲区从一个进程传递到下一个进程。* 绑定通过由 Workers 运行时管理的套接字连接与 Images 通信。套接字连接是两个进程之间的通信通道。每个套接字的一端都有由操作系统内核管理的缓冲区;这些缓冲区是临时存储区域,数据在一方写入后、另一方读取之前暂存于此。Hyper 在 Images 服务一侧管理连接,从套接字读取传入请求,并向套接字写回响应。 当请求使用 Images 绑定后,Images 服务会读取输入,执行请求的优化操作,并对结果进行编码。然后将整个编码后的图像作为一个内存块传递给 hyper。Hyper 将该响应数据写入其内部缓冲区。此时,hyper 认为编码工作已完成,因为它已拥有发送所需的所有字节。下一步是将其内部缓冲区刷新到套接字的出站缓冲区,将数据从 Images 服务移动到另一端的中间人。 如果另一端的读取器速度快,hyper 可以一次完成刷新——因为读取器会以数据到达的速度尽快消费数据,出站缓冲区将有空间。一旦所有数据发送完毕,hyper 会对套接字发出 `shutdown`,表示连接已结束,不再写入更多数据。但如果读取器速度较慢(哪怕慢了几毫秒),那么出站缓冲区将填满,hyper 需要等待直到有空间继续写入。 ### 走向本地 Cloudflare 网络上的所有入站流量都经过 FL,这是一个内部中间服务,负责运行安全与性能功能,并将请求路由到相应的后端。我们首次启动绑定时,图像数据从 Workers 运行时流经 FL,最终到达 Images 服务。这条路径自然地适合我们最初的发布,并且遵循与 URL 接口相同的架构。但随着时间的推移,这种与 FL 的耦合成为一种约束:对绑定的每个更改都必须遵循 FL 的发布周期。 2025 年 12 月,Images 团队用一个新的中间服务替换了 FL,这是一个在同一台机器上运行的内部 Worker 绑定。在原有架构中,数据通过网络套接字经过 FL;这条路径带来了 FL 完整处理流程的开销,例如 DNS 查询和路由。内部绑定使用 Unix 套接字替换了这些,直接连接同一台机器上的服务,绕过了 FL 和网络堆栈的开销。这使得前往 Images 的请求路径更快,并且让团队能够独立控制绑定的发布。 部署后几天内,我们收到了第一个客户报告。 ### 200 OK(并不 OK) 问题的最初迹象来自一个配置不标准的客户:两层图像处理,其中一个管道嵌套在另一个管道中。首先,他们的 Worker 使用 Images 绑定将来自 R2 的多张大图合成一张组合 JPEG——包括一张 JPEG 背景和多个 PNG 叠加图层。其次,他们通过 URL 接口进一步压缩、转码和调整结果大小。 *错误源于内部管道的返回路径,响应在到达外部管道之前被截断。* 内部管道(转换绑定)负责合成。外部管道(转换 URL)负责交付优化,如缩放和格式转换。这种分层方式意味着,当内部管道静默返回截断的响应时,唯一的可见错误出现在上一层: ``` error reading a body from connection: end of file before message length reached ``` 外部管道从内部管道接收到 HTTP `200`,附有一个 `Content-Length` 标头,承诺了几兆字节。实际主体仅为其中的一小部分:在一次请求中,预期的 3.3 MB 仅到达约 200 KB。错误出现在外部管道,但截断可能源于绑定、中间服务、Images 服务或它们之间的某个环节。 当浏览器接收到截断的图像时,结果显而易见。根据格式不同,图像要么部分渲染(例如,底部一半缺失或变灰),要么完全解码失败,显示为损坏的图像。 ### 在黑暗中调试 从这时起,我们沿着请求路径向内工作,测试每一层以隔离截断发生的位置。一些努力走进了死胡同;另一些则留下了缩小搜索范围的线索: - **构建可复现环境。** 我们构建了一个模拟客户嵌套设置的 Worker,然后逐步剥离层次,直到仅用绑定就能触发错误。一个小脚本让我们可以批量发送请求。在一次早期运行中,25 个请求中有 19 个失败。实际到达的数据量——大约 200 KB——与生产环境中套接字缓冲区的大小惊人的接近。这确认了问题与客户的配置无关,并给了我们一个按需可靠触发错误的方法。 - **调查超时。** 早期我们怀疑截断可能与超时行为有关(即连接在时间限制后被关闭)。这个理论不成立,因为截断与请求时长无关。 - **更新 hyper 版本。** 首次报告错误时,我们运行的是 0.14.x,而最新的 hyper 版本大约是 1.8.x。我们在 hyper 0.14、1.7 和 1.8 版本上进行了测试,以防最明显的答案就是正确的(也是最简单的)。但错误在每个版本中都出现了,这意味着上游并没有修复。 - **本地复现。** 我们在 macOS 和 Debian 虚拟机上运行了本地集成测试。即使在相当大的负载下,我们的本地请求也从未触发任何失败。直接使用 curl 请求绑定套接字并重放捕获的请求似乎始终有效。错误只出现在完整的生产路径上,当存在真实并发且套接字另一端有真实的 Workers 运行时客户端时才会出现。这使我们怀疑运行时本身。 - **排除 Workers 运行时。** 我们检查了 Workers 运行时用来通过绑定套接字与 Images 通信的 HTTP 客户端。连接两侧的任何追踪中都没有显示表明意外关闭或提前终止的系统调用。我们观察到客户端行为正确,并且多个其他服务使用相同的客户端也没有问题。 - **分布式追踪。** 通过端到端检查请求追踪,我们确认截断的主体在到达客户设置中的外部转换层之前就已经存在了。这将问题缩小到了内部管道——通过 Images 服务的绑定路径。 - **为中介服务添加检测。** 我们为中介服务添加了检测,以在转发响应数据之前测量主体大小。主体在离开 Images 服务时已经被截断,因此中介被排除了。 - **在 Images 服务内部进行更深层次的追踪。** 在服务层面,请求被处理,图像被正确编码,并且响应以 HTTP `200` 发送。唯一一致的信号是,错误依赖于时间:它只出现在生产路径上,有真实并发,且只针对较大的图像。 ### 内核中的真相 用于应用层调试的工具只告诉我们系统认为自己正在做什么。但根据系统的说法,一切正常:追踪显示响应已发送;日志没有报告错误,Images 服务在每个请求上都返回了 `200`。 为了看看系统实际在做什么,我们将 `strace` 附加到了 Images 服务。`strace` 记录进程向内核发出的系统调用,这能向我们展示哪些字节被写入,何时调用了关闭,以及客户端是否发送了任何终止信号。 设置追踪需要小心。`strace` 通过在系统调用发生时拦截它们来工作,这会为每个调用增加少量时间开销。将过滤器限定在一小组系统调用上可以将开销降到最低。然而,扩大过滤器会使进程变慢,刚好足以改变刷新与关闭检查之间的时间——并让错误完全消失。这本身就加强了我们的理论:问题是对时间敏感的。 使用一个复现 Worker,我们触发了错误,并比较了成功请求与失败请求的系统调用输出。在成功请求中,响应是按块写入的,随着套接字缓冲区允许,仅在所有数据发送完毕后才会调用 shutdown。例如,可能看起来像这样: ``` sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264 sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352 // ...继续写入直到缓冲区清空... sendto(42, "...", 292352) = 292352 shutdown(42, SHUT_WR) = 0 ``` 当我们复现错误时,一个失败请求看起来像这样: ``` sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264 shutdown(42, SHUT_WR) = 0 ``` 这里只有一次写入——仅够发送头部和消息体的一小部分——然后立即调用了 shutdown。在一个 14.9 MB 的响应中,只发送了大约 219 KB。剩下的约 14.8 MB 图像数据从未离开 hyper 的内部缓冲区,而且在写入和关闭之间也没有来自客户端的终止信号。相反,Images 服务提前关闭了连接,并且真心相信自己已经完成了。 失败的请求确认了错误是一个间歇性触发的竞态条件。请求成功与否取决于刷新和关闭操作是否重叠,这从请求到请求都可能变化。当缓冲区在 hyper 决定连接已完成的那一刻仍然满时,数据就会丢失。 *当读取器消费数据的速度慢于 hyper 写入的速度时,出站缓冲区会填满。如果 hyper 在缓冲区清空之前关闭连接,那么只有一小部分响应能到达中介;这些不完整的数据会被转发回 Workers 运行时和客户端。* 12 月的重构并没有引入这个错误,这个错误在 hyper 中已经存在多年,跨越了多个主要版本。但新的中介改变了在套接字响应端读取者是谁。我们的工作理论是,前一个中介 FL 消费数据足够快,使得在响应期间套接字缓冲区很少满。新的读取器以某种节奏读取,偶尔会让缓冲区在较大响应期间填满。这些几毫秒的反压,由一项使其他一切都变得更快的改进引入,就足以暴露一个一直隐藏在明显处的缺陷。 ### 进入分发循环 Hyper 的 HTTP/1 连接生命周期由一个位于 `dispatch.rs` 文件中的状态机驱动。它运行一个循环,读取请求、写入响应、将写入缓冲区刷新到套接字,并决定何时关闭。简化形式如下: ```rust fn poll_loop(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> { loop { let _ = self.poll_read(cx)?; let _ = self.poll_write(cx)?; let _ = self.poll_flush(cx)?; if !self.conn.wants_read_again() { return Poll::Ready(Ok(())); } } } ``` 更精确地说,`poll_flush` 之前的 `let _` 就是错误所在。在 Rust 中,`let _ = expr` 丢弃了表达式的结果,包括 `Poll::Pending`——即刷新尚未完成的信号。刷新可能仍有兆字节的数据在其缓冲区中,但循环永远不会知道。 当请求失败时,以下是确切的事件序列: 1. Images 服务完成图像的编码,并将整个响应作为一个内存块交给 hyper。 2. Hyper 将该块写入其内部缓冲区,并将其写入状态标记为 `Writing::Closed`。从编码的角度来看,工作已完成——没有剩下需要编码的内容。 3. Hyper 调用 `poll_flush` 将缓冲数据移至套接字。在我们之前的例子中,套接字接受了大约 219 KB。剩下的约 14.8 MB 留在 hyper 的缓冲区中。套接字已满,因此内核返回 `Poll::Pending`。 4. `poll_loop` 使用 `let _` 丢弃了 `Poll::Pending`。 5. 它检查 `wants_read_again()`。完整的请求已经被接收,所以返回 `false`。 6. `poll_loop` 返回 `Poll::Ready(Ok(()))`,表示循环已完成,即使刷新尚未完成。 7. `poll_shutdown()` 触发。发出 `SHUT_WR` 系统调用。 8. 客户端收到 219 KB 和一个 EOF(文件尾),表示连接已关闭,尽管它期望的是 14.9 MB。 在第二步中,hyper 在响应体被缓冲后立即将写入操作标记为完成(即编码完成时)

相似文章

Codex发现一个隐藏的HTTP/2炸弹

Lobsters Hottest

Codex发现了一个名为“HTTP/2炸弹”的远程拒绝服务漏洞,该漏洞针对主流Web服务器(包括nginx、Apache、IIS、Envoy、Pingora)中的HPACK压缩,通过将压缩炸弹与流量控制保持相结合,快速耗尽服务器内存。

AMD不愿修复的远程代码执行漏洞

Hacker News Top

一名研究人员发现AMD的AutoUpdate软件存在远程代码执行漏洞,原因在于不安全的HTTP下载链接和缺乏证书验证。AMD最初以超出范围为由不予理会,但在公众关注后同意发布CVE并修复。