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)
This commit is contained in:
Lennart Poettering
2026-10-16 17:24:51 +01:00
committed by Luca Boccassi
parent f9cfc019f9
commit 2c4e3b9a6b
4 changed files with 208 additions and 0 deletions
+22
View File
@@ -795,6 +795,28 @@
<xi:include href="version-info.xml" xpointer="v251"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>PartitionZeroFill=</varname></term>
<listitem><para>Takes a boolean. When the target <varname>Type=</varname> is picked as
<constant>partition</constant>, 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.</para>
<para>If not specified, defaults to on for Verity signature partitions (i.e. if
<varname>MatchPartitionType=</varname> selects one of the <literal>*-verity-sig</literal> 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 <option>--split=</option> mode of
<citerefentry><refentrytitle>systemd-repart</refentrytitle><manvolnum>8</manvolnum></citerefentry>)
generally do not carry this padding.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>ReadOnly=</varname></term>
+25
View File
@@ -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),
+1
View File
@@ -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;
+160
View File
@@ -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" <<EOF
label: gpt
unit: sectors
size=64, type=$ptype_uuid, name=_empty
size=64, type=$ptype_uuid, name=_empty
EOF
if [[ "$source_type" == "url-file" ]]; then
source_path="file://$sigdir"
else
source_path="$sigdir"
fi
cat >"$defdir/01-zerofill.transfer" <<EOF
[Source]
Type=$source_type
Path=$source_path
MatchPattern=sig-@v.raw
[Target]
Type=partition
Path=$blockdev
MatchPattern=sig-@v
MatchPartitionType=$ptype_name
$setting
EOF
# The partition slots are 64 sectors (32768 bytes) each. Payload sizes and the tail state to expect in the
# partition after each version, given that with two slots each version replaces the one two steps before:
#
# v1: 3000 bytes into an empty slot, hence nothing can survive after it
# v2: exactly fills the slot, nothing is left to zero out at all
# v3: 130 bytes over the 3000 of v1, the tail of v1 survives unless zero filling is on
# v4: leaves less than a sector free after it over the full slot of v2, i.e. the remainder is too small
# for BLKZEROOUT, and the tail of v2 survives unless zero filling is on
local -a versions=(v1 v2 v3 v4)
local -A payload_size=([v1]=3000 [v2]=32768 [v3]=130 [v4]=32668)
local -A zeroed=([v1]=1 [v2]=1 [v3]="$expect_zeroed" [v4]="$expect_zeroed")
local v prev=
for v in "${versions[@]}"; do
make_zero_fill_payload "${payload_size[$v]}" "$sigdir/sig-$v.raw"
(cd "$sigdir" && sha256sum sig-*.raw >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