Linux内核中的dm-verity完整性验证深度实战:从哈希树构建到链式可信计算

一、为什么我们需要块设备完整性验证

在容器化部署和边缘计算场景中,一个棘手的问题是:如何确保磁盘上的数据没有被篡改?传统的文件系统(ext4, xfs)只保证数据格式正确,无法检测以下攻击场景:

  • 物理磁盘被替换或写入恶意固件
  • 宿主机 root 权限被攻破后静默篡改容器镜像层
  • 攻击者修改只读层的特定字节以改变程序行为

dm-verity 正是为了解决这类问题而生。它将整个块设备的每一个数据块都纳入到一棵 Merkle 哈希树的保护下,任何单比特的修改都会导致根哈希验证失败,立即触发 I/O 错误。这种机制是 Google Android Verified Boot、Chromebook 安全模型以及容器镜像签名链的核心支柱。

本文将从内核实现机制、哈希树数据结构、用户空间工具链四个层面剖析 dm-verity,并提供完整的 C 语言实战代码。

二、哈希树数据结构:dm-verity 的磁盘布局

dm-verity 将块设备严格地划分为两部分:数据区(data device) 和 哈希树(hash device)。数据区存储原始内容,哈希区存储对应的哈希元数据。

磁盘布局自上而下:


┌──────────────────────────────────────┐
│         Superblock (4096 bytes)       │  ← 根哈希 + 算法 + 种子
├──────────────────────────────────────┤
│     Hash Level 2 (Top hashes)         │  ← 中间层哈希
├──────────────────────────────────────┤
│     Hash Level 1 (Bottom hashes)      │  ← 叶子层哈希(最末级)
└──────────────────────────────────────┘
         ↑ Hash Device
┌──────────────────────────────────────┐
│         Data Blocks (4KB each)        │  ← 原始数据区
└──────────────────────────────────────┘
         ↑ Data Device

每个数据块大小为 4096 字节(典型配置),使用 SHA-256 计算其哈希值。然后这些哈希值本身也被分组成 4KB 块,递归地计算上一层哈希,直到最终得到一个唯一的根哈希。

假设块设备大小为 1GB,共有 262144 个 4KB 数据块:

  • Level 1:262144 个哈希值,占用 262144 × 32 = 8MB,即 2048 个哈希块
  • Level 2:2048 个哈希值,占用 2048 × 32 = 64KB,即 16 个哈希树块
  • Level 3(根):16 个哈希值,占用 512 字节,归入 superblock

哈希树的层级高度计算公式为:levels = ceil(log_4096(data_blocks)) = ceil(log(data_blocks)/12),其中 4096/32 = 128 为每个哈希块中包含的哈希数。

三、内核验证机制:每次读操作的交易

当内核从数据区读取一个 4KB 块时,dm-verity 的处理流程如下:

  1. 从数据区读取原始块数据
  2. 对数据块计算 SHA-256 哈希,得到 leaf_hash
  3. 从哈希区读取对应叶子位置的数据块,验证 leaf_hash 是否匹配
  4. 若不匹配,立即触发 `EIO` 错误并标记设备为损坏状态
  5. 若匹配,逐级向上验证直到根哈希

关键代码路径位于 drivers/md/dm-verity.c(以 dm-verity target 类型实现)。核心验证函数 verity_work 在每次 I/O 完成后被调用:


// 内核验证伪代码(简化自 dm-verity-verify.c)
static int verify_block(struct dm_verity *v, struct dm_verity_io *io,
                        sector_t block)
{
    // 1. 计算数据块的 SHA-256
    sha256_hash(v, data_page, data_size, calculated_hash);
    
    // 2. 读取哈希树中对应的预期哈希值
    expected_hash = read_hash_block(v, io->block);
    
    // 3. 比较哈希值
    if (memcmp(calculated_hash, expected_hash, v->digest_size) != 0) {
        // 验证失败!
        DMERR("verification failed at block %llu", block);
        return -EIO;
    }
    
    // 4. 逐级向上验证
    for (level = 0; level < v->levels; level++) {
        parent_hash = calculate_parent_hash(v, level, ...);
        if (!verify_parent_hash(v, level + 1, parent_hash))
            return -EIO;
    }
    
    return 0; // 全部验证通过
}

