故意破坏ZFS文件
摘要
一篇技术指南,介绍如何使用'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、数据、数据。
相似文章
如果ZFS清理导致性能下降,你的存储池设计有问题
本文解释了ZFS清理性能问题实际上是存储池设计余量不足的症状,而非清理本身的问题,并建议通过合理规划大小来避免性能下降。
恢复损坏的ZIP文件
本文介绍了从损坏的ZIP文件中恢复数据的方法或工具。
如何破坏SQLite数据库文件
这篇来自SQLite官方文档的文章解释了SQLite数据库可能被损坏的各种方式,例如恶意进程覆盖文件、文件描述符的误用以及不安全的备份程序,同时提供了相应的缓解策略。
Show HN: Uberblok – 一个可交互的 ZFS 内部机制博物馆
Show HN:Uberblok 是一个可交互的 ZFS 内部机制“博物馆”,通过动画和与固定在 OpenZFS 2.4.4 源码特定行的代码摘录,讲解 uberblock、事务组(transaction group)等概念。
@x86byte: 我在 adm-zip 中发现了一个本地提权漏洞(LPE)。构造的 ZIP 文件外部属性会携带 SUID/SGID 位传入 fs.chmodSyn…
adm-zip 中存在一个本地提权漏洞,允许构造的 ZIP 文件设置 SUID/SGID 位,导致以 root 权限解压时本地用户可获取 root 访问权限。该问题已在 0.6.1 版本中修补。