我在 Apple 的 fsck_hfs 中发现了一个 Bug
摘要
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 发现
Apple 发布了 macOS Tahoe 26.5 的安全更新,修复了多个漏洞,包括内核错误、拒绝服务攻击和沙盒逃逸。该更新修复了由不同研究人员发现的多个 CVE 漏洞,其中 CVE-2026-28952 据称由 Claude AI 发现。
首个针对Apple M5的macOS内核内存损坏漏洞公开
来自加州的研究人员披露了首个针对Apple M5的macOS内核内存损坏漏洞,该漏洞绕过了MIE硬件缓解措施,并在最新版macOS上实现了本地权限提升。
首个公开的Apple M5 macOS内核内存损坏漏洞利用在Mythos Preview的帮助下5天内完成
加州研究人员借助AI工具Mythos Preview,在Apple M5硬件上构建了首个公开的macOS内核内存损坏漏洞利用,绕过了MIE。该利用链耗时5天,将在苹果修复漏洞后完全披露。
我为iozone打了补丁,使其在最新macOS上更好地进行磁盘基准测试
为iozone打补丁,使其能在运行macOS 26的Apple Silicon Mac上编译,确保磁盘基准测试无错误运行;该修复已纳入版本510。
解剖苹果的稀疏映像格式(ASIF)
一篇技术博文解剖了苹果在macOS Tahoe中用于虚拟磁盘的新稀疏映像格式(ASIF),涵盖了从十六进制转储中逆向工程该文件格式的过程。