值得注意的是,dm-verity 仅在读取时验证,写入操作会完全失败(除非在创建时一次性写入)。这种"写一次读多次"(WORM)模型是只读完整性保护的核心假设。

四、用户空间工具:veritysetup 的使用

cryptsetup 工具包中的 veritysetup 提供了创建和管理 verity 设备的接口。

创建哈希树


# 创建 100MB 的测试数据
dd if=/dev/urandom of=/tmp/data.img bs=1M count=100

# 创建哈希树设备
veritysetup format /tmp/data.img /tmp/hash.img
# 输出包含:Root Hash: aabbccdd1122...

通过 device-mapper 加载


# 获取 root hash(从 format 阶段的输出)
ROOT_HASH="aabbccdd11223344..."

# 创建 verity device-mapper target
echo "0 $(blockdev --getsize /tmp/data.img) verity 1 \
  /tmp/data.img /tmp/hash.img \
  4096 4096 262144 1 sha256 \
  $ROOT_HASH" | dmsetup create veritydev

# 现在 /dev/mapper/veritydev 是一个完整性验证的块设备
mkfs.ext4 /dev/mapper/veritydev
mount /dev/mapper/veritydev /mnt/verity

五、实际代码:手动构建哈希树

以下 C 代码展示了如何手动构建一个 verity 哈希树,模拟 veritysetup 的核心逻辑:


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/sha.h>
#include <stdint.h>

#define BLOCK_SIZE 4096
#define DIGEST_SIZE 32  // SHA-256
#define HASHES_PER_BLOCK (BLOCK_SIZE / DIGEST_SIZE)  // 128

struct verity_params {
    uint64_t data_blocks;
    uint8_t  root_hash[DIGEST_SIZE];
    uint8_t  salt[32];
    size_t   salt_size;
    uint32_t hash_block_size;
};

// 计算单个数据块的哈希
static void hash_block(const uint8_t *data, size_t len,
                       const uint8_t *salt, size_t salt_len,
                       uint8_t *out)
{
    SHA256_CTX ctx;
    SHA256_Init(&ctx);
    
    if (salt && salt_len > 0)
        SHA256_Update(&ctx, salt, salt_len);
    SHA256_Update(&ctx, data, len);
    SHA256_Final(out, &ctx);
}

// 递归构建哈希树
static int build_hash_tree(struct verity_params *params,
                           const uint8_t *hash_device,  // 哈希设备映射
                           uint64_t level_offset,       // 当前层在哈希设备中的起始块
                           uint64_t block_count)        // 当前层块数
{
    uint8_t parent_block[BLOCK_SIZE];
    uint8_t child_hash[DIGEST_SIZE];
    
    if (block_count == 1) {
        // 到达顶层,这是根哈希
        uint8_t root_block[BLOCK_SIZE];
        memcpy(root_block, hash_device + level_offset * BLOCK_SIZE, BLOCK_SIZE);
        memcpy(params->root_hash, root_block, DIGEST_SIZE);
        printf("Root hash computed: ");
        for (int i = 0; i < DIGEST_SIZE; i++)
            printf("%02x", params->root_hash[i]);
        printf("\n");
        return 0;
    }
    
    // 计算上一层的每个块的哈希
    uint64_t parent_blocks = (block_count + HASHES_PER_BLOCK - 1) / HASHES_PER_BLOCK;
    
    for (uint64_t i = 0; i < parent_blocks; i++) {
        memset(parent_block, 0, BLOCK_SIZE);
        
        for (uint64_t j = 0; j < HASHES_PER_BLOCK; j++) {
            uint64_t child_idx = i * HASHES_PER_BLOCK + j;
            if (child_idx >= block_count)
                break;
            
            // 读取当前层子块,计算其哈希
            const uint8_t *child_block_data = 
                hash_device + (level_offset + child_idx) * BLOCK_SIZE;
            SHA256(child_block_data, BLOCK_SIZE, child_hash);
            memcpy(parent_block + j * DIGEST_SIZE, child_hash, DIGEST_SIZE);
        }
        
        // 将父块写入哈希设备
        uint64_t parent_offset = level_offset - parent_blocks + i;
        // memcpy(hash_device + parent_offset * BLOCK_SIZE, parent_block, BLOCK_SIZE);
        printf("Level block %lu/%lu: ", i, parent_blocks);
        for (int k = 0; k < 8; k++)
            printf("%02x", parent_block[k]);
        printf("...\n");
    }
    
    // 递归构建上层
    return build_hash_tree(params, hash_device,
                          level_offset - parent_blocks, parent_blocks);
}

