实现 dm-verity

Android 4.4 及更高版本支持通过可选的 device-mapper-verity (dm-verity) 内核功能进行启动时验证,以便对块存储设备进行透明的完整性检查。dm-verity 有助于阻止可以持续保有 root 权限并入侵设备的持续性 Rootkit。验证启动功能有助于 Android 用户在启动设备时确定设备状态与上次使用时是否相同。

具有 root 权限的潜在有害应用 (PHA) 可以躲开检测程序的检测,并以其他方式掩蔽自己。可以获取 root 权限的软件就能够做到这一点,因为它通常比检测程序的权限更高,从而能够“欺骗”检测程序。

通过 dm-verity 功能,您可以查看块存储设备(文件系统的底部存储层),并确定它是否与预期配置一致。该功能是利用加密哈希树做到这一点的。对于每个块(通常为 4k),都有一个 SHA256 哈希。

由于哈希值存储在页面树中,因此顶级“根”哈希必须可信,才能验证树的其余部分。能够修改任何块相当于能够破坏加密哈希。下图描绘了此结构。

dm-verity-hash-table

图 1. dm-verity 哈希表

启动分区中包含一个公钥,该公钥必须已由设备制造商在外部进行验证。该密钥用于验证相应哈希的签名,并用于确认设备的系统分区是否受到保护且未被更改。

操作

dm-verity 保护机制位于内核中。因此,如果获取 Root 权限的软件在内核启动之前入侵系统,它将会一直拥有该权限。为了降低这种风险,大多数制造商都会使用烧录到设备的密钥来验证内核。该密钥在设备出厂后即无法更改。

制造商会使用该密钥验证第一级引导加载程序中的签名,而该引导加载程序会验证下一级别的应用引导加载程序,最后则是验证内核中的签名。希望利用启动时验证功能的每个制造商都应该有验证内核完整性的方法。内核经过验证后,可以在块存储设备装载时由内核对其进行检查和验证。

验证块存储设备的一种方法是直接对其内容进行哈希处理,然后将其与存储的值进行比较。不过,尝试验证整个块存储设备可能会需要较长的时间,并且会消耗设备的大量电量。设备将需要很长时间来启动,从而在可供使用之前便消耗了大量电量。

而 dm-verity 只有在各个块被访问时才会对其进行单独验证。将块读入内存时,会以并行方式对其进行哈希处理。然后,会从第一级开始逐级验证整个哈希树的哈希。由于读取块是一项耗时又耗电的操作,因此这种块级验证带来的延时相对而言就有些微不足道了。

如果验证失败,设备会生成 I/O 错误,指明无法读取相应块。系统会将文件系统视为已损坏,这是一种预期的行为。

应用可以选择在没有结果数据的情况下继续运行,例如,当这些结果并不是应用执行主要功能所必需的数据时。不过,如果应用在没有这些数据的情况下无法继续运行,则会失败。

前向纠错

Android 7.0 及更高版本通过前向纠错 (FEC) 功能提高了 dm-verity 的稳健性。AOSP 实现首先使用常用的 Reed-Solomon 纠错码,并应用一种称为交错的技术来减少空间开销并增加可以恢复的损坏块的数量。有关 FEC 的更多详情,请参阅严格强制执行的启动时验证与纠错

实现

摘要

  1. 生成 EXT4 系统映像。
  2. 为该映像生成哈希树
  3. 为该哈希树构建 dm-verity 表
  4. 为该 dm-verity 表签名以生成表签名。
  5. 将表签名和 dm-verity 表绑定到 Verity 元数据。
  6. 将系统映像、Verity 元数据和哈希树连接起来。

如需关于哈希树和 dm-verity 表的详细说明,请参阅 Chromium 项目 - 启动时验证

生成哈希树

如简介中所述,哈希树是 dm-verity 不可或缺的一部分。cryptsetup 工具将为您生成哈希树。或者,也可以使用下面定义的兼容方式:

<your block device name> <your block device name> <block size> <block size> <image size in blocks> <image size in blocks + 8> <root hash> <salt>

为了形成哈希,该工具会将系统映像在第 0 层拆分成 4k 大小的块,并为每个块分配一个 SHA256 哈希。然后,通过仅将这些 SHA256 哈希组合成 4k 大小的块来形成第 1 层,从而产生一个小得多的映像。接下来再使用第 1 层的 SHA256 哈希以相同的方式形成第 2 层。

