Yocto Hardening: dm-verity

You can find the other Yocto hardening posts from here!

In the last part of the Yocto Hardening, we talked about read-only file systems. The methods presented there show good ways to protect the integrity of a running system. But what if we want to protect the system from corruptions? Or from unauthorised modifications when the device is turned off? In embedded systems, it is often at least theoretically possible to remove the storage media, modify it offline, and then attempt to run the modified system. Or just modify the root file system in the boot loader. To prevent this, we need to have actual integrity checking in the system, instead of just relying on the read-only assumptions.

dm-verity

dm-verity is a Linux feature that provides integrity checking for read-only block devices. The first part of the name, dm, comes from the term “device mapper”. Device mapper is a Linux framework that allows creating virtual block devices on top of physical block devices. When the accesses to physical devices go through these dm-devices, the device mapper devices can implement custom functionality like integrity checking or encryption on top of the underlying device.

In dm-verity, the underlying file system is divided into blocks, each block is hashed (typically with SHA256), and a Merkle hash tree is generated from the block hashes. When a block from the file system is read, dm-verity calculates the hash of the accessed block and compares it against the hash tree. If someone or something has managed to modify the contents of the block device, a warning, reboot or kernel panic is raised.

When your kernel wants to panic, but you configure it to just print a log message instead.

The hash tree can be stored either after the file system in the slack space of the same partition, or in a separate partition. Storing it in an offset after the file system is simple, but because it is stored in an unmarked space, there is a risk that something at some point may accidentally overwrite it (for example, scripts that increase the size of the file system to match the partition size). You’ll also need to calculate the file system and partition sizes carefully so that there is enough extra space (usually around 8%-10%).

On the other hand, setting up the extra partition is a bit more complex to set up and maintain as it requires changes to the partition table. However, it is clearer as there is no mysterious unallocated yet vital space on the disk. This makes it harder to mess things up. Also, you don’t need to worry about calculating the hash tree offset.

Hash Tree and Root Hash

In the Merkle hash tree, changes to any node of the tree change the hash of the parent nodes. This, in practice, means that any change in the file system changes the root hash of the hash tree. Because all the changes propagate through the tree, we don’t need to protect the integrity of the entire hash tree: protecting the integrity and the authenticity of just the root hash is enough.

This root hash is required to open the protected device during the system initialization, either in kernel during the early mapped device creation, or in the initramfs, or even in the user-space. There are numerous places where we can store and retrieve the root hash from:

  1. Store the root hash in the initramfs. This is unsafe if the integrity of the initramfs isn’t verified, but if it is for example bundled into a signed kernel, then this method can be quite safe and simple.
  2. External partition. This partition should also be integrity-checked to keep the root hash secure, and the root hash itself should be signed.
  3. FIT image. This is a handy place to store everything that’s needed, and the information can be signed to ensure the authenticity as well.
  4. TPM, from where it can be unsealed if the device is in an acceptable state. Complex, but secure approach, as tampering it becomes a lot harder.
  5. Hardcoded into the kernel boot parameters. This is complex, as it creates a dependency between the bootloader, the root file system, and the kernel.

So, quite a few options then. If the root hash is stored somewhere like a signed FIT image or a signed initramfs, the authenticity of the root hash should come from the pre-existing signatures. However, if the root hash is stored for example in unsigned bootloader arguments or read from an external partition, you should look into root hash signing and signature verification. I attached a resource about this to the end of this text.

As one can guess, dm-verity requires the underlying file system to be read-only, as being read-write defeats the whole point of integrity checking. Therefore, you’ll need the learnings from the previous read-only rootfs blog post.

Getting Practical with dm-verity

That’s all for the theory part, how to actually enable dm-verity then? It is usually quite straightforward. First, you’ll prepare the disk (or disk image), and then open it before mounting it. With the verity tooling, this can be as simple as running two commands.

Preparing the Disk

To set up dm-verity for a partition, veritysetup command could be used. An alternative is using lower-level utility dmsetup directly, but it is a bit less user-friendly. Initialising the partition with veritysetup can be done with format subcommand, and the command looks as follows:

veritysetup format \
    <DATA_DEVICE> \
    <HASH_DEVICE>

The device can be a block device, or it can also be a disk file, like <IMAGE_NAME>.ext4 or <IMAGE_NAME>.erofs. This command creates the hash tree to the destination defined in the second parameter. If you want to append the hash tree to a disk image, add the --hash-offset parameter. For example, with core-image-base image file from a Yocto build, the command could look as follows:

stat -c%s core-image-base-raspberrypi4-64.rootfs.ext3.inplace
veritysetup format \
    --hash-offset=<STAT_COMMAND_OUTPUT> \
    core-image-base-raspberrypi4-64.rootfs.ext3.inplace \
    core-image-base-raspberrypi4-64.rootfs.ext3.inplace

The output of the command might look like the following:

VERITY header information for core-image-base-raspberrypi4-64.rootfs.ext3.inplace
UUID:                   a69d12d3-4a32-4355-8f8d-3cf6d0d498af
Hash type:              1
Data blocks:            53248
Data block size:        4096
Hash block size:        4096
Hash algorithm:         sha256
Salt:                   5c8e96a895c8e22b40af9b06575b7dfbbc6095a03faaed1d03a3b30c41fb2ace
Root hash:              fcc16a56e6915ff3d20adc9e7e23b55bd5a74b4d81b9a6ac9ae30dbf52f8442f

From the output you can see the important root hash value that we need to secure and store for opening the device. Speaking of which.

Opening the Disk

To open the device, there are (at least) two options. We can either use dm-mod.create kernel parameter to open the device in kernel, or we can use veritysetup open in an initramfs. Let’s go through these:

Kernel Command-Line Parameter

When opening the disk directly with the kernel’s early device mapping parameter dm-mod.create, we need to add something like this to the kernel command-line:

dm-mod.create="<name>,<uuid>,<minor>,<flags>,<table>"

Most of the fields are quite self-explanatory, and if they’re not, there will be an example that will hopefully explain things. The biggest mystery is table variable, which depends on the type of device mapper device you’re setting up. For dm-verity, it looks as follows:

<start> <length> verity <version> <data_dev> <hash_dev> <data_block_size> <hash_block_size> <num_data_blocks> <hash_start_block> <algorithm> <root_hash> <salt>

This is… quite a number of fields to fill out. Still, with this command line parameter, you can avoid the need for an initramfs, assuming you can provide this information to the kernel parameters when it boots. Here’s one example of what the boot parameter may look like:

dm-mod.create="testdevice,,,ro,0 204800 verity 1 /dev/mmcblk0p2 /dev/mmcblk0p3 4096 4096 25600 0 sha256 9bcd1234af56e789b012c345d678e901f234a567b890c123d456e789f012a345 a3f1b2c4d5e6f7a8"
# Where:
# testdevice: device name
# : UUID empty
# : Device minor empty (kernel assigns it)
# ro: flags
# 0: start sector
# 204800: data length in 512 byte sectors
# verity: dm-mod name
# 1: verity version
# /dev/mmcblk0p2: data device
# /dev/mmcblk0p3: hash device
# 4096: data block size in bytes
# 4096: hash block size in bytes
# 25600: data block amount
# 0: hash device start sector
# sha256: hashing algorithm
# 9bcd1...: root hash
# a3f1b...: salt

This parameter assumes you have the hash tree in a separate partition. If that’s not the case, you would adjust the data and hash devices to be the same device, and the hash device start sector to continue right after the data sectors.

veritysetup in Initramfs

If all that seems like a lot of hassle, you can use veritysetup open in an initramfs instead. That is quite a lot simpler, at least if you are using an initramfs anyway and don’t have to start shoehorning it in. To do that, at some point in your initramfs scripts, you’d need to run the following command:

veritysetup open \
    <DATA_DEVICE> \
    <DEVICE_NAME> \
    <HASH_DEVICE> \
    <ROOT_HASH>
# There also exists obsolete syntax, so
# don't be confused if you see this instead:
# veritysetup create...

Notice the distinct lack of bazillion parameters? This again applies to the situations where you have the hashes on a separate device. If that’s not the case, you should add the --hash-offset parameter with the correct offset.

After you have opened the device mapper device, you should still mount it with the regular mount command:

mount -o ro /dev/mapper/<DEVICE_NAME> /<MOUNT_POINT>
One of my all-time favourite games, and worst memes, in one picture.

dm-verity in Yocto

Now that we know what should be done, let’s move on to implementing it on the Yocto side. Our old favourite, meta-security, has a mechanism for enabling dm-verity for the root file system. The provided feature calculates the rootfs hash tree during the build and uses initramfs to open the protected disk. The hash tree can either be appended to the end of the file system (default) or to a separate device. The root hash is stored in an environment file in initramfs, so the integrity of the initramfs has to be ensured when using this method.

Note: After writing about 99% of this text, I realised that meta-oe also has a class for generating verity images. When considering adding verity on your system, you may also want to check out that alternative. I didn’t try it out, but it seems to be generating a .verity suffixed image with appended hash-tree and an environment file for opening it, so it seems to be quite similar, but it’s still worth knowing about.

The feature requires some configuration and small Wic modifications to get running. This example builds a bit on the examples presented in the read-only file system blog post, where we enabled erofs on a Raspberry Pi, so I’m assuming you’re building erofs image already (this was enabled with IMAGE_FSTYPES:append = " erofs"). To enable dm-verity, first you’ll define the following in your build configuration:

# This mechanism stores the root hash in
# initramfs, and initramfs prepares verity,
# so we need an initramfs image
INITRAMFS_IMAGE = "dm-verity-image-initramfs"
# The name of the image you want to
# integrity check with dm-verity
DM_VERITY_IMAGE = "core-image-base"
# Type of the image, important for finding
# the artifacts during hash tree calculation
DM_VERITY_IMAGE_TYPE = "erofs"
# This actually enables the verity tasks for
# the image
IMAGE_CLASSES:append = " dm-verity-img "
# Not mandatory, but you may want to use this for
# the simplicity
INITRAMFS_IMAGE_BUNDLE = "1"
# Required for the variables in Wic file to
# expand correctly
WICVARS:append = " DM_VERITY_IMAGE DM_VERITY_IMAGE_TYPE IMAGE_NAME_SUFFIX"
# If using kernel recipe other than linux-yocto,
# add this as well
KERNEL_FEATURES:append = " features/device-mapper/dm-verity.scc"

The magic here is the dm-verity-img that handles most of the image generation work, and dm-verity-image-initramfs that opens the device during the boot. If you’d like to use a separate device for storing the hash tree, you’d set DM_VERITY_SEPARATE_HASH to 1. This should take care of the Wic configuration as well, as it generates a wks.in file with the root and hash partitions. In the case of the appended hash tree, dm-verity-img takes the regular file system image, calculates the hash tree, and outputs a single complete file system image with .verity suffix.

In this demo, I’ll stick to using the default appended hash tree. Since we’re using the Raspberry Pi from the previous read-only rootfs example, we’ll need to modify the Wic file to use the generated verity image instead of the “regular” image:

# Replace this:
# part / --source rootfs --ondisk mmcblk0 --fstype=ext4 --label root --align 4096
# with this:
part / --source rawcopy --ondisk mmcblk0 --sourceparams="file=${IMGDEPLOYDIR}/${DM_VERITY_IMAGE}-${MACHINE}${IMAGE_NAME_SUFFIX}.${DM_VERITY_IMAGE_TYPE}.verity --align 4096

And for this simplest case of appending the hash tree and storing the root hash in the initramfs, that’s all that is required! You should now be able to boot your device and see a verity-related message in the log:

device-mapper: verity: sha256 using implementation "sha256-generic"

If you don’t see the log message, ensure that the correct kernel image is installed. For some reason, when I try enabling initramfs, the wrong kernel without initramfs always gets picked, so you may manually have to copy it in place.

If you want to try out corrupting the device, you could try something like this. Simply modifying the files doesn’t work because the underlying file system erofs does not support writing the files:

dd if=/dev/zero of=/dev/<ROOTFS_DEVICE> bs=4096 seek=128 count=32 conv=notrunc

Now, when you boot, you should see something new in the logs:

device-mapper: verity: sha256 using implementation "sha256-generic"
device-mapper: verity: 179:2: data block 512 is corrupted
device-mapper: verity: 179:2: data block 512 is corrupted
Buffer I/O error on dev dm-0, logical block 128, async page read
Verity device detected corruption after activation.
device-mapper: verity: 179:2: data block 512 is corrupted
device-mapper: verity: 179:2: data block 512 is corrupted

The default behaviour is to fail the I/O operation, which naturally results in the errors getting printed in the dmesg. The behaviour can be controlled by the veritysetup open command parameters, so you’ll need to modify the initramfs script to change it.

Closing Words

As you can see, dm-verity is indeed a really useful feature for ensuring the integrity of your read-only file systems. It can also be configured to work in a number of different ways, so you’ll have to do some thinking trying to come up with the best configuration for your situation. The important things to consider are at least the threats your device will be facing and how you plan to handle the firmware upgrades. The answers to these should give some guidance on where you need to store the root hash and how to handle the changes in the root hash during firmware upgrades. But that’s all for now, see you in the next text!

As a reminder, if you’re interested, you can find the previous Yocto hardening posts from here!

Recommended Reading

Share