全盘加密应对的威胁模型、分层设计与实践
最近在整理一些以前写的东西,这里把一些关于全盘加密的想法记录一下。
在构建存储系统时,数据加密往往是一个常见需求,但「加密」本身并不是目的。脱离了具体的威胁模型去讨论加密算法或配置参数,往往容易陷入过度设计,或者在真正需要防御的地方留下盲区。结合FreeBSD下的geli(8) 与ZFS存储栈,我在这里重点梳理一下离线威胁边界、反容量分析的抹盘策略、机械硬盘与闪存缓存的差异化处理、块设备加密与文件系统原生加密的架构取舍,以及数据完整性校验的去重考量。
威胁模型:全盘加密在防止什么?
全盘加密的核心目标是保护静态数据。更准确地说,它针对的是离线攻击者。
通俗地说,离线攻击者是那些拿到了包含数据的硬件,但未必获得了软件系统访问权限的人。比较典型的例子有:
- 硬盘因故障需要走RMA流程返厂维修;
- 存储介质退役、报废或二手流转;
- 设备在关机断电状态下失窃。
攻击者在这些情况下可能能获得底层物理存储介质,例如硬盘(完整的硬盘,或是盘片、闪存颗粒等),但并没有得到系统运行时的内存。只要加密实现得当,一块离线硬盘在没有密钥的攻击者眼中,应当与一盘毫无意义的随机噪声没有区别。
全盘加密的防御范围并不包括「在线攻击」,所谓在线攻击,是指系统完成了引导、管理员挂载了解密后的卷,主密钥常驻在操作系统的内核内存中。此时:
- 对上层应用而言,存储数据是明文。通过操作系统鉴权的读写操作拿到的都是明文。如果运行中的系统本身被攻破,全盘加密不会提供任何额外的保护。此时起作用的是操作系统自身的权限与隔离机制(例如UID/GID、Jail、MAC等)。
- 物理层面的「半在线」:如果机器处于挂起或锁屏但通电的状态,具备物理接触条件的攻击者依然可能通过冷启动攻击提取内存残留数据,或利用未受IOMMU保护的总线接口发起DMA攻击,直接从内核内存中提取主密钥。我的考虑主要是在物理层面有保障的机房等场景,若已经有人提了液氮来了,我等草民明智的选择很可能还是直接认怂。
毕竟,对吧。
扇区加密原理与擦除策略的权衡
在FreeBSD中,块设备层的全盘加密通常由GEOM框架下的geli(8)提供,底层默认采用行业标准的AES-XTS模式(IEEE Std 1619,NIST亦在SP 800-38E中将其认可为存储设备的机密性模式)。
AES-XTS的数学构造
磁盘属于随机读写块设备,没有额外空间去存储每个数据块的随机初始向量IV。如果使用固定IV,相同的明文会生成相同的密文,从而暴露数据特征。
XTS(XEX-based tweaked-codebook mode with ciphertext stealing)通过引入与物理位置绑定的「调整值(Tweak)」解决了这个问题。它使用两个独立的密钥(数据加密密钥)和(Tweak加密密钥)。对于逻辑扇区号(LBA)为、扇区内偏移序号为的明文块,其加密过程为:
其中:
- 表示使用密钥的AES块加密函数;
- 是扇区号(LBA),是该扇区内部第个块的索引();
- 是有限域上的本原元,表示该有限域上的模乘运算,表示按位异或。
具体到GELI的实现,有两处与教科书写法略有不同:一是它直接把扇区在设备上的字节偏移量当作
(见g_eli_crypto_ivgen()),而不是LBA,两者只差一个常数倍,效果相同;二是它并不在整块盘上使用同一组密钥,而是每个扇区(G_ELI_KEY_SHIFT)由主密钥派生一组新的数据密钥。
底层设备的初始化
在建立全盘加密容器之前,我的做法是先在整块裸盘上均匀地填满一层密码学安全的伪随机数,然后再在其上执行geli init建立加密容器,而不是在容器中采用「抹零(写入全0)」的做法。
这样设计主要基于两个层面的考量:
其一,是消除已用容量的侧信道泄露。
如果在一块出厂默认全0(或预先抹零)的硬盘上直接建立加密容器并创建文件系统,那么只有被文件系统实际写入过的扇区才会表现为随机密文,而未分配的扇区依然保持全0。离线攻击者虽然无法解密数据,但只需扫描一遍物理扇区,就能精确统计出这块盘目前存了多少数据,甚至分析出分区与文件系统的空间分配特征。预先用随机数填满整盘后,未使用的空闲扇区与已写入的加密扇区在统计特征上完全一致,攻击者无法区分哪里是有效密文、哪里是未分配空间。
其二,是避免构造大面积的「已知明文(Known-Plaintext)」。
社区里有一种常见的替代做法:在geli attach之后,向映射出的加密设备(例如/dev/da0.eli)写入全0(即令),利用AES-XTS的特性在底层生成伪随机数据。从上面的公式可以看出,当时,底层落盘的数据实际上变成了:
虽然AES-XTS目前并没有已知的、实用化的已知明文攻击漏洞,但这种做法本质上等于用为数不多的几组数据密钥(每组覆盖个扇区)和由公开的扇区位置决定的Tweak ,对同一份已知明文(全0)加密了数十亿次并铺满整块硬盘。对于攻击者而言,所有未被真实数据覆盖的空闲区域都构成了明确的已知明文样本集。
出于纵深防御的原则,我对「把同一份明文用关联参数加密后大面积落盘」的做法始终存有顾虑。直接在初始化前使用操作系统的随机数发生器(例如FreeBSD内核基于Fortuna算法的/dev/random,见random(4))对底层裸盘进行填充,写入的数据流与GELI的密钥体系完全正交且相互独立,从而避免了这种关联关系。
机械硬盘与闪存设备的差异化策略
全盘填充随机数带来了一个直接的衍生结果:闪存设备的TRIM(SCSI UNMAP / NVMe Deallocate)机制在这里失去了意义。
因为TRIM的作用是通知底层存储介质「某些逻辑扇区已不再使用」。一旦向SSD发送TRIM,控制器往往会将这些回收的块映射为确定性的全0(RZAT),这会直接破坏我们用来掩盖数据量的随机背景。FreeBSD的geli(8)专门提供了-T参数,用来禁止透传BIO_DELETE(即TRIM/UNMAP),以防攻击者据此获知实际使用容量。
不过,这样一来,在SSD的主控看来,整盘的数据全都是有效的,这可能会导致性能下降。
实践上,我目前的策略是:
- 数据盘(机械硬盘):采用「先全盘抹一层随机数,再初始化容器」的策略,来隐藏容量信息。
- 缓存盘(闪存盘):采用「先全盘TRIM,再初始化容器」的策略。由于这些盘是缓存,会频繁写入,并且大部分时间是满的状态,因此关闭TRIM透传和提前写入一遍随机数价值不高。
在哪一层做加密:块设备加密(GELI)vs. 文件系统原生加密(ZFS)
自ZFS on Linux 0.8(2019年)引入原生加密(Native Encryption),FreeBSD 13.0随OpenZFS 2.0获得这一功能以来,很多人在构建存储池时会纠结是直接开启ZFS的encryption=on,还是在底层使用GELI。
我的观点是:除了极个别需要向不可信远端执行zfs send的场景外,在绝大多数本地存储场景下,ZFS原生加密并不是一个好主意,使用ZFS over GELI是更合理的选择。
未全盘加密导致的元数据与容量泄露
ZFS内建的原生加密以Dataset为单位管理密钥(DSL层),实际的加解密则发生在ZIO流水线中,并且只作用于用户数据相关的对象,存储池自身的元数据不在其列。为了保证系统在未加载密钥时依然能够执行zpool import、zpool status和zpool scrub,ZFS在设计上必须将大量的池级别和数据集级别的元数据保持为明文。
这意味着,使用ZFS原生加密时,它并没有对整个盘做加密。离线攻击者拿到硬盘后,不仅能精确知道盘上到底存了多少加密数据,还能直接以明文读取(这一点,在zfs-load-key(8)的Encryption一节对此有明确说明):
- 存储池的拓扑结构与历史操作记录(
zpool history); - 所有的dataset名称与dataset之间的层级关系;
- 所有快照的名称,以及包括创建时间在内的各项属性;
- 每个dataset精确的字节占用量与压缩率特征;
- 每个文件的大小与空洞(hole)分布,以及去重表(DDT)。
当然,文件内容、文件名(目录项)、权限与ACL等是加密的。
在实际使用中,dataset和快照的名字往往本身就包含敏感上下文。而GELI工作在最底层的块设备层,未解锁时整块盘呈现为无结构的随机数据,离线攻击者连上面跑的是不是ZFS都很难判断。
混合状态带来的人为配置泄露风险
ZFS原生加密是以dataset为粒度配置的,一个存储池中,允许同时存在加密和未加密的dataset(根dataset通常不加密)。
这种设计打破了物理介质与安全边界的一致性。在日常维护、目录迁移或服务配置过程中,很容易因为疏忽(例如写错了挂载点路径,或者将临时目录指到了父级未加密的dataset),在无意中把敏感数据保存到了同一个存储池中未加密的区域中,造成静默的数据泄露,并且这些泄漏可能很难检测并且持续很久,因为ZFS采取的是写时复制的设计。而在ZFS over GELI的架构下,安全边界就是物理硬盘本身:只要数据落到了这块盘上,就必然经过了加密,从根源上杜绝了「同盘不同命」的人为配置失误。
系统正交性与实现复杂度
将加密强行塞入写时复制(Copy-on-Write)文件系统内部,会使加密状态机与快照、克隆、deduplication等复杂逻辑产生深度耦合。OpenZFS原生加密在过去几年中曾多次在send/recv和快照边界上曝出棘手的Bug,例如openzfs/zfs#12014:加密dataset的快照在升级到2.0之后出现无法解释的永久性错误,相关问题拖了数年,最终才在c5228ba
(「Fix 2 bugs in non-raw send with encryption」)中得到修复。遵循UNIX的正交设计原则,让geli只负责纯粹的块级透明加解密,让ZFS专心管理存储池与文件系统,两层解耦,各自的代码路径都更加简单可靠。
数据完整性校验
如前所述,AES-XTS模式只提供机密性,不提供完整性校验(非AEAD模式)。如果离线攻击者篡改了磁盘上的密文,AES-XTS本身无法察觉,只会将其解密为一串随机损坏的明文。
为了解决这个问题,加密容器本身通常提供了可选的数据完整性校验支持。例如,geli(8)提供了一个-a参数来启用HMAC/SHA256。我之前写过一篇关于geli(8)的HMAC设计细节。但在实际部署中,当上层文件系统是ZFS时,我从来不使用GELI的这个功能。
块设备层校验
块设备层看到的是一维的线性扇区阵列,它不知道哪些扇区是空闲的,也没有块指针的概念。当启用GELI的HMAC功能时,GELI必须为每一个逻辑扇区额外存储一段MAC校验值。
这样做会产生两方面的开销:
- 可用空间缩水:GELI采取的设计是将HMAC数据保存在扇区内部,对于4 KiB逻辑扇区,需要占用9个512字节的物理扇区来存放,这会直接消耗掉全盘约11%,或的物理容量(这也是geli(8)手册中「89%可用」的来历)。如果底层是原生4 KiB扇区(4Kn)的硬盘,每个物理扇区扣掉32字节HMAC后只剩4064字节,一个4 KiB逻辑扇区就得占用两个物理扇区,开销直接变成50%。
- 破坏扇区对齐与读写放大:由于每个扇区附加了额外的校验字节,原本对齐4 KiB物理扇区的I/O请求会被打散并跨越物理扇区边界。这使得写入操作退化为「读取-修改-写入(Read-Modify-Write)」循环,导致吞吐量大幅下降。
ZFS内建的校验
如果上层运行的是不具备完整数据校验能力的传统文件系统(例如UFS),上述校验可能是需要的。但ZFS本身已经构建了端到端数据完整性体系,它采用基于写时复制的Merkle Tree结构:每一个数据块的校验和(我通常用OpenZFS 2.2起支持的BLAKE3)并不存放在数据块原地,而是存放在指向它的父节点块指针中,并层层向上校验直至Uberblock。
在ZFS over GELI (AES-XTS)的组合下,整体的数据流向为:
flowchart LR
Disk["物理磁盘"] -->|密文扇区| GELI["GELI层
(AES-XTS解密)"]
GELI -->|明文数据块| ZFS["ZFS层
(校验和验证)"]
ZFS -->|校验通过数据| App["应用程序"]由于整个ZFS树(包括数据块与存放校验和的父节点块指针)都位于GELI加密容器内部,则离线攻击者在没有密钥的情况下,如果强行篡改了底层某个物理扇区的密文为,经过GELI解密后,输出的明文块会变成毫无规律的伪随机垃圾数据。当ZFS读取该块并计算校验和时,必然无法与父节点块指针中解密出的期望校验和匹配。
ZFS会立刻捕获这次校验失败(ECKSUM),拒绝向应用层返回损坏的数据;在配置了冗余(例如RAID-Z、dRAID或镜像)的存储池中,ZFS还会通过其他额外的冗余(镜像副本或RAID-Z校验数据,甚至同一盘上由于copies属性而多写的冗余块)重建出正确的数据块,在内存中完成自愈修复,再经由GELI重新加密写回。
类似地,攻击者也无法把某个扇区换成它自己早先保存的旧密文,从而在用户不察觉的情况下回滚个别数据块:旧数据块本身能正常解密,但它的校验和与父节点块指针中记录的不一致。不过,这一保护是有边界的:如果攻击者保存了所有磁盘在某一时刻的完整镜像,再整体写回,ZFS看到的是一个自洽的旧状态,并不会报错。要发现这种整盘回滚,只能依靠系统之外的信息,例如另行记录最近的事务组号(txg)或快照列表。当然,从现实考虑,有这种能力的攻击者可能已经攻陷了系统,这种情况请自求多福。
FreeBSD下的操作流程备忘
结合上述原则,在FreeBSD下初始化存储设备的核心流程如下:
机械硬盘(主力数据盘)
先执行全盘随机数填充,再初始化无HMAC的GELI容器:
| |
闪存设备(ZFS缓存盘)
初始化前仅执行一次全盘TRIM重置FTL状态,直接建立GELI容器,不预先填充随机数:
| |
创建或导入ZFS存储池
直接将.eli设备交给ZFS管理,由ZFS负责完整性校验,不开启ZFS原生加密:
| |
在线阅读本文并参与讨论 delphij's Chaos https://blog.delphij.net/posts/2026/10/fde-notes/