直到前一层的 SHA256 哈希可以放到一个块中,该过程就完成了。获得该块的 SHA256 哈希后,就相当于获得了树的根哈希。

哈希树的大小(以及相应的磁盘空间使用量)会因已验证分区的大小而异。在实际中,哈希树一般都比较小,通常不到 30 MB。

如果某个层中的某个块无法由前一层的哈希正好填满,您应在其中填充 0 来获得所需的 4k 大小。这样一来,您就知道哈希树没有被移除,而是填入了空白数据。

为了生成哈希树,需要将第 2 层哈希组合到第 1 层哈希的上方,将第 3 层哈希组合到第 2 层哈希的上方,依次类推。然后将所有这些数据写入到磁盘中。请注意,这种方式不会引用根哈希的第 0 层。

总而言之,构建哈希树的一般算法如下:

  1. 选择一个随机盐(十六进制编码)。
  2. 将系统映像拆分成 4k 大小的块。
  3. 获取每个块的加盐 SHA256 哈希。
  4. 组合这些哈希以形成层。
  5. 在层中填充 0,直至达到 4k 块的边界。
  6. 将层组合到哈希树中。
  7. 重复第 2-6 步(使用前一层作为下一层的来源),直到最后只有一个哈希。

该过程的结果是一个哈希,也就是根哈希。在构建 dm-verity 映射表时会用到该哈希和您选择的盐。

构建 dm-verity 映射表

构建 dm-verity 映射表,该映射表会标明内核的块存储设备(或目标)以及哈希树的位置(是同一个值)。在生成 fstab 和设备启动时会用到此映射。该映射表还会标明块的大小和 hash_start,即哈希树的起始位置(具体来说,就是哈希树在映像开头处的块编号)。

如需关于 Verity 目标映射表字段的详细说明,请参阅 cryptsetup

为 dm-verity 表签名

为 dm-verity 表签名以生成表签名。在验证分区时,会首先验证表签名。该验证是对照位于启动映像上某个固定位置的密钥来完成的。密钥通常包含在制造商的构建系统中,以便自动添加到设备上的固定位置。

如需使用这种签名和密钥的组合来验证分区,请执行以下操作:

  1. 将一个格式与 libmincrypt 兼容的 RSA-2048 密钥添加到 /boot 分区的 /verity_key 中。确定用于验证哈希树的密钥所在的位置。
  2. 在相关条目的 fstab 中,将 verify 添加到 fs_mgr 标记。

将表签名绑定到元数据

将表签名和 dm-verity 表绑定到 Verity 元数据。为整个元数据块添加版本号,以便它可以进行扩展,例如添加第二种签名或更改某些顺序。

一个魔数(作为一个健全性检查项目)会与每组表元数据相关联,以协助标识表。由于长度包含在 EXT4 系统映像标头中,因此这为您提供了一种在不知道数据本身内容的情况下搜索元数据的方式。

这可确保您未选择验证未经验证的分区。如果是这样,缺少此魔数将会导致验证流程中断。该数字类似于:
0xb001b001

十六进制的字节值为:

  • 第一字节 = b0
  • 第二字节 = 01
  • 第三字节 = b0
  • 第四字节 = 01

下图展示了 Verity 元数据的细分:

<magic number>|<version>|<signature>|<table length>|<table>|<padding>
\-------------------------------------------------------------------/
\----------------------------------------------------------/   |
                            |                                  |
                            |                                 32K
                       block content

下表介绍了这些元数据字段。

表 1. Verity 元数据字段

字段 用途 大小
魔数 供 fs_mgr 用作一个健全性检查项目 4 个字节 0xb001b001
版本 用于为元数据块添加版本号 4 个字节 目前为 0
签名 PKCS1.5 填充形式的表签名 256 个字节
表长度 dm-verity 表的长度(以字节数计) 4 个字节
上文介绍的 dm-verity 表 字节数与表长度相同
填充 此结构会通过填充 0 达到 32k 长度 0

优化 dm-verity

为了充分发挥 dm-verity 的最佳性能,您应该:

  • 在内核中开启 NEON SHA-2(如果是 ARMv7)或 SHA-2 扩展(如果是 ARMv8)。
  • 使用不同的预读设置和 prefetch_cluster 设置进行实验,找出适合您设备的最佳配置。