故意破坏ZFS文件

Hacker News Top 工具

摘要

一篇技术指南,介绍如何使用'zinject'工具或手动字节级编辑故意破坏ZFS文件,以了解ZFS的错误处理和自愈行为。

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

缓存时间: 2026/06/09 14:43

# 故意破坏 ZFS 文件 来源:https://oshogbo.com/blog/90/ ## 故意破坏 ZFS 文件 大多数时候,ZFS 的全部意义在于你的数据不会被损坏。但在开发过程中,有时你需要相反的情况:一种可控的、可重现的损坏,这样你就可以观察自愈机制如何启动,看看 scrub 报告了什么,或者只是理解文件如何映射到物理磁盘。没有什么比故意破坏一个字节,然后看着 ZFS 挣扎更好的练习了。 安全规则很简单:只在对可丢弃文件支持的临时池上进行这些操作。把这些命令指向真实磁盘,与其说是教训,不如说是忏悔。这个故事讲述了在 Linux 上如何以懒惰的方式和教育的方式做到这一点。 ## 懒惰的方法 如果你只是想要一个损坏的文件,而不关心它是如何发生的,ZFS 有一个工具。在 ZFS 文件系统上创建一个文件后,`zinject` 会导致数据块返回校验和错误: ``` # zinject -t data -e checksum -a /tmp/zfs-blog-flow/single-mnt/file.bin Added handler 1 with the following properties: pool: zblog1 objset: 54 object: 3 type: 0 level: 0 range: all dvas: 0x0 ``` 你可以列出活动的 handler: ``` # zinject ID POOL OBJSET OBJECT TYPE LVL DVA RANGE --- ----------- ------ ------ -------- --- ------- --------------- 1 zblog1 54 3 - 0 0x00 all ``` 完成后清除它们: ``` # zinject -c all removed all registered handlers # zinject No handlers registered. Run 'zinject -h' for usage information. ``` 就这样。`zinject` 将模拟的损坏注入到活动池中。这是一个很好的工具,在 ZFS 测试套件中被大量使用。但如果你真正想要的是理解字节**在哪里**,那么它就完全不令人满意。为此,我们必须手动操作。 ## 由文件组成的池 我不想破坏真正的磁盘。不是因为道德原因。只是我没有现成的磁盘。是的,我可以使用带有虚拟磁盘的 VM,但普通文件更容易演示这个想法。所以第一步是在 `/tmp/zfs-blog-flow` 下用普通文件构建池。每个“磁盘”都是一个我可以使用 `dd` 和十六进制编辑器打开的文件,这就是整个技巧。 ``` $ mkdir -p /tmp/zfs-blog-flow/single-mnt /tmp/zfs-blog-flow/raidz-mnt $ cd /tmp/zfs-blog-flow $ truncate -s 512M single.img $ truncate -s 512M r1.img $ truncate -s 512M r2.img $ truncate -s 512M r3.img $ truncate -s 512M r4.img ``` 从现在开始,我在 `/tmp/zfs-blog-flow` 内部工作,所以后端文件只是 `single.img`、`r1.img` 等等。我将构建两个池,因为它们以不同的方式失败。首先是一个单一 vdev 池,没有任何冗余: ``` # zpool create -f -O atime=off \ -O mountpoint=/tmp/zfs-blog-flow/single-mnt \ zblog1 /tmp/zfs-blog-flow/single.img ``` 然后是一个四个文件的 RAIDZ2 池,带有奇偶校验: ``` # zpool create -f -O atime=off \ -O mountpoint=/tmp/zfs-blog-flow/raidz-mnt \ zblogR raidz2 \ /tmp/zfs-blog-flow/r1.img \ /tmp/zfs-blog-flow/r2.img \ /tmp/zfs-blog-flow/r3.img \ /tmp/zfs-blog-flow/r4.img ``` 两者都上线: ``` # zpool status zblog1 zblogR pool: zblog1 state: ONLINE config: NAME STATE READ WRITE CKSUM zblog1 ONLINE 0 0 0 /tmp/zfs-blog-flow/single.img ONLINE 0 0 0 errors: No known data errors pool: zblogR state: ONLINE config: NAME STATE READ WRITE CKSUM zblogR ONLINE 0 0 0 raidz2-0 ONLINE 0 0 0 /tmp/zfs-blog-flow/r1.img ONLINE 0 0 0 /tmp/zfs-blog-flow/r2.img ONLINE 0 0 0 /tmp/zfs-blog-flow/r3.img ONLINE 0 0 0 /tmp/zfs-blog-flow/r4.img ONLINE 0 0 0 errors: No known data errors ``` 有一个习惯值得记住,因为文件支持的池不是 `zpool` 默认查找的地方。要让导入在普通目录中找到池,请将其指向该目录:稍后当池被导出并且我们在背后破坏其后端文件时,我们将需要这个。 ## 将文件追踪到硬件 从单一 vdev 池开始。写入一个带有易识别模式的文件,然后让我们去找它: ``` $ yes 'SINGLE-ZFS-CORRUPTION-DEMO-BLOCK' | head -c 1M > single-mnt/file.bin $ sync ``` 获取它的 inode、大小和块使用情况: ``` $ stat -c 'path=%n inode=%i size=%s blocks512=%b' single-mnt/file.bin path=single-mnt/file.bin inode=2 size=1048576 blocks512=21 ``` 一个 1 MiB 的文件,磁盘上有 21 个扇区。记下。现在将该对象编号交给 `zdb`,并要求它详细描述该对象: ``` # zdb -ddddd zblog1/ 2 Dataset zblog1 [ZPL], ID 54, cr_txg 1, 34K, 7 objects Object lvl iblk dblk dsize dnsize lsize %full type 2 2 128K 128K 10K 512 1M 100.00 ZFS plain file 176 bonus System attributes dnode flags: USED_BYTES USERUSED_ACCOUNTED USEROBJUSED_ACCOUNTED dnode maxblkid: 7 path /file.bin uid 0 gid 0 atime Thu Jun 4 23:46:26 2026 mtime Thu Jun 4 23:46:34 2026 ctime Thu Jun 4 23:46:34 2026 crtime Thu Jun 4 23:46:26 2026 gen 12 mode 100664 size 1048576 parent 34 links 1 pflags 840800000004 projid 0 Indirect blocks: 0 L1 0:1ba00:400 20000L/400P F=8 B=14/14 cksum=... 0 L0 0:19a00:400 20000L/400P F=1 B=14/14 cksum=... 20000 L0 0:19e00:400 20000L/400P F=1 B=14/14 cksum=... 40000 L0 0:1a200:400 20000L/400P F=1 B=14/14 cksum=... ``` 一个警告让我困惑了一阵:这个参数是一个**数据集**,而不是一个池。如果你只传递池名 `zblog1`,你是在检查池顶级对象集内的对象,而不是在你的文件系统内,你会愉快地读错数字一段时间。要查看 `zblog1` 池根数据集内部,请使用 `zblog1/`。有一个快捷方式可以为你处理数据集簿记,并通过路径查找文件: ``` $ zdb -O zblog1 file.bin -vvvv ``` 无论哪种方式,我们正在寻找的是块指针,以及其中的 DVA。 ## DVA 实际上说了什么 DVA 代表**D**ata **V**irtual **A**ddress(数据虚拟地址),它是 ZFS 说“这个块在*这里*”的方式。每个 DVA 携带一个 vdev ID 和该 vdev 内的偏移量。上面转储中的第一个 level-0 块是: ``` 0 L0 0:19a00:400 20000L/400P F=1 B=14/14 cksum=... ``` 解码后,该行表示: - `0` 在 `L0` 之前 - 该块在文件内的偏移量。 - `L0` - 一个 level-0 块,意味着实际数据而不是更多元数据。 - `0:19a00:400` - DVA:vdev `0`,该 vdev 内的字节偏移量 `0x19a00`,以及磁盘上的 `0x400` 字节。 - `20000L/400P` - 逻辑大小,然后是物理大小(十六进制,所以 0x20000 = 128 KiB)。 - `F=1` - 这里有实际数据。 - `B=14` - 创建它的事务组。 偏移量是有趣的部分,并且有一个陷阱。DVA 偏移量不是从磁盘的最开始位置开始计数的。ZFS 为每个磁盘保留前 4 MiB:vdev 标签的两个副本和一个引导块。偏移量是在**那个**保留区域之后测量的,所以要找到文件上的真实字节,你加上 `0x400000` 并转换为扇区: ``` 物理字节偏移量 = 0x400000 + DVA 偏移量 扇区 = 物理字节偏移量 / 512 ``` 对于这个块: ``` 0x400000 + 0x19a00 = 0x419a00 0x419a00 / 512 = 8397 ``` 所以 `single.img` 的扇区 8397 应该包含我块的起始位置。让我们直接从后端文件检查: ``` $ dd if=single.img bs=512 skip=8397 count=2 status=none | hexdump -C | head 00000000 00 00 02 2d ff 12 53 49 4e 47 4c 45 2d 5a 46 53 |...-..SINGLE-ZFS| 00000010 2d 43 4f 52 52 55 50 54 49 4f 4e 2d 44 45 4d 4f |-CORRUPTION-DEMO| 00000020 2d 42 4c 4f 43 4b 0a 21 00 ff ff ff ff ff ff ff |-BLOCK.!........| 00000030 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff |................| * 00000220 ff ff ff ff ff ff ff ff ff ff c8 50 4d 4f 2d 42 |...........PMO-B| 00000230 4c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |L...............| 00000240 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00000400 ``` 我的模式在里面 - `SINGLE-ZFS-CORRUPTION-DEMO-BLOCK` - 所以地址算法是正确的。但它被垃圾包围,文本在一行后中断,块只有 `0x400` 字节,而不是我原本想象的干净的 128 KiB。DVA 是正确的,扇区数学是正确的,`dd` 命令也是正确的。位置正确,但心理模型错误。 ## 压缩陷阱 块数一直在试图告诉我这一点。一个 1 MiB 的文件不会仅适合 21 个扇区,除非有东西在压缩它,而 `zdb` 一直明摆着说:`20000L/400P` 意味着 128 KiB 逻辑,1 KiB 物理。块被压缩了。磁盘上的是**压缩后**的图像,所以当然它不像我重复的字符串。我忘了关闭压缩,然后我责怪 ZFS 玩弄我的数据。所以,在进行任何偏移量算术之前先关闭压缩 - 注意更改属性不会重写现有块,因此之后必须重新写入文件: ``` # zfs set compression=off zblog1 # zfs get -H -o name,property,value compression zblog1 zblog1 compression off ``` 重新创建文件: ``` $ rm single-mnt/file.bin $ yes 'SINGLE-ZFS-CORRUPTION-DEMO-BLOCK' | head -c 1M > single-mnt/file.bin $ sync ``` `zdb` 的路径形式在这里很方便,因为它为我们报告新的对象编号: ``` # zdb -O zblog1 file.bin -vvvv obj=3 dataset=zblog1 path=/file.bin type=19 bonustype=44 Object lvl iblk dblk dsize dnsize lsize %full type 3 2 128K 128K 1.00M 512 1M 100.00 ZFS plain file 176 bonus System attributes dnode flags: USED_BYTES USERUSED_ACCOUNTED USEROBJUSED_ACCOUNTED dnode maxblkid: 7 uid 0 gid 0 atime Thu Jun 4 23:47:09 2026 mtime Thu Jun 4 23:47:09 2026 ctime Thu Jun 4 23:47:09 2026 crtime Thu Jun 4 23:47:09 2026 gen 22 mode 100664 size 1048576 parent 34 links 1 pflags 840800000004 projid 0 Indirect blocks: 0 L1 0:469e00:400 20000L/400P F=8 B=22/22 cksum=... 0 L0 0:369e00:20000 20000L/20000P F=1 B=22/22 cksum=... 20000 L0 0:389e00:20000 20000L/20000P F=1 B=22/22 cksum=... 40000 L0 0:3a9e00:20000 20000L/20000P F=1 B=22/22 cksum=... ``` 现在看物理大小:`20000L/20000P`。逻辑等于物理,没有压缩,第一个数据块位于 DVA `0:369e00:20000`。和以前一样算: ``` 0x400000 + 0x369e00 = 0x769e00 0x769e00 / 512 = 15183 ``` 再次读取后端文件: ``` $ dd if=single.img bs=512 skip=15183 count=1 status=none | hexdump -C | head 00000000 53 49 4e 47 4c 45 2d 5a 46 53 2d 43 4f 52 52 55 |SINGLE-ZFS-CORRU| 00000010 50 54 49 4f 4e 2d 44 45 4d 4f 2d 42 4c 4f 43 4b |PTION-DEMO-BLOCK| 00000020 0a 53 49 4e 47 4c 45 2d 5a 46 53 2d 43 4f 52 52 |.SINGLE-ZFS-CORR| 00000030 55 50 54 49 4f 4e 2d 44 45 4d 4f 2d 42 4c 4f 43 |UPTION-DEMO-BLOC| 00000040 4b 0a 53 49 4e 47 4c 45 2d 5a 46 53 2d 43 4f 52 |K.SINGLE-ZFS-COR| 00000050 52 55 50 54 49 4f 4e 2d 44 45 4d 4f 2d 42 4c 4f |RUPTION-DEMO-BLO| 00000060 43 4b 0a 53 49 4e 47 4c 45 2d 5a 46 53 2d 43 4f |CK.SINGLE-ZFS-CO| 00000070 52 52 55 50 54 49 4f 4e 2d 44 45 4d 4f 2d 42 4c |RRUPTION-DEMO-BL| 00000080 4f 43 4b 0a 53 49 4e 47 4c 45 2d 5a 46 53 2d 43 |OCK.SINGLE-ZFS-C| 00000090 4f 52 52 55 50 54 49 4f 4e 2d 44 45 4d 4f 2d 42 |ORRUPTION-DEMO-B| ``` 这是一个漫长的旅程: - 文件路径到对象 - 对象到块指针 - 块指针到 DVA - DVA 到假装是磁盘的文件中的扇区 `zdb` 也可以为你读取块并节省手动转换,如果你更信任它而不是 `dd`: ``` # zdb -R zblog1 0:369e00:20000:r ``` 后缀标志决定了 `zdb` 如何处理块 - `r` 原始转储,`d` 解压缩,`c` 校验和,`i` 将其作为间接块跟踪,等等。这是更快的路径,但为了学习布局,我仍然更喜欢 `dd` 步骤:它迫使你知道你站在哪个地址空间中。 ## 实际的损坏 以上都是侦察。损坏本身只有一行。首先导出池可以避免我们与活动 ARC 冲突;之后从目录导入正是我们之前学习 `-d` 标志的原因: ``` # zpool export zblog1 $ printf 'BAD-SINGLE\n' | dd of=single.img bs=512 seek=15183 count=1 conv=notrunc status=none # zpool import -d . zblog1 # zpool scrub zblog1 ``` `conv=notrunc` 是重要的标志 - 没有它,`dd` 会愉快地截断整个镜像文件,而不是原地覆盖一个扇区。ZFS 存储的校验和不再与磁盘上的字节匹配,scrub 立即发现了它: ``` # zpool status -v zblog1 pool: zblog1 state: ONLINE status: One or more devices has experienced an error resulting in data corruption. Applications may be affected. action: Restore the file in question if possible. Otherwise restore the entire pool from backup. see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-8A scan: scrub repaired 0B in 00:00:00 with 1 errors on Thu Jun 4 23:48:37 2026 config: NAME STATE READ WRITE CKSUM zblog1 ONLINE 0 0 0 /tmp/zfs-blog-flow/single.img ONLINE 0 0 2 errors: Permanent errors have been detected in the following files: /tmp/zfs-blog-flow/single-mnt/file.bin ``` 这是不可恢复的情况。ZFS 知道设备存储了损坏的数据,但只有一个副本,它无法重建数据。它可以检测,但无法治愈。要观察愈合过程,我们需要奇偶校验。 ## 在 RAIDZ2 上做同样的事情 现在在 RAIDZ2 池上重复整个练习。这次我在写入文件之前禁用压缩,因为我们已经被咬过一次了: ``` # zfs set compression=off zblogR $ yes 'RAIDZ2-ZFS-CORRUPTION-DEMO-BLOCK' | head -c 1M > raidz-mnt/file.bin $ sync $ stat -c 'path=%n inode=%i size=%s blocks512=%b' raidz-mnt/file.bin path=raidz-mnt/file.bin inode=2 size=1048576 blocks512=2051 ``` `zdb` 显示第一个 level-0 数据块: ``` # zdb -O zblogR file.bin -vvvv | sed -n '1,40p' obj=2 dataset=zblogR path=/file.bin type=19 bonustype=44 Object lvl iblk dblk dsize dnsize lsize %full type 2 2 128K 128K 1.00M 512 1M 100.00 ZFS plain file 176 bonus System attributes dnode maxblkid: 7 size 1048576 parent 34 links 1 Indirect blocks: 0 L1 0:23c400:c00 20000L/400P F=8 B=31/31 cksum=... 0 L0 0:3b400:40200 20000L/20000P F=1 B=31/31 cksum=... 20000 L0 0:7b600:40200 20000L/20000P F=1 B=31/31 cksum=... 40000 L0 0:bb800:40200 20000L/20000P F=1 B=31/31 cksum=... ``` 这里简单的 `0x400000 + 偏移量` 技巧是不够的。那个 DVA 偏移量 `0x3b400` 是进入**顶级** raidz vdev 的偏移量,而不是进入任何一个子文件的字节偏移量。raidz 将每个块切成扇区,并将它们作为数据和奇偶校验列的条带分布在子文件上,因此要在真实磁盘上找到一个字节,我们必须手动重放这个映射。 映射存在于 `vdev_raidz_map_alloc()` (https://github.com/openzfs/zfs/blob/33839166fd39b1632ab7f9ce7e366598766064b3/module/zfs/vdev_raidz.c#L611) 中的 `module/zfs/vdev_raidz.c`,它只有几个整数操作。对于我们的池,它的输入是: ``` ashift = 9 # log2(512); 一个扇区是512字节 dcols = 4 # raidz vdev 中的子文件数量 (r1..r4) nparity = 2 # RAIDZ2 -> 每个条带2个奇偶校验列 dva_off = 0x3b400 # DVA 偏移量,以字节为单位,进入 raidz vdev psize = 0x20000 # 物理块大小,来自 20000L/20000P ``` raidz 内部的所有内容都以扇区计数,所以第一步是将这些字节计数转换为扇区计数。`b` 是块在 raidz vdev 内的起始扇区,`s` 是它的长度(扇区数): ``` b = dva_off >> ashift = 0x3b400 >> 9 = 474 # raidz 中的起始扇区 s = psize >> ashift = 0x20000 >> 9 = 256 # 块长度,以扇区为单位 ``` 一个条带不一定从第一个子文件开始。`f` 是它开始的列,`o` 是每个子文件读取的字节偏移量;两者都来自将起始扇区除以子文件数量: ``` f = b % dcols = 474 % 4 = 2 # 第一列,从0开始 o = (b / dcols) << ashift = 118 << 9 = 0xec00 # 每个子文件的基础偏移量 ``` (`b`、`s`、`f` 和 `o` 是源代码中的实际变量名,所以你可以跟着代码读。)子文件按创建顺序从零编号 - r1=0,r2=1,r3=2,r4=3 - 所以 `f = 2` 意味着这个条带从 `r3.img` 开始。在四列中,前 `nparity` 列是奇偶校验,其余是数据,所以条带依次为 P、Q、数据、数据。

相似文章

如何破坏SQLite数据库文件

Hacker News Top

这篇来自SQLite官方文档的文章解释了SQLite数据库可能被损坏的各种方式,例如恶意进程覆盖文件、文件描述符的误用以及不安全的备份程序,同时提供了相应的缓解策略。