// 完整性校验函数
static int verify_data_block(const uint8_t *data_device,
                              const uint8_t *hash_device,
                              const struct verity_params *params,
                              uint64_t block_num)
{
    uint8_t calculated[DIGEST_SIZE];
    uint8_t expected[DIGEST_SIZE];
    uint64_t hash_block_num = block_num / HASHES_PER_BLOCK;
    uint32_t hash_offset = (block_num % HASHES_PER_BLOCK) * DIGEST_SIZE;
    
    // 1. 计算数据块的 SHA-256
    const uint8_t *data_block = data_device + block_num * BLOCK_SIZE;
    hash_block(data_block, BLOCK_SIZE, params->salt, params->salt_size, calculated);
    
    // 2. 从哈希树中读取预期哈希值
    const uint8_t *hash_block = hash_device + hash_block_num * BLOCK_SIZE;
    memcpy(expected, hash_block + hash_offset, DIGEST_SIZE);
    
    // 3. 比较
    if (memcmp(calculated, expected, DIGEST_SIZE) != 0) {
        fprintf(stderr, "Block %llu: VERIFICATION FAILED\n", block_num);
        return -1;
    }
    
    return 0;
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <data_image>\n", argv[0]);
        return 1;
    }
    
    // 1. 读取数据文件
    FILE *fp = fopen(argv[1], "rb");
    if (!fp) { perror("fopen"); return 1; }
    
    fseek(fp, 0, SEEK_END);
    long file_size = ftell(fp);
    fseek(fp, 0, SEEK_SET);
    
    uint8_t *data_device = aligned_alloc(BLOCK_SIZE, 
        (file_size + BLOCK_SIZE - 1) & ~(BLOCK_SIZE - 1));
    memset(data_device, 0, (file_size + BLOCK_SIZE - 1) & ~(BLOCK_SIZE - 1));
    fread(data_device, 1, file_size, fp);
    fclose(fp);
    
    uint64_t data_blocks = (file_size + BLOCK_SIZE - 1) / BLOCK_SIZE;
    printf("Data blocks: %lu\n", data_blocks);
    
    // 2. 计算哈希树各层的大小
    uint64_t hash_blocks = data_blocks;
    uint64_t total_hash_blocks = 0;
    uint64_t level_blocks = data_blocks;
    
    printf("Hash tree levels:\n");
    for (int level = 0; ; level++) {
        level_blocks = (level_blocks + HASHES_PER_BLOCK - 1) / HASHES_PER_BLOCK;
        total_hash_blocks += level_blocks;
        printf("  Level %d: %lu blocks\n", level, level_blocks);
        if (level_blocks == 1)
            break;
    }
    
    printf("Total hash blocks: %lu (%lu KB)\n", 
           total_hash_blocks, total_hash_blocks * BLOCK_SIZE / 1024);
    
    // 3. 分配哈希设备
    uint8_t *hash_device = calloc(total_hash_blocks, BLOCK_SIZE);
    
    // 4. 构建 Level 0(叶子哈希层)
    struct verity_params params = {0};
    params.data_blocks = data_blocks;
    params.hash_block_size = BLOCK_SIZE;
    params.salt_size = 0; // 简化示例,无 salt
    
    for (uint64_t i = 0; i < data_blocks; i++) {
        hash_block(data_device + i * BLOCK_SIZE, BLOCK_SIZE,
                   NULL, 0,
                   hash_device + i * DIGEST_SIZE);
    }
    
    // 5. 递归构建上层哈希树
    build_hash_tree(¶ms, hash_device, data_blocks - 1, data_blocks);
    
    // 6. 验证所有数据块
    printf("\nVerifying all blocks...\n");
    int verified = 0;
    for (uint64_t i = 0; i < data_blocks && i < 1000; i++) {
        if (verify_data_block(data_device, hash_device, ¶ms, i) == 0)
            verified++;
    }
    printf("Successfully verified %d/%d blocks\n", verified, 
           (int)(data_blocks < 1000 ? data_blocks : 1000));
    
    // 7. 模拟篡改攻击
    printf("\nSimulating tamper on block 10...\n");
    data_device[10 * BLOCK_SIZE + 0x100] ^= 0xFF;
    verify_data_block(data_device, hash_device, ¶ms, 10);
    
    free(data_device);
    free(hash_device);
    return 0;
}

