diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4b38e24..a5ea494a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- guest OS: disable automatic GPT partition discovery and host-supplied system + credentials on the measured kernel command line. Remove production systemd + generators that act on host-controlled, unmeasured input; pin the allowed + generator set and assert the hardening configuration during builds. Includes + the unreleased 0.6.2 fixes for GHSA-c79w-3x5w-gmm5. - guest-agent: gate every external API route by scopes granted through signed Ed25519 JWTs or `[cvm.guest_agent_api].anonymous_scopes`. No scopes are anonymous by default; app SDKs keep using the internal Unix socket. The diff --git a/os/image/kernel-cmdline.sh b/os/image/kernel-cmdline.sh index c900899be..bedbf2722 100644 --- a/os/image/kernel-cmdline.sh +++ b/os/image/kernel-cmdline.sh @@ -30,6 +30,20 @@ # Intel's confidential-computing guest hardening names early PCI code as # something to disable, so it stays off. +# `systemd.gpt_auto=0` disables systemd-gpt-auto-generator, which reads the root +# disk's GPT and mounts or activates partitions found there. That table is +# host-controlled and unmeasured, so without this a partition the host appends +# (swap, /var, /home, ...) is acted on inside the guest. The rootfs is located +# by PARTLABEL and verified by dm-verity in the initramfs, so nothing we rely on +# depends on the generator. Every systemd since v252 parses the flag the same +# way, unlike `systemd.image_policy=` which older versions ignore and v254/v255 +# mishandle. +# +# `systemd.import_credentials=no` stops PID1 importing system credentials from +# SMBIOS and qemu fw_cfg, both host-controlled and unmeasured. Many units +# consume such credentials (sysusers, tmpfiles, udev rules, networkd, ...), so +# this closes that injection path at the source instead of masking each consumer. + # Emit the guest kernel command line. # $1 dm-verity root hash # $2 rootfs data size in bytes @@ -39,5 +53,6 @@ dstack_kernel_cmdline() { echo "console=ttyS0 init=/init panic=1 net.ifnames=0 biosdevname=0" \ "mce=off oops=panic pci=noearly random.trust_cpu=y" \ "random.trust_bootloader=n tsc=reliable no-kvmclock" \ + "systemd.gpt_auto=0 systemd.import_credentials=no" \ "dstack.rootfs_hash=$root_hash dstack.rootfs_size=$data_size" } diff --git a/os/mkosi/components/kernel/kernel.config b/os/mkosi/components/kernel/kernel.config index 0091f6ac4..9dcb41655 100644 --- a/os/mkosi/components/kernel/kernel.config +++ b/os/mkosi/components/kernel/kernel.config @@ -283,6 +283,13 @@ CONFIG_SUSPEND=n CONFIG_PM=n CONFIG_PM_SLEEP=n +# SMBIOS tables and qemu fw_cfg blobs are host-controlled and unmeasured; these +# expose them to userspace, where systemd imports them as system credentials. +# pstore restores records the firmware hands back from a previous boot. +CONFIG_DMI_SYSFS=n +CONFIG_FW_CFG_SYSFS=n +CONFIG_PSTORE=n + # Yocto dstack fragments and minimal guest-only hardware policy. CONFIG_9P_FS_POSIX_ACL=y CONFIG_SERIAL_8250_PCI=y diff --git a/os/mkosi/mkosi.profiles/prod/mkosi.conf b/os/mkosi/mkosi.profiles/prod/mkosi.conf index a571f6a71..799cf3e5b 100644 --- a/os/mkosi/mkosi.profiles/prod/mkosi.conf +++ b/os/mkosi/mkosi.profiles/prod/mkosi.conf @@ -46,6 +46,33 @@ RemoveFiles= /usr/lib/systemd/system/systemd-logind.service /usr/lib/systemd/system/multi-user.target.wants/systemd-logind.service /usr/lib/systemd/logind.conf.d + # Generators that act on host-controlled, unmeasured input or are + # simply unreachable in this image. systemd.gpt_auto=0 on the measured + # command line already disables gpt-auto; removing these binaries is + # defence in depth and mirrors meta-dstack's systemd_%.bbappend, whose + # deletion of gpt-auto the mkosi rewrite had dropped. + # + # - gpt-auto: mounts/activates partitions from the host-controlled GPT. + # - hibernate-resume: reads resume= and the EFI HibernateLocation; the + # kernel drops CONFIG_PM so hibernation cannot happen anyway. + # - bless-boot / system-update: boot-assessment and offline-update + # handshakes this image never performs. + # - sysv / rc-local: every shipped SysV script has a native unit, and + # there is no /etc/rc.local. + # - ssh: binds sshd to AF_VSOCK, but prod ships no sshd. + # + # parity.json pins the generators prod may keep. + /usr/lib/systemd/system-generators/systemd-gpt-auto-generator + /usr/lib/systemd/system-generators/systemd-hibernate-resume-generator + /usr/lib/systemd/systemd-hibernate-resume + /usr/lib/systemd/system/systemd-hibernate-resume.service + /usr/lib/systemd/system/systemd-hibernate-clear.service + /usr/lib/systemd/system/sysinit.target.wants/systemd-hibernate-clear.service + /usr/lib/systemd/system-generators/systemd-bless-boot-generator + /usr/lib/systemd/system-generators/systemd-system-update-generator + /usr/lib/systemd/system-generators/systemd-sysv-generator + /usr/lib/systemd/system-generators/systemd-rc-local-generator + /usr/lib/systemd/system-generators/systemd-ssh-generator # Suspend/hibernate units: the kernel drops CONFIG_PM entirely, so # these can only produce failed jobs. /usr/lib/systemd/system/suspend.target diff --git a/os/mkosi/parity.json b/os/mkosi/parity.json index 0d367f942..b3095dfde 100644 --- a/os/mkosi/parity.json +++ b/os/mkosi/parity.json @@ -174,12 +174,18 @@ "usr/bin/login", "usr/bin/loginctl", "usr/sbin/agetty", - "usr/lib/systemd/system-generators/systemd-getty-generator", "usr/lib/systemd/system/getty@.service", "usr/lib/systemd/system/serial-getty@.service", "usr/lib/systemd/system/debug-shell.service", "usr/lib/systemd/system/systemd-logind.service" ], + "prod_allowed_generators": [ + "systemd-debug-generator", + "systemd-fstab-generator", + "systemd-run-generator", + "systemd-tpm2-generator", + "zfs-mount-generator" + ], "required_ldconfig_entries": [ "libnvidia-ml.so.1", "libnvidia-container.so.1", diff --git a/os/mkosi/tests/acceptance.sh b/os/mkosi/tests/acceptance.sh index 0e09651a3..b96bb03c2 100755 --- a/os/mkosi/tests/acceptance.sh +++ b/os/mkosi/tests/acceptance.sh @@ -258,6 +258,16 @@ if grep -q 'random.trust_bootloader' "$D/scripts/make-release-artifacts.sh"; the echo 'kernel command line must not be restated outside kernel-cmdline.sh' >&2 exit 1 fi +# These keep the guest from acting on the host-controlled GPT and on host-supplied +# system credentials; they only protect anything while they stay measured. +# shellcheck source=../../image/kernel-cmdline.sh +cmdline=$(. "$D/../image/kernel-cmdline.sh" && dstack_kernel_cmdline hash size) +for flag in systemd.gpt_auto=0 systemd.import_credentials=no; do + if [[ " $cmdline " != *" $flag "* ]]; then + echo "kernel command line lacks $flag" >&2 + exit 1 + fi +done grep -q 'Archiving UKI image' "$D/../image/assemble.sh" grep -Fq "FLAVORS=\${FLAVORS:-prod}" "$D/build.sh" # A cache hit must never be able to answer the question repro-check asks. diff --git a/os/mkosi/tests/check-parity.py b/os/mkosi/tests/check-parity.py index ebfaab983..6c324ed26 100755 --- a/os/mkosi/tests/check-parity.py +++ b/os/mkosi/tests/check-parity.py @@ -75,6 +75,23 @@ def resolves_in_rootfs(root: str, path: str) -> bool: for path in spec.get("prod_forbidden_paths", []): if os.path.lexists(os.path.join(rootfs, path)): missing.append(f"prod contains forbidden path:/{path}") + # Generators run as root before any unit and act on whatever they find, so + # prod pins the exact set: a package upgrade that ships a new one fails the + # build instead of silently widening what the host can influence. + generators = { + os.path.basename(path) + for pattern in ( + "usr/lib/systemd/system-generators/*", + "usr/local/lib/systemd/system-generators/*", + "etc/systemd/system-generators/*", + ) + for path in glob.glob(os.path.join(rootfs, pattern)) + } + allowed = set(spec["prod_allowed_generators"]) + for name in sorted(generators - allowed): + missing.append(f"prod contains unexpected generator: {name}") + for name in sorted(allowed - generators): + missing.append(f"prod is missing allowed generator: {name}") for path, expected in spec.get("required_symlink_resolutions", {}).items(): # Which netfilter frontend the image programs is decided by a symlink, and # on this backend that symlink comes from the distribution's default rather