From 2c4e3b9a6b62a1d4237452208dbe476322893d27 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Sat, 5 Sep 2026 07:19:03 +0200 Subject: [PATCH] sysupdate: add PartitionZeroFill= and enable it by default for verity signature partitions A verity signature partition carries a JSON object that must be followed by NUL bytes only, up to the end of the partition (dissect refuses anything else). The split-out artifact repart generates for it however is trimmed of that padding since b54ef83414, so that it is a plain JSON text file (which jq nowadays insists on). sysupdate installs such a file into a partition by simply writing it at the partition's offset, leaving whatever follows in place. Hence, once a shorter signature (e.g. after rotating to a different key type) is installed into a partition that previously held a longer one, the tail of the old signature survives right after the new JSON object, and the partition becomes unparsable. Add a new PartitionZeroFill= boolean setting to the [Target] section, which makes the importer continue writing zeroes up to the end of the partition once the payload has been written, by passing --zero-fill= to systemd-import and systemd-pull. It defaults to off, except for verity signature partitions (which are tiny, hence this is cheap) where it defaults to on. Fixes: #43567 (cherry picked from commit d3b6d765a5b320d6d864591c6a7b87e1047d0c78) --- man/sysupdate.d.xml | 22 ++++ src/sysupdate/sysupdate-transfer.c | 25 +++++ src/sysupdate/sysupdate-transfer.h | 1 + test/units/TEST-72-SYSUPDATE.sh | 160 +++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+) diff --git a/man/sysupdate.d.xml b/man/sysupdate.d.xml index 3572322682..af415ed7fa 100644 --- a/man/sysupdate.d.xml +++ b/man/sysupdate.d.xml @@ -795,6 +795,28 @@ + + PartitionZeroFill= + + Takes a boolean. When the target Type= is picked as + partition, controls whether the remainder of the partition is overwritten with + zeroes after the payload has been written into it. If disabled, only as much of the partition is + written as the payload is large, and whatever data was previously stored in the rest of the partition + remains in place. If enabled, everything from the end of the payload up to the end of the partition + is zeroed out, so that no remnants of an earlier, larger payload survive. On block devices this is + offloaded to the kernel and hence the storage device where possible. + + If not specified, defaults to on for Verity signature partitions (i.e. if + MatchPartitionType= selects one of the *-verity-sig types) and + off for all other partition types. Verity signature partitions carry a JSON object that must be + followed by zero bytes only, up to the end of the partition, while the artifacts installed into them + (for example as generated via the mode of + systemd-repart8) + generally do not carry this padding. + + + + ReadOnly= diff --git a/src/sysupdate/sysupdate-transfer.c b/src/sysupdate/sysupdate-transfer.c index 226dbd93ae..930e453737 100644 --- a/src/sysupdate/sysupdate-transfer.c +++ b/src/sysupdate/sysupdate-transfer.c @@ -104,6 +104,9 @@ Transfer* transfer_new(Context *ctx) { .read_only = -1, .growfs = -1, + /* whether to fill up the rest of the partition with zeroes, as configured by the user */ + .zero_fill = -1, + /* the read only flag, as ultimately determined */ .install_read_only = -1, @@ -564,6 +567,7 @@ int transfer_read_definition(Transfer *t, const char *path, const char **dirs, H { "Target", "PartitionFlags", config_parse_partition_flags, 0, t }, { "Target", "PartitionNoAuto", config_parse_tristate, 0, &t->no_auto }, { "Target", "PartitionGrowFileSystem", config_parse_tristate, 0, &t->growfs }, + { "Target", "PartitionZeroFill", config_parse_tristate, 0, &t->zero_fill }, { "Target", "ReadOnly", config_parse_tristate, 0, &t->read_only }, { "Target", "Mode", config_parse_mode, 0, &t->mode }, { "Target", "TriesLeft", config_parse_uint64, 0, &t->tries_left }, @@ -1326,6 +1330,25 @@ int transfer_compute_temporary_paths(Transfer *t, Instance *i, InstanceMetadata return 0; } +static bool transfer_zero_fill(const Transfer *t) { + assert(t); + assert(t->target.type == RESOURCE_PARTITION); + assert(t->target.partition_type_set); + + /* Returns whether to fill up the rest of the partition with zeroes after writing the payload. If + * configured explicitly, use that. Otherwise, do so for verity signature partitions only: they carry + * a JSON object that must be followed by NUL bytes only, up to the end of the partition. The + * artifacts we install there generally come without that padding though (repart's --split= mode + * trims it, so that the file is a plain JSON text file), hence without zero filling the tail of a + * previous, longer signature would survive right after the new one. These partitions are tiny, hence + * this is cheap. */ + + if (t->zero_fill >= 0) + return t->zero_fill; + + return partition_designator_is_verity_sig(t->target.partition_type.designator); +} + int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, TransferProgress cb, void *userdata) { _cleanup_free_ char *digest = NULL; char offset[DECIMAL_STR_MAX(uint64_t)+1], max_size[DECIMAL_STR_MAX(uint64_t)+1]; @@ -1449,6 +1472,7 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra "--direct", /* just copy/unpack the specified file, don't do anything else */ "--offset", offset, "--size-max", max_size, + transfer_zero_fill(t) ? "--zero-fill=yes" : "--zero-fill=no", t->context->sync ? "--sync=yes" : "--sync=no", i->path, t->target.path), @@ -1528,6 +1552,7 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra "--verify", digest, /* validate by explicit SHA256 sum */ "--offset", offset, "--size-max", max_size, + transfer_zero_fill(t) ? "--zero-fill=yes" : "--zero-fill=no", t->context->sync ? "--sync=yes" : "--sync=no", i->path, t->target.path), diff --git a/src/sysupdate/sysupdate-transfer.h b/src/sysupdate/sysupdate-transfer.h index 64af8c2fc3..958b02215f 100644 --- a/src/sysupdate/sysupdate-transfer.h +++ b/src/sysupdate/sysupdate-transfer.h @@ -37,6 +37,7 @@ typedef struct Transfer { int no_auto; int read_only; int growfs; + int zero_fill; /* If we create a new file/dir/subvol in the fs, the temporary and final path we create it under, as well as the read-only flag for it */ char *temporary_partial_path; diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index fc98dd8963..63c4136022 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -58,6 +58,7 @@ systemctl daemon-reload SIGTEST_GPGHOME= SIGTEST_OTHERHOME= +ZEROFILL_LOOP= at_exit() { set +e @@ -76,6 +77,9 @@ at_exit() { if [ "$SIGTEST_OTHERHOME" != "" ]; then gpgconf --homedir "$SIGTEST_OTHERHOME" --kill all 2>/dev/null fi + if [ "$ZEROFILL_LOOP" != "" ]; then + losetup --detach "$ZEROFILL_LOOP" + fi rm -rf "$WORKDIR" } @@ -1255,6 +1259,161 @@ rm -rf "$CONFIGDIR" "$WORKDIR/blobs" rm -f "$WORKDIR/source/notifytest-v1.bin" "$WORKDIR/source/SHA256SUMS" \ "$WORKDIR/notify-recorder.py" "$NOTIFY_LOG" +verify_zero_fill_partition() { + local blockdev="${1:?}" + local sigfile="${2:?}" + local version="${3:?}" + local expect_zeroed="${4:?}" + local start size dump tail + + start="$(sfdisk --json "$blockdev" | jq -r ".partitiontable.partitions[] | select(.name == \"sig-$version\") | .start")" + test -n "$start" + size="$(stat -c %s "$sigfile")" + dump="$WORKDIR/zerofill-dump" + + dd if="$blockdev" of="$dump" bs=512 skip="$start" count=64 status=none + + # The partition must start with the payload file in any case + cmp -n "$size" "$dump" "$sigfile" + + # Everything after it must be NUL bytes if zero filling is on, and must not be if it is off (the tail of + # the longer payload of the version previously stored in the partition then survives) + tail="$(tail -c +$((size + 1)) "$dump" | tr -d '\0' | wc -c)" + if [[ "$expect_zeroed" == "1" ]]; then + test "$tail" -eq 0 + else + test "$tail" -gt 0 + fi +} + +make_zero_fill_payload() { + local size="${1:?}" + local file="${2:?}" + + # Generates a JSON object resembling a verity signature of precisely the requested size: the fixed part of + # the object is 94 bytes, the signature string makes up the rest + test "$size" -gt 94 + printf '{"rootHash":"%s","signature":"%s"}' \ + "$(head -c 32 /dev/zero | od -An -v -tx1 | tr -d ' \n')" \ + "$(head -c $((size - 94)) /dev/zero | tr '\0' A)" >"$file" + test "$(stat -c %s "$file")" -eq "$size" +} + +test_zero_fill_partition() { + local source_type="${1:?}" # Source Type= to use (regular-file or url-file) + local ptype_uuid="${2:?}" # partition type UUID to create the partitions with + local ptype_name="${3:?}" # matching partition type identifier for MatchPartitionType= + local setting="${4}" # explicit PartitionZeroFill= line, or empty to test the default + local expect_zeroed="${5:?}" # whether the tail of the partition is expected to be zeroed out + local sigdir="$WORKDIR/zerofill-source" + local defdir="$WORKDIR/zerofill-defs" + local backing="$WORKDIR/zerofill-disk.raw" + local blockdev source_path + + rm -rf "$sigdir" "$defdir" "$backing" + mkdir -p "$sigdir" "$defdir" + truncate -s 4M "$backing" + + if [[ -e /dev/loop-control ]]; then + blockdev="$(losetup --find --show "$backing")" + ZEROFILL_LOOP="$blockdev" + else + blockdev="$backing" + fi + + sfdisk "$blockdev" <"$defdir/01-zerofill.transfer" <SHA256SUMS) + + "$SYSUPDATE" --verify=no --definitions="$defdir" update + verify_zero_fill_partition "$blockdev" "$sigdir/sig-$v.raw" "$v" "${zeroed[$v]}" + + # The version written in the previous step is in the other slot, and must be untouched, i.e. the zero + # filling must not have overshot the partition + if [[ -n "$prev" ]]; then + verify_zero_fill_partition "$blockdev" "$sigdir/sig-$prev.raw" "$prev" "${zeroed[$prev]}" + fi + prev="$v" + done + + # v3 and v4 must have replaced v1 and v2 + (! sfdisk --json "$blockdev" | jq -e '.partitiontable.partitions[] | select(.name == "sig-v1" or .name == "sig-v2")' >/dev/null) + + # And the partition table itself (including the backup GPT at the end of the device, right after the last + # slot) must be intact + sfdisk --verify "$blockdev" + + if [[ "$ZEROFILL_LOOP" != "" ]]; then + losetup --detach "$ZEROFILL_LOOP" + ZEROFILL_LOOP= + fi + + rm -rf "$sigdir" "$defdir" "$backing" +} + +test_zero_fill() { + # A verity signature partition carries a JSON object that must be followed by NUL bytes only. The split-out + # artifact is not padded (so that it's a valid JSON text file), hence sysupdate must make sure that no tail + # of a previously installed, longer signature survives when it installs a shorter one into a reused + # partition, see https://github.com/systemd/systemd/issues/43567. This is controlled by the + # PartitionZeroFill= setting, which defaults to on for verity signature partitions, and off otherwise. + local vsig_uuid=41092b05-9fc8-4523-994f-2def0408b176 # root-x86-64-verity-sig + local generic_uuid=0fc63daf-8483-4772-8e79-3d69d8477de4 # linux-generic + + # Default for verity signature partitions is on, both for local and URL sources + test_zero_fill_partition regular-file "$vsig_uuid" root-x86-64-verity-sig "" 1 + test_zero_fill_partition url-file "$vsig_uuid" root-x86-64-verity-sig "" 1 + + # ... and can be turned off explicitly + test_zero_fill_partition regular-file "$vsig_uuid" root-x86-64-verity-sig "PartitionZeroFill=no" 0 + + # Default for other partitions is off, but can be turned on explicitly + test_zero_fill_partition regular-file "$generic_uuid" linux-generic "" 0 + test_zero_fill_partition regular-file "$generic_uuid" linux-generic "PartitionZeroFill=yes" 1 + test_zero_fill_partition url-file "$generic_uuid" linux-generic "PartitionZeroFill=yes" 1 +} + test_signature_verification() { if ! command -v gpg >/dev/null; then echo "gpg not available, skipping signature verification test" @@ -1500,6 +1659,7 @@ EOF ) test_signature_verification +test_zero_fill test_signature_keyring_overlay # Test '**/' as prefix in MatchPattern= for subpaths in SHA256SUMS