Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 11 min read

Stop Assembling Boot Pieces by Hand: Practical Unified Kernel Images with ukify on Linux

Stop Assembling Boot Pieces by Hand: Practical Unified Kernel Images with ukify on Linux Most Linux boots still treat the kernel, initrd, command line, and splash as separate moving parts. That works β€” until you want o

Stop Assembling Boot Pieces by Hand: Practical Unified Kernel Images with ukify on Linux

Most Linux boots still treat the kernel, initrd, command line, and splash as separate moving parts. That works β€” until you want one signed PE binary the firmware or boot loader can trust as a whole.

A Unified Kernel Image (UKI) packs those pieces into a single UEFI PE/COFF application. ukify is the recommended builder: it wraps a kernel and initrd with systemd-stub, embeds metadata sections, can Secure Boot-sign the result, and can pre-calculate TPM2 PCR 11 policies via systemd-measure.

This guide is the builder-side companion to boot-manager work with bootctl. Here the focus is constructing Type #2 UKIs correctly, inspecting them, signing them, and wiring kernel-install so package updates keep producing them.

What a UKI actually is

Per the UAPI.5 Unified Kernel Image specification, a UKI is a PE/COFF UEFI application (IMAGE_SUBSYSTEM_EFI_APPLICATION) that embeds:

PE section Role
stub (.text/… ) systemd-stub UEFI entry point
.linux ELF kernel image (required)
.osrel os-release contents for menus/versioning
.cmdline Kernel command line
.initrd Initramfs
.ucode Microcode initrd (uncompressed; first)
.splash BMP splash
.dtb / .dtbauto DeviceTree (fixed or auto-matched)
.hwids Hardware ID table for auto DTB/firmware match
.uname uname -r string
.sbat Shim SBAT revocation metadata
.pcrsig Signed expected TPM2 PCR 11 values (JSON)
.pcrpkey PEM public key matching those signatures

systemd-stub loads the embedded sections, measures most of them into TPM PCR 11, optionally collects companion files next to the UKI, then boots the kernel. Because Secure Boot signs the PE as a whole, kernel + initrd + cmdline travel under one trust decision.

You can assemble this with objcopy. Don't. Section alignment, SBAT merges, Secure Boot signing, and PCR measurement are easy to get subtly wrong. Use ukify.

Install the builder

On Debian 13 / Ubuntu with systemd 257-era packages:

sudo apt update
sudo apt install systemd-ukify systemd-boot-efi sbsigntool

# Optional but useful
sudo apt install systemd-boot binutils  # bootctl + objdump helpers

systemd-ukify pulls in python3-pefile (and friends). The EFI stub lives under:

ls /usr/lib/systemd/boot/efi/
# linuxx64.efi.stub  linuxia32.efi.stub  linuxaa64.efi.stub  …

Confirm the CLI:

ukify --version
ukify --help | head

Verbs you will use:

  • ukify build β€” assemble a UKI (or addon)
  • ukify genkey β€” create Secure Boot + PCR key material from config
  • ukify inspect β€” dump sections, sizes, digests, text payloads

Lab: build a minimal unsigned UKI

Work in a throwaway directory. Paths below match a typical Debian layout; adjust KERNEL_VER to your running kernel.

KERNEL_VER="$(uname -r)"
WORKDIR="$HOME/uki-lab"
mkdir -p "$WORKDIR" && cd "$WORKDIR"

# Kernel image (Debian/Ubuntu usually ship vmlinuz under /boot as well)
LINUX="/lib/modules/${KERNEL_VER}/vmlinuz"
# Fallback if your distro only keeps it in /boot:
[[ -f "$LINUX" ]] || LINUX="/boot/vmlinuz-${KERNEL_VER}"

# Existing initrd from the package/initramfs tools
INITRD="/boot/initrd.img-${KERNEL_VER}"
# Some systems use initramfs-*.img β€” pick what exists:
[[ -f "$INITRD" ]] || INITRD="/boot/initramfs-${KERNEL_VER}.img"

test -f "$LINUX" && test -f "$INITRD"

ukify build \
  --linux="$LINUX" \
  --initrd="$INITRD" \
  --cmdline='quiet rw' \
  --os-release=@"/etc/os-release" \
  --uname="$KERNEL_VER" \
  --output="${WORKDIR}/minimal.unsigned.efi"

If you omit --output=, ukify names the file after the kernel with a .unsigned.efi or .signed.efi suffix depending on whether Secure Boot signing ran.

Inspect what you built

ukify inspect minimal.unsigned.efi

You should see well-known sections (at least .linux, and whatever you embedded). Dig into one section:

# Print .cmdline as text
ukify inspect minimal.unsigned.efi --section=.cmdline:text

# JSON summary (ukify β‰₯255)
ukify inspect minimal.unsigned.efi --json=pretty | head -n 80