编译运行:


gcc -o verity_tool verity_tool.c -lcrypto -lssl
dd if=/dev/urandom of=test.img bs=1M count=10
./verity_tool test.img

六、安全模型与限制

dm-verity 提供的是离线完整性保护(offline integrity protection)。它的安全模型建立在以下假设之上:

  1. **根哈希可信**:根哈希必须通过安全信道获取(如数字签名、可信 bootloader 传递)
  2. **数据区暴露可接受**:原始数据块本身不包含机密信息(或额外加密保护)
  3. **只读假设**:数据写入必须发生在哈希树构建之前

dm-verity 不提供的保护:

  • 不防重放攻击(可结合 `dm-integrity` 的 journal 模式)
  • 不防数据删除(可结合写时复制 CoW 机制)
  • 不提供机密性(需配合 `dm-crypt` 使用)

在生产环境中,dm-verity 通常与 dm-crypt 配合使用。内核支持设置 allow_discards 等参数优化 SSD 场景下的性能。

七、实战:容器镜像完整性验证

在 OCI 容器镜像中,dm-verity 正在成为镜像层完整性验证的重要方案。以下是使用 verity 保护容器存储层的完整流程:


# 1. 提取容器镜像层
skopeo copy docker://alpine:3.18 oci:alpine:3.18
cd alpine/blobs/sha256

# 2. 解压 tar 层,创建循环设备
layer_hash=$(cat ../index.json | jq -r '.manifests[0].digest' | cut -d: -f2)
tar xf ${layer_hash}
dd if=/dev/zero of=verity-data.img bs=1M count=20
mkfs.ext4 verity-data.img

# 3. 挂载并复制数据
mount -o loop verity-data.img /mnt/tmp
tar xf ${layer_hash} -C /mnt/tmp/
umount /mnt/tmp

# 4. 创建 verity 哈希树
veritysetup format verity-data.img verity-hash.img

# 5. 获取 root hash
HASH_INFO=$(veritysetup format verity-data.img verity-hash.img)
ROOT_HASH=$(echo "$HASH_INFO" | grep "Root Hash:" | awk '{print $NF}')

# 6. 通过 device mapper 加载
dmsetup create alpine-verity --table \
  "0 $(blockdev --getsz verity-data.img) verity 1 \
   verity-data.img verity-hash.img \
   4096 4096 $(echo "$HASH_INFO" | grep "Data blocks:" | awk '{print $NF}') 1 \
   sha256 $ROOT_HASH"

# 7. 挂载验证后的设备
mount /dev/mapper/alpine-verity /mnt/safe

# 8. 验证:篡改任何数据将导致 EIO
echo "test" > /dev/mapper/alpine-verity  # 会失败,设备只读

八、总结

dm-verity 是 Linux 内核中一个精巧而强大的完整性子系统。它的核心思想简洁:通过 Merkle 哈希树将整个块设备的完整性归约为一个根哈希值。一旦根哈希被可信地锚定(签名、硬件信任根),任何后续的数据篡改都能被立即检测。

在多租户、容器化、边缘计算场景中,dm-verity 提供了比传统签名校验更细粒度的保护——它保护的是运行时的每一个 4KB 块,而不是整体镜像文件。理解其哈希树数据结构和内核验证流程,对于构建可信的 Linux 存储栈至关重要。

对于需要同时保证完整性和机密性的场景,建议组合使用 dm-verity + dm-crypt,或者使用更新的 dm-integrity(支持在线写入和 journaling),在不同的安全-性能权衡中找到最佳平衡点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部