我在 Apple 的 fsck_hfs 中发现了一个 Bug

Hacker News Top 新闻

摘要

macOS Sequoia 上 Apple 的 fsck_hfs 工具存在一个 Bug,在配备 8GB RAM 的机器上,对大型 HFS+ 卷(24TB 以上)进行检测时会误报损坏错误,而文件系统本身并无问题。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/03 15:40

# 我在苹果的 fsck_hfs 中发现了一个 Bug — 这是追踪过程 来源:https://medium.com/@kivancgunalp/i-found-a-bug-in-apples-fsck-hfs-here-s-how-i-tracked-it-down-edc782ce5cf9 作者:Kivanc G (https://medium.com/@kivancgunalp?source=post_page---byline--edc782ce5cf9---------------------------------------) **TL;DR:**`fsck_hfs` 在 macOS Sequoia(版本 hfs-683.x)中存在一个缓存耗尽缺陷,会在大型 HFS+ 卷上报告虚假的损坏。在 8 GB RAM 的机器上,24 TB 或更大的卷会在扩展属性检查期间触发“Couldn't read node”错误。你的数据没问题——问题是工具本身的问题,而不是文件系统。16 GB 以上 RAM 的机器不受影响,旧版 macOS 也不受影响。 如果你一直关注我关于 24TB HFS+ 卷 bug 的探索,这篇就是续集。在之前的文章中,我记录了一个 24TB 外置硬盘上持续出现的损坏错误。这次,我将展示如何一步步将错误追溯到根本原因——结果完全出乎我的意料。**剧透:文件系统一直没问题。bug 在 `fsck_hfs` 本身。** ## 环境设置 我有一个 24TB 外置硬盘,格式化为日志式 HFS+。每次我在 Mac mini M1 上运行 `fsck_hfs`,它都会报同样的错误: `` ** Checking extended attributes file. Couldn't read node #61432 The volume 24TB TOSHIBA could not be verified completely. volume check failed with error 12 `` 错误 12 是 `ENOMEM`——“内存不足”。在一台只有 8 GB RAM 的机器上?运行文件系统检查?这不对劲。 这个错误完全可复现。每次都是相同的节点编号。即使在新格式化的卷上,几乎没有数据,也会出现。这排除了渐进式数据损坏,表明问题是确定性的。 ## 排除硬件 我最先怀疑硬件——可能是硬盘盒的 USB 桥接芯片导致数据损坏,或者硬盘本身有缺陷。但是同样的错误也在另一块完全不同的 24TB 硬盘上出现,采用不同的硬盘盒和桥接芯片。两块不同的硬盘,同样在节点 #61432 出错。这已经指向非硬件问题。 为了彻底,我检查了内核日志(`dmesg`),并在 fsck 运行时用 `fs_usage` 监控 I/O: `` sudo fs_usage -w -f diskio fsck_hfs `` **零 I/O 错误。**整个检查过程中都是干净的线性读取。从磁盘读出的数据是没问题的。无论什么东西在失败,都不是硬件的问题。 ## 读取磁盘结构 如果硬件没问题,那也许是文件系统元数据本身损坏了。我决定转储并解析原始卷头,看看 Attributes B-tree 的样子。HFS+ 卷头位于分区字节偏移 1024(扇区 2)处: `` sudo xxd -l 1024 -s 1024 /dev/rdisk7s2 `` 解析后发现 Attributes 文件分叉数据:逻辑大小 512 MiB,单个连续 extent 从分配块 36588 开始,共有 16,384 个分配块,每个 32KB。 接下来,我读取 B-tree 头节点——Attributes 文件的前 8192 字节: `` sudo xxd -l 512 -s 1198915584 /dev/rdisk7s2 `` B-tree 头告诉我所有信息: - **节点大小:**8192 字节 - **总节点数:**65,536(占满整个 512 MiB 文件) - **空闲节点:**65,341 - **已用节点:**195 - **最后一个叶子节点:**155 - **树深度:**3 因此 Attributes B-tree 有 65,536 个节点槽位,但只用了 195 个。活跃树完全位于前 ~155 个节点内。节点 #61432 深处于未使用区域。 **节点 #61432 是否在文件范围内?**每个节点 8192 字节,节点 #61432 位于字节偏移 503,250,944——完全在 536,870,912 字节的文件内。它在范围内。 **节点 #61432 在位图中被标记为已用吗?**B-tree 节点使用位图存储在头节点的记录 2 中。我计算出节点 #61432 对应位图的字节 7679,位 7(MSB 优先)。读取该区域: `` sudo xxd -l 512 -s $((1198915584 + 8192 - 512)) /dev/rdisk7s2 `` 该区域全为零。**节点 #61432 被正确标记为空闲。** **磁盘上节点 #61432 处有什么?** `` sudo xxd -l 256 -s $((1198915584 + 61432 * 8192)) /dev/rdisk7s2 `` 全为零。完全为空,符合空闲节点的预期。 每一个磁盘上的结构都是有效且内部一致的。卷头正确,B-tree 头正确,位图正确地将节点 61432 标记为空闲,节点本身也正确地清零了。文件系统没有任何问题。 ## 定位代码 既然数据没问题,那 bug 一定在 `fsck_hfs` 中。苹果将 HFS+ 作为 Darwin 发布的一部分开源,所以我克隆了仓库: `` git clone https://github.com/apple-oss-distributions/hfs.git `` 快速 grep 找到了错误信息: `` grep -rn "Couldn.t read node" --include="*.c" `` 找到了两个文件:`SRepair.c` 和 `SVerify2.c`。相关代码在 `SVerify2.c` 中的函数 `BTCheckUnusedNodes`: `` int BTCheckUnusedNodes(SGlobPtr GPtr, short fileRefNum, UInt16 *btStat){ BTreeControlBlock *btcb = GetBTreeControlBlock(fileRefNum); unsigned char *bitmap = ...; unsigned char mask = 0x80; UInt32 nodeNum; for (nodeNum = 0; nodeNum < btcb->totalNodes; ++nodeNum) { if ((*bitmap & mask) == 0) // Node is FREE { // Read the node to verify it's all zeros err = btcb->getBlockProc(btcb->fcbPtr, nodeNum, kGetBlock, &node); if (err) { fsck_print(ctx, LOG_TYPE_INFO, "Couldn't read node #%u\n", nodeNum); return err; } // ... verify node contents are zero ... // Release the node btcb->releaseBlockProc(btcb->fcbPtr, &node, kReleaseBlock); } // Advance bitmap pointer mask >>= 1; if (mask == 0) { mask = 0x80; ++bitmap; } } } `` 这个函数遍历 Attributes B-tree 中**全部 65,536 个节点**。对于位图中标记为空闲的每个节点,它从磁盘读取原始节点,验证是否全为零。有 65,341 个空闲节点,这意味着大量读取操作。 `getBlockProc` 的调用链为:`GetFileBlock` → `MapFileBlockC` → `CacheRead` → `CacheLookup`。在 `CacheLookup` 中,我找到了罪魁祸首: `` int CacheLookup(Cache_t *cache, uint64_t off, Tag_t **tag){ // ... 搜索哈希表 ... // 缓存未命中:从堆分配一个新的 Tag temp = (Tag_t *)calloc(sizeof(Tag_t), 1); temp->Offset = off; // ... 插入哈希表 ... // 为标签获取缓冲区 if (temp->Buffer == NULL) { temp->Buffer = CacheAllocBlock(cache); if (temp->Buffer == NULL) { // 尝试驱逐 error = LRUEvict(&cache->LRU, (LRUNode_t *)temp); if (error != EOK) return (error); temp->Buffer = CacheAllocBlock(cache); if (temp->Buffer == NULL) return (ENOMEM); // 错误 12! } } } `` ## 缓存耗尽缺陷 原理如下:`fsck_hfs` 在启动时预分配一个缓存——一个用于所有磁盘读取的 32KB 块池。池的大小由可用系统 RAM 决定: | RAM | 缓存大小 | 缓存块数 | |--------|---------|---------| | 4 GB | 512 MB | 16,384 | | 8 GB | 1 GB | 32,768 | | 16 GB | 2 GB | 65,536 | 原始缓冲区内存在启动时已分配,并且是足够的。问题出在扫描期间这些缓冲区的处理方式上。 `BTCheckUnusedNodes` 快速遍历数万个空闲节点,每个被访问的独特磁盘偏移都会通过 `calloc` 分配一个 `Tag_t` 结构,并插入到缓存的哈希表中。每个标签从池中占用一个 32KB 缓冲区。当释放路径运行时,它将标签返回到 LRU 列表——但 LRU 管理跟不上分配的速度。 在一台 8 GB 机器上,缓存大小为 1 GB / 32,768 个块,扫描最终会达到这样一个状态:池中的每一个块都被一个活跃标签持有,而 LRU 列表完全为空。`CacheAllocBlock` 返回 NULL,因为空闲池已耗尽;然后 `LRUEvict` 失败,因为 LRU 中没有任何东西可以驱逐。`CacheLookup` 别无选择,只能返回 `ENOMEM`。这里“内存”耗尽的不是系统 RAM——而是内部缓存管理元数据。 ## 用调试构建确认 理论很好,但我想拿出证据。所以我拉取苹果开源的 HFS 代码,自己构建了 `fsck_hfs`,并在 `CacheLookup`、`CacheAllocBlock`、`LRUEvict` 和 `BTCheckUnusedNodes` 中加入了额外的调试输出。 在失败的卷上运行这个带探测的二进制文件,得到了确凿证据: `` LRUEvict(1477): empty? ERROR: CacheRead: CacheLookup error 12 `` LRU 列表为空。在失败的时刻,池中的每一个缓存块都被一个活跃标签持有,驱逐代码无法回收任何东西。`CacheLookup` 返回错误 12(`ENOMEM`),这个错误沿着调用链传播:`CacheRead` → `GetFileBlock` → `getBlockProc` → `BTCheckUnusedNodes`,最终打印出“Couldn't read node #61432”并退出。 这精确地证实了机制:不是驱逐因杂乱而失败——而是根本没有东西可驱逐。扫描在完成之前已使缓存池饱和。 ## 证据:在四台机器上测试 为了进一步确认这个理论,我在四台不同的 Mac 上运行了相同的测试。我使用相同的硬盘和连接线,只更换计算机: | 机器 | macOS | fsck_hfs | 缓存大小 | 结果 | |-------------------------------|---------|-----------------|---------|------| | MacBook Air (Intel i5) | 12.7.6 | hfs-583.100.10 | 512 MB | **通过** | | MacBook Pro (M3) | 15.5 | hfs-683.120.3 | 2 GB | **通过** | | Mac mini #1 (M1, 8GB) | 15.5 | hfs-683.120.3 | 1 GB | **失败** | | Mac mini #2 (M1, 8GB) | 15.4 | hfs-683.120.3 | 1 GB | **失败** | 模式很清晰: - **MacBook Air** 运行的是较旧的 `fsck_hfs`(hfs-583),要么没有 `BTCheckUnusedNodes` 函数,要么实现方式不同。它能轻松通过。 - **MacBook Pro** 运行的是有 bug 的 hfs-683 版本,但因为有 16 GB RAM,它获得了 2 GB 缓存(65,536 个块),有足够的冗余来扫描所有 65,341 个空闲节点而不会耗尽标签元数据。 - **两台 Mac mini** 都运行 hfs-683,但只有 8 GB RAM,获得了 1 GB 缓存(32,768 个块)。这正好是标签累积导致扫描完成前缓存耗尽的临界范围。 ## 总结 文件系统从未损坏。macOS 15.x (Sequoia) 中的 `fsck_hfs` 工具在其 `BTCheckUnusedNodes` 函数中存在一个 bug:在验证未使用的 B-tree 节点是否已清零时,它会在扫描完成前使自己内部的块缓存池饱和。在具有大型预分配 B-tree 的卷上(例如 24TB HFS+ 卷上 512 MiB 的 Attributes 文件,有 65,536 个节点槽位),在 8 GB RAM 的机器上,缓存会耗尽可驱逐的块,扫描以虚假的“Couldn't read node”错误中止。 通过自定义调试构建,在失败点显示 LRU 列表为空,从而得到确认。这个 bug 完全与硬盘无关——我是在两块来自不同制造商、不同硬盘盒的 24TB 硬盘上复现的。 **讽刺的是:**一个旨在验证文件系统完整性的函数本身却是有缺陷的——它在完美的卷上报告了虚假的损坏。如果你在运行 macOS Sequoia 的 8 GB 机器上,在大型 HFS+ 卷上看到“Couldn't read node”错误,你的数据几乎肯定没问题。问题是检查工具本身,而不是被检查的对象。 ## 修复方法:绕过未使用节点检查的缓存 知道根本原因是一回事,修补它又是另一回事。我考虑了两种方法: **修复缓存本身。** 取消 `CacheLookup` 中的 `LRUHit` 调用注释,并调整 `LRUHit` 中的提前返回,使得新标签实际被插入 LRU 列表。这在技术上是“正确”的修复——它解决了导致标签泄漏的底层缓存 bug。但缓存是 `fsck_hfs` 所有部分共享的基础设施:日志重放、目录扫描、extents 检查、分配位图验证。错误的修复可能会悄然破坏其中任何一个功能,而且在不理解 `LRUHit` 调用最初被注释掉的原因的情况下,我无法确信我不会重新引入导致某人注释掉它的那个问题。 **对这个特定函数绕过缓存。** `BTCheckUnusedNodes` 对每个节点只读取一次,顺序读取,不会重用。缓存提供了零收益——每个缓存块都是纯粹的额外开销,永远不可能产生缓存命中。适合这种访问模式的数据结构是一个可重复使用的缓冲区,而不是一个通用的 LRU 缓存。 我选择了绕过方法。修改完全局限在 `BTCheckUnusedNodes` 内部——没有触及共享代码,如果某处出错,影响范围仅限于未使用节点验证步骤。修改后的函数在开始时分配一个缓冲区,在所有 65,000 多次读取中重复使用它,最后释放它。每次读取直接访问磁盘,完全跳过标签分配、哈希表插入和 LRU 簿记。没有缓存污染,没有 LRU 耗尽的可能,扫描干净地完成。 **重要提示:**这个修复针对上述特定的缓存耗尽 bug(**错误 12**,发生在扩展属性检查期间)。如果你的 `fsck_hfs` 报告的是不同的错误,或者你的机器上 `fsck_hfs` 工作正常,则不需要这个修复。只是为了避免有人下载并运行它,以为它是一个通用改进。 ## 获取补丁 由于苹果的开源镜像站是只读的——PR 和 issues 都被锁定——我无法向上游提交。相反,我在 GitHub 上发布了修补后的源代码: **https://github.com/kivancgnlp/fsck_hfs_cache_issue** (https://github.com/kivancgnlp/fsck_hfs_cache_issue) 仓库包含修改后的 `fsck_hfs` 源代码以及构建说明。任何在大型 HFS+ 卷上遇到相同“Couldn't read node”错误的人,都可以从那里构建自己的修复版二进制文件。

相似文章

CVE-2026-28952:Apple macOS 26.5 内核漏洞由 Claude 发现

Hacker News Top

Apple 发布了 macOS Tahoe 26.5 的安全更新,修复了多个漏洞,包括内核错误、拒绝服务攻击和沙盒逃逸。该更新修复了由不同研究人员发现的多个 CVE 漏洞,其中 CVE-2026-28952 据称由 Claude AI 发现。

解剖苹果的稀疏映像格式(ASIF)

Hacker News Top

一篇技术博文解剖了苹果在macOS Tahoe中用于虚拟磁盘的新稀疏映像格式(ASIF),涵盖了从十六进制转储中逆向工程该文件格式的过程。