# PE headers via llvm/binutils if installed
llvm-objdump -p minimal.unsigned.efi 2>/dev/null | head
# or:
objdump -p minimal.unsigned.efi | head

bootctl and kernel-install recognize UKIs partly via .osrel and PE layout β€” leaving .osrel empty is allowed by ukify but not recommended, because other tools may stop treating the artifact as a UKI.

Config file workflow (preferred for hosts)

Command-line flags are fine for labs. On real machines, put policy in a config file. ukify loads the first of:

  1. --config=PATH (explicit)
  2. /etc/systemd/ukify.conf
  3. /run/systemd/ukify.conf
  4. /usr/local/lib/systemd/ukify.conf
  5. /usr/lib/systemd/ukify.conf

kernel-install's ukify plugin path conventionally uses /etc/kernel/uki.conf. Example:

sudo tee /etc/kernel/uki.conf >/dev/null <<'EOF'
[UKI]
Cmdline=quiet rw
OSRelease=@/etc/os-release
# SecureBootPrivateKey=/etc/kernel/secure-boot-key.pem
# SecureBootCertificate=/etc/kernel/secure-boot-certificate.pem
# SignKernel=yes
# PCRBanks=sha256

# [PCRSignature:initrd]
# Phases=enter-initrd
# PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key-initrd.pem
# PCRPublicKey=/etc/systemd/tpm2-pcr-public-key-initrd.pem

# [PCRSignature:system]
# Phases=enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit enter-initrd:leave-initrd:sysinit:ready
# PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key-system.pem
# PCRPublicKey=/etc/systemd/tpm2-pcr-public-key-system.pem
EOF

Build with that policy while still passing the kernel/initrd on the CLI (they change every upgrade):

ukify build \
  --config=/etc/kernel/uki.conf \
  --linux="$LINUX" \
  --initrd="$INITRD" \
  --uname="$KERNEL_VER" \
  --output="${WORKDIR}/host-style.unsigned.efi"

--summary is excellent for debugging merge order (config vs CLI):

ukify --config=/etc/kernel/uki.conf --summary build \
  --linux="$LINUX" --initrd="$INITRD"

Secure Boot signing with ukify genkey

If the target machine verifies PE signatures (or you enroll your own keys with sbctl / firmware Setup Mode), sign the UKI as a whole.

1. Decide key paths in config

sudo tee /etc/kernel/uki.conf >/dev/null <<'EOF'
[UKI]
Cmdline=quiet rw
OSRelease=@/etc/os-release
SecureBootPrivateKey=/etc/kernel/secure-boot-key.pem
SecureBootCertificate=/etc/kernel/secure-boot-certificate.pem
SignKernel=yes
SecureBootCertificateValidity=3650
EOF

2. Generate keys once

# Files must not already exist
sudo ukify genkey --config=/etc/kernel/uki.conf
sudo chmod 600 /etc/kernel/secure-boot-key.pem
sudo chmod 644 /etc/kernel/secure-boot-certificate.pem

genkey writes whatever private/public material the config declares (Secure Boot cert/key and any PCR key pairs). Certificate validity defaults to 3650 days (10 years) unless you override SecureBootCertificateValidity=.

3. Build a signed UKI

ukify build \
  --config=/etc/kernel/uki.conf \
  --linux="$LINUX" \
  --initrd="$INITRD" \
  --uname="$KERNEL_VER" \
  --output="${WORKDIR}/minimal.signed.efi"

Signing tool selection (SecureBootSigningTool= / --signtool=):

  • sbsign (default) β€” needs sbsigntool
  • pesign β€” NSS cert DB (SecureBootCertificateDir=, SecureBootCertificateName=)
  • systemd-sbsign β€” supports OpenSSL providers

Boundary vs sbctl: sbctl owns platform key enrollment (PK/KEK/db) and re-signing hooks for arbitrary EFI binaries. ukify owns UKI assembly and can call a signtool for the resulting PE. Use both: enroll with sbctl, build/sign UKIs with ukify (or sign the ukify output with sbctl sign if that is your house style).

With Secure Boot enabled, if a .cmdline section is present, firmware/stub ignores attempt to override the command line at invocation time. That is intentional: the signed cmdline is part of the trusted image. Want local overrides? Omit .cmdline from the UKI, or ship signed addons (below).

PCR 11 measurement and signed policies

systemd-stub measures UKI sections into PCR 11 (the .pcrsig section itself is excluded so signatures are not circular). systemd-measure pre-calculates those values the same way the stub will, optionally signs them, and ukify embeds the JSON into .pcrsig.

Typical goals:

  • Unlock LUKS only for kernels you signed (systemd-cryptenroll TPM2 policies)
  • Unlock systemd-creds encrypted credentials only for known UKIs
  • Bind secrets to boot phases (initrd vs multi-user)

