近期做的一个项目,由于客户对安全性要求比较高,涉及到文件系统校验的问题,起初是在ramdisk中挂载rootfs后对所有重要的文件检查sha256,但是随着rootfs的逐步增大,发现校验花费的时间太长,竟然达到十几秒,于是就想改用一种方案,首先想到的是整个rootfs校验后在挂载,这样肯定是比一个文件一个文件校验要来得快些,但是项目中用的是nandflash,并不是EMMC,所以文件系统只能采用支持坏块管理的ubifs,于是这里就存在问题了,ubi层负责逻辑块到物理块的映射,也就是说在物理存储上块不一定是逻辑连续的,这样在ubifs还没有挂载之前读取整个镜像然后整体校验肯定是行不通的,一个解决办法是读取ubi卷设备而不是mtd设备,这样的话是可行的,但是一种更好的办法是采用dm-verity,但是这个是Linux项目,并不是Android项目,通过对dm-verity移植,这里记录一下对dm-verity的理解。
dm-verity是什么?
它是dm(device mapper)的一个target,是一个虚拟块设备,专门用于文件系统的校验
+------------------------------+
| fs |
+------------------------------+
|
↓
+-------------------------------+
| dm-verity |
+-------------------------------+
|
↓
+-------------------------------+
| block driver |
+--------------------------------+
|
↓
+-------------------------------+
| block device |
+-------------------------------+
fs在挂载的时候直接指定dm-verity设备,也就是fs直接交互的设备是dm-verity,dm-verity调用真正的块驱动去读取对应的块,并计算hash值和hash-tree中对应的hash值进行比较,如果相等,则说明块没有被篡改,返回块数据给fs,如果不相等,则说明块被篡改,根据mode是返回EIO,或者直接重启。
首先通过ioctl去crt(create)一个dm-verity设备,通过传入参数指定这个创建的dm-verity设备的一些特性,传入的参数包括verity-table,verity-table的内容如下:
40 def build_verity_table(block_device, data_blocks, root_hash, salt):
41 table = "1 %s %s %s %s %s %s sha256 %s %s"
42 table %= ( block_device,
43 block_device,
44 BLOCK_SIZE,
45 BLOCK_SIZE,
46 data_blocks,
47 data_blocks + (METADATA_SIZE / BLOCK_SIZE),
48 root_hash,
49 salt)
50 return table
block_device描述了该dm-verity设备对应了那个底层的块设备,第二个block_device指定了hash-tree存在于哪个块设备上,对于我这个项目就是/dev/ubiblock0_0,BLOCK_SIZE描述了多大一个块对应一个hash,一般都4k, data_blocks描述了有多少个4k的块,data_blocks + (METADATA_SIZE / BLOCK_SIZE)表示hash-tree在对应块设备上的偏移,由此来找到hash-tree,root_hash为hash-tree的根hash。
dm-verity工作在块设备之上,所以这里是/dev/ubiblock0_0,于是就不能再用ubifs 了,因为ubifs工作在卷设备之上,而/dev/ubi0_0是一个字符设备,所以只能采用工作在块设备之上的文件系统,我这里采用了squashfs,因为它比较简单。
dm-verity的工作原理
通过前面的描述,很容易理解dm-verity的工作过程,就拿我这个项目来说,squashfs需要读取某个块时,调用dm-verity读取对应的块,dm-verity根据verity-table中block_device,调用block_device读取对应的块,读取到块的内容后dm-verity会算出块的sha256,然后跟verity-hash-tree中相对应的hash值进行比较,如果相等,则说明该块没有被修改过,一切正常。
为何dm-verify支持所有的文件系统?
该项目在选用dm-verity之前,我一直都在怀疑dm-verity是否支持ubifs,通过前面的描述,如果你对dm-verity的工作原理足够理解的话,你就会发现,dm-verity跟文件系统是无关的,只要文件系统是工作在块设备之上的,所以ubifs是不可以的,工作在块设备之上的文件系统都是可以的,dm-verity是对逻辑块校验hash值,产生hash-tree的时候也是根据文件系统镜像来产生的(然后除ubifs之外,不存在逻辑块的概念,但是可以类似将它看出逻辑块直接等于物理块),至于逻辑块到物理块直接是怎样映射,dm-verity根本就不需要关心。
dm-verity为何这么快?
了解了原理之后这个就很好回答了,因为dm-verity并不需要在挂载前对所有的块进行校验,而是在使用的过程中用到哪个块就校验哪个块的hash值,这样对于像android一个分区几个G来说优势就显得更加明显了。
dm-verity是如何保证安全的?
前面说过每个block都在hash-tree中记录了对应的hash值,这样就能防止别人篡改block的内容了,但是如果黑客把block改了之后,重新计算hash把hash-tree中对应的hash值也改了呢,这样就能神不知鬼不觉了,所以必须要有一种机制防止hash-tree被篡改,hash-tree是这样一种结构,所有的block对应的hash值放在最底层,也就是第0层,如下图:
第1层的hash值由下面一层的hash值计算得到,除了第0层,其他的层hash值都不对应物理上block的hash值,它们存在的意义只是为了构建hash链,防止hash篡改,这样第0层的hash值改变了的话,上层对应的hash值也需要修改,也就是说根hash也需要修改,所以只需要一种机制能保证root-hash不被篡改就行了。
Android中采用的方法是算root-hash的签名,verity-table中保存了root-hash,对verity-table进行签名,它们的存储分布如下:
在Android中,系统进入ramdisk后,由/system/core/fs_mgr/ 负责dm-verity设备的创建,verity-table的校验,这里涉及到的一些知识是:
1.如何知道哪些分区需要校验?
fs_mgr通过读取fstab文件,其中记录了哪些分区需要校验
2.如何知道需要校验的分区中verity-table的位置?
这是通过读取文件系统的超级块(Superblock,简称SB),里面记录了文件系统的大小,verity-table紧挨着文件系统镜像之后
3.签名的key存放在哪里?
这里指的是public key
上面说的这些只是Android的一套,自己实现的话没必要完全按照它的来,比如说verity-table和hash-tree没有必要放在分区中,可以放在ramdisk中,签名和校验RSA2014可以自己实现,public key存放的位置可以自己决定,如放在ramdisk中或放在OTP中。
dm-verity异常处理
dm-verity签名校验失败后会怎么做呢?下面是Android的做法:
在metadata分区中会记录dm-verity的状态,提示是否挂载,同时在dm-verity设备创建时也会指定mode,dm-verity在内核中块hash校验失败后不同的mode表现的行为不一样。
// Verity modes
enum verity_mode {
VERITY_MODE_EIO = 0,
VERITY_MODE_LOGGING = 1,
VERITY_MODE_RESTART = 2,
VERITY_MODE_LAST = VERITY_MODE_RESTART,
VERITY_MODE_DEFAULT = VERITY_MODE_RESTART
};
static int load_verity_table(struct dm_ioctl *io, char *name, uint64_t device_size, int fd, char *table,int mode)
{
...
if (mode == VERITY_MODE_EIO) { //对于比较老的内核dm-verity驱动,是不支持mode的,当block hash校验不过时总是cause an I/O error for corrupted blocks
// allow operation with older dm-verity drivers that are unaware
// of the mode parameter by omitting it; this also means that we
// cannot use logging mode with these drivers, they always cause
// an I/O error for corrupted blocks
strcpy(verity_params, table);
} else if (snprintf(verity_params, bufsize, "%s %d", table, mode) < 0) {
return -1;
}
...
ioctl(fd, DM_TABLE_LOAD, io);
}
指定mode后,kernel中碰到校验不过的块的处理:
/*
* Handle verification errors.
*/
static int verity_handle_err(struct dm_verity *v, enum verity_block_type type,
unsigned long long block)
{
...
out:
if (v->mode == DM_VERITY_MODE_LOGGING)
return 0;
if (v->mode == DM_VERITY_MODE_RESTART)
kernel_restart("dm-verity device corrupted");
return 1;
}
记录一下移植的过程中踩过的坑:
在移植的过程中发现Android6.0是有bug的,在产生hash-tree的时候:
image_size = os.stat(out_file).st_size
由于img是sparse过后的,所以这里的大小肯定是不对的,正确的做法应该是先unsparse,然后再计算大小。
另外这里采用的是ubiblock:
ubiblock --create /dev/ubi0_0
mtd--->ubi------>ubi vol----->ubiblock
另外还可以采用gluebi:
mtd---->ubi---->ubi vol--->mtd--->mtdblock
ubiblock比较简单,缺点是只读,在挂载时必须指定为只读:
mount -t squashfs /dev/ubiblock0_0 /mnt -o ro
生成烧录镜像的过程:
rootfs dir---------mksquash------------>rootfs.squashfs-----------ubinize-------------->rootfs.ubi
把rootfs.ubi烧进去即可。
最终实现的效果如下: