Merge patch series "boot: fit: authenticate the dm-verity roothash"
Daniel Golle <daniel@makrotopia.org> says: A signed FIT configuration can delegate the integrity of a (potentially large) root filesystem image to the kernel's dm-verity instead of having U-Boot hash the whole payload at boot: the FIT carries a "dm-verity" subnode with the roothash, salt and block parameters, U-Boot passes the roothash to Linux through the dm-mod.create bootargs, and dm-verity then validates the filesystem block by block against it. For that to be safe the roothash has to be trusted, and in a signed configuration the only thing that establishes trust is the configuration signature. The roothash was not covered by it. fit_config_add_hash() collected the image node, its hash subnodes and its cipher subnode into the signed region, but not the dm-verity subnode, so the roothash, the sole integrity anchor for the filesystem, was left unsigned. The result is a verified-boot bypass for the root filesystem: an attacker who can rewrite the boot medium can replace the filesystem, recompute a matching dm-verity tree, write the new roothash into the unsigned dm-verity subnode, and the configuration signature still verifies. dm-verity then faithfully validates the malicious filesystem against the attacker's roothash. This series closes the gap. Link: https://lore.kernel.org/r/cover.1785276461.git.daniel@makrotopia.org
This commit is contained in:
@@ -6,6 +6,11 @@
|
||||
*/
|
||||
|
||||
#include <image.h>
|
||||
#include <fdt_region.h>
|
||||
#include <malloc.h>
|
||||
#include <linux/kernel.h>
|
||||
#include <linux/libfdt.h>
|
||||
#include <u-boot/hash-checksum.h>
|
||||
#include <test/test.h>
|
||||
#include <test/ut.h>
|
||||
|
||||
@@ -304,3 +309,198 @@ static int fit_verity_test_bad_blocksize(struct unit_test_state *uts)
|
||||
return 0;
|
||||
}
|
||||
FIT_VERITY_TEST(fit_verity_test_bad_blocksize, 0);
|
||||
|
||||
#if CONFIG_IS_ENABLED(FIT_SIGNATURE)
|
||||
/**
|
||||
* build_signed_verity_fit() - build a FIT with a signable verity config
|
||||
* @buf: output buffer (at least FIT_BUF_SIZE bytes)
|
||||
*
|
||||
* Like build_verity_fit(), but the filesystem image also carries a hash
|
||||
* subnode (required for a configuration to be signable) so the config's
|
||||
* signed-region node list can be built with fit_config_get_signed_nodes().
|
||||
*
|
||||
* Return: configuration node offset, or -ve on error
|
||||
*/
|
||||
static int build_signed_verity_fit(void *buf)
|
||||
{
|
||||
int images_node, confs_node, conf_node, img_node, hash_node, verity_node;
|
||||
fdt32_t val;
|
||||
int ret;
|
||||
|
||||
ret = fdt_create_empty_tree(buf, FIT_BUF_SIZE);
|
||||
if (ret)
|
||||
return ret;
|
||||
|
||||
images_node = fdt_add_subnode(buf, 0, "images");
|
||||
if (images_node < 0)
|
||||
return images_node;
|
||||
|
||||
img_node = fdt_add_subnode(buf, images_node, "rootfs");
|
||||
if (img_node < 0)
|
||||
return img_node;
|
||||
ret = fdt_setprop_string(buf, img_node, FIT_TYPE_PROP, "filesystem");
|
||||
if (ret)
|
||||
return ret;
|
||||
|
||||
hash_node = fdt_add_subnode(buf, img_node, "hash-1");
|
||||
if (hash_node < 0)
|
||||
return hash_node;
|
||||
ret = fdt_setprop_string(buf, hash_node, FIT_ALGO_PROP, "sha256");
|
||||
if (ret)
|
||||
return ret;
|
||||
ret = fdt_setprop(buf, hash_node, FIT_VALUE_PROP, test_digest,
|
||||
sizeof(test_digest));
|
||||
if (ret)
|
||||
return ret;
|
||||
|
||||
verity_node = fdt_add_subnode(buf, img_node, FIT_VERITY_NODENAME);
|
||||
if (verity_node < 0)
|
||||
return verity_node;
|
||||
ret = fdt_setprop_string(buf, verity_node, FIT_VERITY_ALGO_PROP,
|
||||
"sha256");
|
||||
if (ret)
|
||||
return ret;
|
||||
val = cpu_to_fdt32(4096);
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_DBS_PROP, &val,
|
||||
sizeof(val));
|
||||
if (ret)
|
||||
return ret;
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_HBS_PROP, &val,
|
||||
sizeof(val));
|
||||
if (ret)
|
||||
return ret;
|
||||
val = cpu_to_fdt32(100);
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_NBLK_PROP, &val,
|
||||
sizeof(val));
|
||||
if (ret)
|
||||
return ret;
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_HBLK_PROP, &val,
|
||||
sizeof(val));
|
||||
if (ret)
|
||||
return ret;
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_DIGEST_PROP, test_digest,
|
||||
sizeof(test_digest));
|
||||
if (ret)
|
||||
return ret;
|
||||
ret = fdt_setprop(buf, verity_node, FIT_VERITY_SALT_PROP, test_salt,
|
||||
sizeof(test_salt));
|
||||
if (ret)
|
||||
return ret;
|
||||
|
||||
confs_node = fdt_add_subnode(buf, 0, "configurations");
|
||||
if (confs_node < 0)
|
||||
return confs_node;
|
||||
conf_node = fdt_add_subnode(buf, confs_node, "conf-1");
|
||||
if (conf_node < 0)
|
||||
return conf_node;
|
||||
ret = fdt_setprop_string(buf, conf_node, FIT_LOADABLE_PROP, "rootfs");
|
||||
if (ret)
|
||||
return ret;
|
||||
|
||||
return conf_node;
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: the dm-verity roothash and salt are inside the region covered by the
|
||||
* configuration signature.
|
||||
*
|
||||
* A dm-verity filesystem image is not hashed by U-Boot; its integrity is
|
||||
* delegated to the kernel, which trusts the roothash from the FIT dm-verity
|
||||
* subnode. That roothash must therefore be part of the signed region, so that
|
||||
* an attacker cannot swap both the filesystem and the roothash while keeping
|
||||
* the configuration signature valid.
|
||||
*
|
||||
* This checks the property without a private key, so it also runs on real
|
||||
* devices: it builds the exact node list the signature is computed over
|
||||
* (fit_config_get_signed_nodes), turns it into hashed regions, and verifies both
|
||||
* that the roothash bytes fall inside a region and that tampering them changes
|
||||
* the hash. It uses the same hash path a device would (crypto accelerated where
|
||||
* available).
|
||||
*/
|
||||
static int fit_verity_test_roothash_signed(struct unit_test_state *uts)
|
||||
{
|
||||
char buf[FIT_BUF_SIZE];
|
||||
char *node_inc[32];
|
||||
char path_buf[256];
|
||||
char region_path[256];
|
||||
struct fdt_region fdt_regions[64];
|
||||
struct image_region *region = NULL;
|
||||
int conf_node, verity_node;
|
||||
int count, i, digest_len;
|
||||
const void *digest;
|
||||
ulong digest_off, region_off;
|
||||
bool covered = false;
|
||||
u8 hash_clean[32], hash_tampered[32], hash_control[32];
|
||||
|
||||
conf_node = build_signed_verity_fit(buf);
|
||||
ut_assert(conf_node >= 0);
|
||||
|
||||
verity_node = fdt_path_offset(buf, "/images/rootfs/dm-verity");
|
||||
ut_assert(verity_node >= 0);
|
||||
|
||||
/* Build the node list the configuration signature is computed over. */
|
||||
count = fit_config_get_signed_nodes(buf, conf_node, node_inc,
|
||||
ARRAY_SIZE(node_inc), path_buf,
|
||||
sizeof(path_buf));
|
||||
ut_assert(count > 0);
|
||||
|
||||
/*
|
||||
* Turn the node list into hashed regions. No exclude list is needed:
|
||||
* the excluded properties (data, data-size, data-offset,
|
||||
* data-position) never include the dm-verity digest or salt, so the
|
||||
* coverage answer is the same with or without it.
|
||||
*/
|
||||
count = fdt_find_regions(buf, node_inc, count, NULL, 0, fdt_regions,
|
||||
ARRAY_SIZE(fdt_regions) - 1, region_path,
|
||||
sizeof(region_path), 0);
|
||||
ut_assert(count > 0);
|
||||
/* Region array exhausted: mirror the bound fit_config_check_sig() enforces. */
|
||||
ut_assert(count < ARRAY_SIZE(fdt_regions) - 1);
|
||||
|
||||
region = fit_region_make_list(buf, fdt_regions, count, NULL);
|
||||
ut_assertnonnull(region);
|
||||
|
||||
digest = fdt_getprop(buf, verity_node, FIT_VERITY_DIGEST_PROP,
|
||||
&digest_len);
|
||||
ut_assertnonnull(digest);
|
||||
ut_assert(digest_len > 0);
|
||||
digest_off = (ulong)((const char *)digest - (const char *)buf);
|
||||
|
||||
/*
|
||||
* Control: the hash covers a non-empty region and reacts to a change
|
||||
* inside it. Flip a byte of the (signed) image hash value and confirm
|
||||
* the computed hash differs, proving the region set and hash work.
|
||||
*/
|
||||
ut_assertok(hash_calculate("sha256", region, count, hash_clean));
|
||||
for (i = 0; i < count; i++) {
|
||||
region_off = (ulong)((const char *)region[i].data -
|
||||
(const char *)buf);
|
||||
if (digest_off >= region_off &&
|
||||
digest_off + digest_len <= region_off + region[i].size) {
|
||||
covered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* The roothash must be covered by the configuration signature. */
|
||||
ut_assert(covered);
|
||||
|
||||
/*
|
||||
* Tampering the roothash must change the signed hash. Only the digest
|
||||
* is flipped here; salt sits in the same dm-verity node, so coverage
|
||||
* of one implies coverage of the other.
|
||||
*/
|
||||
buf[digest_off] ^= 0xff;
|
||||
ut_assertok(hash_calculate("sha256", region, count, hash_tampered));
|
||||
buf[digest_off] ^= 0xff;
|
||||
ut_assert(memcmp(hash_clean, hash_tampered, sizeof(hash_clean)) != 0);
|
||||
|
||||
/* Sanity: with the byte restored the hash matches the clean value. */
|
||||
ut_assertok(hash_calculate("sha256", region, count, hash_control));
|
||||
ut_asserteq_mem(hash_clean, hash_control, sizeof(hash_clean));
|
||||
|
||||
free(region);
|
||||
return 0;
|
||||
}
|
||||
FIT_VERITY_TEST(fit_verity_test_roothash_signed, 0);
|
||||
#endif /* FIT_SIGNATURE */
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
# Copyright 2026 Daniel Golle <daniel@makrotopia.org>
|
||||
|
||||
"""Verify that the dm-verity roothash is covered by the FIT configuration
|
||||
signature.
|
||||
|
||||
A dm-verity protected filesystem image is not hashed by U-Boot; its integrity
|
||||
is delegated to the kernel, which trusts the roothash taken from the FIT
|
||||
``dm-verity`` subnode. That roothash must therefore be part of the signed
|
||||
region of the configuration, otherwise an attacker can replace both the
|
||||
filesystem and the roothash while keeping the configuration signature valid.
|
||||
|
||||
This test signs a configuration referencing a filesystem image that carries a
|
||||
``dm-verity`` subnode, then flips one byte of the roothash and of the salt and
|
||||
checks that verification rejects the image. A control tampering a byte that is
|
||||
known to be signed confirms that the check is able to detect a broken region.
|
||||
|
||||
The FIT pairs a signed configuration with a filesystem image carrying a
|
||||
``dm-verity`` subnode:
|
||||
|
||||
.. code-block:: devicetree
|
||||
|
||||
images {
|
||||
rootfs-1 {
|
||||
data = /incbin/("rootfs.bin");
|
||||
type = "filesystem";
|
||||
compression = "none";
|
||||
hash-1 {
|
||||
algo = "sha256";
|
||||
};
|
||||
dm-verity {
|
||||
algo = "sha256";
|
||||
data-block-size = <4096>;
|
||||
hash-block-size = <4096>;
|
||||
num-data-blocks = <16>;
|
||||
hash-start-block = <16>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
configurations {
|
||||
conf-1 {
|
||||
kernel = "kernel-1";
|
||||
loadables = "rootfs-1";
|
||||
signature-1 {
|
||||
algo = "sha256,rsa2048";
|
||||
key-name-hint = "dev";
|
||||
sign-images = "kernel", "loadables";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
mkimage builds the dm-verity hash tree when assembling the image and records
|
||||
the resulting roothash and salt in the ``dm-verity`` subnode; fit_check_sign
|
||||
must reject an image where either was modified after signing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import utils
|
||||
|
||||
# 16 blocks of 4096 bytes, matching num-data-blocks/data-block-size below.
|
||||
ROOTFS_SIZE = 16 * 4096
|
||||
|
||||
ITS = '''
|
||||
/dts-v1/;
|
||||
/ {
|
||||
description = "verity roothash signing coverage test";
|
||||
#address-cells = <1>;
|
||||
|
||||
images {
|
||||
kernel-1 {
|
||||
description = "kernel";
|
||||
data = /incbin/("kernel.bin");
|
||||
type = "kernel";
|
||||
arch = "arm64";
|
||||
os = "linux";
|
||||
compression = "none";
|
||||
load = <0x40000000>;
|
||||
entry = <0x40000000>;
|
||||
hash-1 { algo = "sha256"; };
|
||||
};
|
||||
rootfs-1 {
|
||||
description = "rootfs";
|
||||
data = /incbin/("rootfs.bin");
|
||||
type = "filesystem";
|
||||
arch = "arm64";
|
||||
compression = "none";
|
||||
hash-1 { algo = "sha256"; };
|
||||
dm-verity {
|
||||
algo = "sha256";
|
||||
data-block-size = <4096>;
|
||||
hash-block-size = <4096>;
|
||||
num-data-blocks = <16>;
|
||||
hash-start-block = <16>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
configurations {
|
||||
default = "conf-1";
|
||||
conf-1 {
|
||||
description = "signed config";
|
||||
kernel = "kernel-1";
|
||||
loadables = "rootfs-1";
|
||||
signature-1 {
|
||||
algo = "sha256,rsa2048";
|
||||
key-name-hint = "dev";
|
||||
sign-images = "kernel", "loadables";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'''
|
||||
|
||||
VERITY_NODE = '/images/rootfs-1/dm-verity'
|
||||
ROOTFS_HASH_NODE = '/images/rootfs-1/hash-1'
|
||||
|
||||
|
||||
def flip_prop_byte(ubman, fit, node, prop):
|
||||
"""Flip the first byte of a byte-array property in a FIT, in place.
|
||||
|
||||
The property is rewritten with the same length so that no node is
|
||||
relaid out and the signed regions keep their offsets.
|
||||
"""
|
||||
val = utils.run_and_log(ubman, 'fdtget -t bx %s %s %s' % (fit, node, prop))
|
||||
bytelist = val.split()
|
||||
bytelist[0] = '%x' % (int(bytelist[0], 16) ^ 0xff)
|
||||
utils.run_and_log(ubman, 'fdtput -t bx %s %s %s %s' %
|
||||
(fit, node, prop, ' '.join(bytelist)))
|
||||
|
||||
|
||||
@pytest.mark.boardspec('sandbox')
|
||||
@pytest.mark.buildconfigspec('fit_signature')
|
||||
@pytest.mark.requiredtool('dtc')
|
||||
@pytest.mark.requiredtool('fdtget')
|
||||
@pytest.mark.requiredtool('fdtput')
|
||||
@pytest.mark.requiredtool('openssl')
|
||||
@pytest.mark.requiredtool('veritysetup')
|
||||
def test_fit_verity_roothash_signed(ubman):
|
||||
"""The dm-verity roothash must be inside the signed configuration region."""
|
||||
tmpdir = os.path.join(ubman.config.result_dir, 'verity-sign') + '/'
|
||||
if not os.path.exists(tmpdir):
|
||||
os.makedirs(tmpdir)
|
||||
mkimage = ubman.config.build_dir + '/tools/mkimage'
|
||||
fit_check_sign = ubman.config.build_dir + '/tools/fit_check_sign'
|
||||
dtc_args = '-I dts -O dtb -i %s' % tmpdir
|
||||
its = tmpdir + 'verity.its'
|
||||
fit = tmpdir + 'verity.itb'
|
||||
dtb = tmpdir + 'control.dtb'
|
||||
|
||||
# Signing key and empty control dtb to receive the public key.
|
||||
utils.run_and_log(ubman, 'openssl genpkey -algorithm RSA -out %sdev.key '
|
||||
'-pkeyopt rsa_keygen_bits:2048 '
|
||||
'-pkeyopt rsa_keygen_pubexp:65537' % tmpdir)
|
||||
utils.run_and_log(ubman, 'openssl req -batch -new -x509 -key %sdev.key '
|
||||
'-out %sdev.crt' % (tmpdir, tmpdir))
|
||||
with open(tmpdir + 'control.dts', 'w') as f:
|
||||
f.write('/dts-v1/; / { model = "verity-test"; };\n')
|
||||
utils.run_and_log(ubman, 'dtc -O dtb -o %s %scontrol.dts' % (dtb, tmpdir))
|
||||
|
||||
# Payloads. The rootfs must be a whole number of data blocks so mkimage can
|
||||
# build the dm-verity hash tree and compute the roothash.
|
||||
with open(tmpdir + 'rootfs.bin', 'wb') as f:
|
||||
f.write(b'R' * ROOTFS_SIZE)
|
||||
with open(tmpdir + 'kernel.bin', 'wb') as f:
|
||||
f.write(b'KERNEL')
|
||||
|
||||
with open(its, 'w') as f:
|
||||
f.write(ITS)
|
||||
|
||||
# Build and sign. -E keeps the (large) rootfs external, as on a real device.
|
||||
utils.run_and_log(ubman, [mkimage, '-D', dtc_args, '-E', '-f', its,
|
||||
'-k', tmpdir, '-K', dtb, '-r', fit])
|
||||
|
||||
# Baseline: the freshly signed image must verify.
|
||||
utils.run_and_log(ubman, [fit_check_sign, '-f', fit, '-k', dtb])
|
||||
|
||||
# Control: tampering a byte that is signed (the filesystem image hash value)
|
||||
# must be detected. This proves the check can fail.
|
||||
control = tmpdir + 'control.itb'
|
||||
utils.run_and_log(ubman, 'cp %s %s' % (fit, control))
|
||||
flip_prop_byte(ubman, control, ROOTFS_HASH_NODE, 'value')
|
||||
utils.run_and_log_expect_exception(
|
||||
ubman, [fit_check_sign, '-f', control, '-k', dtb],
|
||||
1, 'Failed to verify required signature')
|
||||
|
||||
# Roothash: tampering the dm-verity digest must be rejected. If the digest
|
||||
# is outside the signed region this check passes and boot is compromised.
|
||||
tampered = tmpdir + 'tamper-digest.itb'
|
||||
utils.run_and_log(ubman, 'cp %s %s' % (fit, tampered))
|
||||
flip_prop_byte(ubman, tampered, VERITY_NODE, 'digest')
|
||||
utils.run_and_log_expect_exception(
|
||||
ubman, [fit_check_sign, '-f', tampered, '-k', dtb],
|
||||
1, 'Failed to verify required signature')
|
||||
|
||||
# Salt: likewise, the salt feeds the dm-verity target and must be signed.
|
||||
tampered = tmpdir + 'tamper-salt.itb'
|
||||
utils.run_and_log(ubman, 'cp %s %s' % (fit, tampered))
|
||||
flip_prop_byte(ubman, tampered, VERITY_NODE, 'salt')
|
||||
utils.run_and_log_expect_exception(
|
||||
ubman, [fit_check_sign, '-f', tampered, '-k', dtb],
|
||||
1, 'Failed to verify required signature')
|
||||
Reference in New Issue
Block a user