Default phase paths used by systemd-measure when you do not pass --phases= / Phases=:

  • enter-initrd
  • enter-initrd:leave-initrd
  • enter-initrd:leave-initrd:sysinit
  • enter-initrd:leave-initrd:sysinit:ready

Example: separate keys for initrd-only secrets vs runtime secrets (from the ukify manual’s β€œbells and whistles” pattern):

ukify build \
  --linux="$LINUX" \
  --initrd="$INITRD" \
  --cmdline='quiet rw' \
  --pcr-private-key=tpm2-pcr-private-key-initrd.pem \
  --pcr-public-key=tpm2-pcr-public-key-initrd.pem \
  --phases='enter-initrd' \
  --pcr-private-key=tpm2-pcr-private-key-system.pem \
  --pcr-public-key=tpm2-pcr-public-key-system.pem \
  --phases='enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit enter-initrd:leave-initrd:sysinit:ready' \
  --pcr-banks=sha256 \
  --output="${WORKDIR}/measured.efi"

On the command line, each --pcr-private-key= pairs with the matching --phases= in order. In config files, group them under [PCRSignature:NAME] sections instead.

Useful related flags:

# Print calculated PCR values while building
ukify build ... --measure --output=out.efi

# Compare expectation vs the live TPM after boot (on systems with a TPM)
sudo /usr/lib/systemd/systemd-measure status

After boot, stub-provided signature material is commonly exposed under /run/systemd/tpm2-pcr-signature.json and /run/systemd/tpm2-pcr-public-key.pem (via a synthetic initrd + tmpfiles). systemd-cryptsetup, systemd-cryptenroll, and systemd-creds look there automatically.

Note: systemd-measure is still marked experimental in its man page; prefer going through ukify rather than hand-rolling JSON into PE sections.

Microcode and multiple initrds

Initrd= / --initrd= may be repeated. ukify concatenates them into one .initrd section in order. Microcode can also use the dedicated .ucode section (Microcode= / --microcode=), which the stub hands to the kernel before other initrds and which must be uncompressed.

ukify build \
  --linux="$LINUX" \
  --microcode=/boot/intel-ucode.img \
  --initrd="$INITRD" \
  --cmdline='quiet rw' \
  --output="${WORKDIR}/with-ucode.efi"

Or early+late initrds without .ucode:

ukify build \
  --linux="$LINUX" \
  --initrd=/boot/early_cpio \
  --initrd="$INITRD" \
  --cmdline='quiet rw' \
  --output="${WORKDIR}/concat-initrd.efi"

Command-line PE addons

Sometimes you want a signed extra cmdline (or other UKI-like auxiliary PE) without rebuilding the base UKI. systemd-stub loads *.addon.efi companions from:

  • ESP/.../foo.efi.extra.d/*.addon.efi next to foo.efi
  • ESP/loader/addons/*.addon.efi globally

Build one:

ukify build \
  --cmdline='debug systemd.log_level=debug' \
  --sbat='sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
uki-addon.example,1,Example addon,uki-addon.example,1,https://example.local' \
  --output="${WORKDIR}/debug.addon.efi"

# Optionally sign with the same Secure Boot key as the UKI:
#   --secureboot-private-key=... --secureboot-certificate=...

Addons are PE binaries that are not full bootable UKIs; they carry auxiliary sections the stub merges at boot. Sign them if Secure Boot is on, or the stub will ignore untrusted companions on locked platforms.

Multi-profile UKIs (quick tour)

Since systemd 257, one PE can carry multiple profiles separated by .profile sections. Build profile fragments, then join them:

ukify build --profile=$'TITLE=Base\nID=base' --output=profile0.efi

ukify build \
  --profile=$'TITLE=Storage Target Mode\nID=storagetm' \
  --cmdline='quiet rw rd.systemd.unit=storage-target-mode.target' \
  --output=profile1.efi

ukify build \
  --linux="$LINUX" \
  --initrd="$INITRD" \
  --cmdline='quiet rw' \
  --join-profile=profile0.efi \
  --join-profile=profile1.efi \
  --output="${WORKDIR}/multi-profile.efi"

Boot loaders that understand multi-profile UKIs can present each TITLE= / ID= as a selectable entry without storing multiple full kernels. PCR measurement covers the selected profile (plus base sections not overridden).

Install with kernel-install layout=uki

Manual cp to the ESP works for experiments. For upgrades, teach kernel-install to install Type #2 UKIs under $BOOT/EFI/Linux/.

Inspect current detection:

sudo kernel-install inspect
# Look for KERNEL_INSTALL_LAYOUT, BOOT, ENTRY_TOKEN, machine-id, etc.

Configure layout (and optional generators) in /etc/kernel/install.conf:

sudo tee /etc/kernel/install.conf >/dev/null <<'EOF'
layout=uki
# uki_generator=ukify
# initrd_generator=dracut   # or mkinitcpio / the distro default
EOF

What the stock plugins do:

Plugin layout Behavior on add
90-loaderentry.install bls Type #1 entry under $BOOT/loader/entries/ + linux/initrd files
90-uki-copy.install uki Copies staged uki.efi (or a .efi kernel argument) to $BOOT/EFI/Linux/ENTRY-TOKEN-KERNEL-VERSION.efi

With layout=uki, after your UKI generator stages $KERNEL_INSTALL_STAGING_AREA/uki.efi, 90-uki-copy.install places:

$BOOT/EFI/Linux/<entry-token>-<kernel-version>.efi

$BOOT is discovered as the first among /efi/, /boot/, /boot/efi/ that already looks like a BLS tree. Prefer mounting the ESP on /efi when you can.

Install one kernel version explicitly:

# After building/staging a UKI for this version β€” distro plugins vary
sudo kernel-install add "$KERNEL_VER" "$LINUX" "$INITRD"

# Or list / remove
kernel-install list
sudo kernel-install remove "$KERNEL_VER"

bootctl list should then show the Type #2 entry. Selecting it boots the PE directly; no separate initrd path in a Type #1 conf file.

Practical packaging note: On Debian, systemd-ukify provides the builder; your image/initrd generator plugin must actually call ukify and leave uki.efi in the staging area. If uki_generator=ukify is not wired on your distro version, keep a small /etc/kernel/install.d/60-ukify.install that builds into "$KERNEL_INSTALL_STAGING_AREA/uki.efi" using /etc/kernel/uki.conf, then let 90-uki-copy.install finish the job. Return 0 on success; return 77 only if you intend to abort the entire kernel-install run.

End-to-end checklist

  1. Install systemd-ukify, EFI stub package, and a signtool if you need Secure Boot PE signatures.
  2. Write /etc/kernel/uki.conf with cmdline, os-release, and optional SB/PCR keys.
  3. ukify genkey --config=... once; lock down private key modes (0600).
  4. Build a lab UKI; ukify inspect sections and digests.
  5. Enroll the Secure Boot certificate into the platform db (via sbctl or firmware) if verification is enabled.
  6. Set layout=uki in /etc/kernel/install.conf and verify with kernel-install inspect.
  7. Reinstall a kernel package or run kernel-install add and confirm $BOOT/EFI/Linux/*.efi.
  8. bootctl list / reboot into the UKI; on TPM hosts compare systemd-measure status with build-time --measure output.
  9. Only then enroll LUKS/creds policies against the PCR public key.

Operational pitfalls

  • Missing stub package: ukify needs linuxx64.efi.stub (or arch equivalent) from systemd-boot-efi / distro equivalent.
  • Wrong initrd path after upgrade: always take initrd from the same kernel version you embed; UKIs freeze that pairing on purpose.
  • Secure Boot + embedded cmdline: local BootNext cmdline overrides are ignored; use addons or rebuild.
  • PCR bank mismatch: if the OS policy disables SHA-1 signatures, restrict PCRBanks= / --pcr-banks= to sha256 (and whatever your TPM actually supports).
  • Empty .osrel: other tools may not classify the PE as a UKI.
  • Signing keys on the build host: treat PCR and Secure Boot private keys like production CA material; split initrd vs runtime PCR keys if unlock policies differ by phase.
  • Companion files: .cred / .sysext.raw / .confext.raw next to the UKI are measured into PCR 12 and only accepted when authentic under Secure Boot policy β€” do not treat the ESP drop-in folder as a free-form unsigned config dump on locked systems.

What this is not

Topic Covered elsewhere / out of scope
bootctl install, loader.conf, BLS entry lifecycle Boot manager ops (Type #1 vs consuming Type #2)
sbctl PK/KEK/db enrollment Platform Secure Boot key ownership
systemd-sysupdate A/B image pulls Shipping whole OS slots that may include UKIs
systemd-cryptenroll TPM2 unlock UX Consuming .pcrsig after the UKI exists
GRUB linux/initrd snippets Non-UKI boot path
Distro-custom signed kernel packages Vendor-signed vmlinuz without a UKI wrapper

Wrap-up

UKIs turn β€œkernel + initrd + cmdline + metadata” into one PE you can sign, measure, and install like any other EFI binary. ukify is the practical assembly line: build for images, genkey for key material, inspect for verification, with systemd-measure handling PCR 11 policy blobs and kernel-install layout=uki dropping results into $BOOT/EFI/Linux/.

Start unsigned in a lab directory, inspect every section, then add Secure Boot signing and PCR signatures once the shape is right. After that, every kernel upgrade can produce the same artifact shape instead of another pile of loosely coupled boot files.

Sources and references

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.