stub: add fat variant

A compile time feature is introduced that allows to build "fat" stubs
that can be used to build "fat" UKIs. "fat" here means that the actual
kernel and initrd are embedded in the PE binary, not only the file path
and hash. This brings us one step closer to feature partiy with
systemd-stub and thus one step closer to replacing it fully. Such a
"fat" or "real" UKI is also interesting for image-based deployments of
NixOS.
This commit is contained in:
nikstur 2023-05-22 23:28:59 +02:00
parent f9681e3e23
commit 7ecafb2947
9 changed files with 488 additions and 230 deletions

View File

@ -87,6 +87,13 @@ on the ESP. The chain of trust is maintained by validating the
signature on the Linux kernel and embedding a cryptographic hash of
the initrd into the signed UKI.
The stub is available in a "thin" and a "fat" variant. The "thin" variant is
the one described above and is tailor made for NixOS. The "fat" variant aims to
work exactly like the `systemd-stub`---in fact, it's supposed to eventually
replace it. The "thin" variant is the default, you can build it from the stub
directory with `cargo build`. The "fat" variant needs to be enabled at build
time with `cargo build --no-default-features --features fat`.
The stub lives in `rust/stub`.
### Fwupd

View File

@ -59,6 +59,17 @@
}
);
flake.nixosModules.uki = moduleWithSystem (
perSystem@{ config }:
{ lib, ... }: {
imports = [
./nix/modules/uki.nix
];
boot.loader.uki.stub = lib.mkDefault "${perSystem.config.packages.fatStub}/bin/lanzaboote_stub.efi";
}
);
systems = [
"x86_64-linux"
@ -81,7 +92,7 @@
craneLib = crane.lib.x86_64-linux.overrideToolchain uefi-rust-stable;
# Build attributes for a Rust application.
buildRustApp =
buildRustApp = lib.makeOverridable (
{ src
, target ? null
, doCheck ? true
@ -126,7 +137,8 @@
});
rustfmt = craneLib.cargoFmt (commonArgs // { inherit cargoArtifacts; });
};
}
);
stubCrane = buildRustApp {
src = craneLib.cleanCargoSource ./rust/stub;
@ -134,7 +146,14 @@
doCheck = false;
};
fatStubCrane = stubCrane.override {
extraArgs = {
cargoExtraArgs = "--no-default-features --features fat";
};
};
stub = stubCrane.package;
fatStub = fatStubCrane.package;
toolCrane = buildRustApp {
src = ./rust/tool;
@ -164,7 +183,7 @@
in
{
packages = {
inherit stub;
inherit stub fatStub;
tool = wrappedTool;
lzbt = wrappedTool;
};
@ -173,14 +192,26 @@
inherit (config.packages) tool;
};
checks = {
checks =
let
nixosLib = import (pkgs.path + "/nixos/lib") { };
runTest = module: nixosLib.runTest {
imports = [ module ];
hostPkgs = pkgs;
};
in
{
toolClippy = toolCrane.clippy;
stubClippy = stubCrane.clippy;
fatStubClippy = fatStubCrane.clippy;
toolFmt = toolCrane.rustfmt;
stubFmt = stubCrane.rustfmt;
} // (import ./nix/tests/lanzaboote.nix {
inherit pkgs;
lanzabooteModule = self.nixosModules.lanzaboote;
}) // (import ./nix/tests/stub.nix {
inherit pkgs runTest;
ukiModule = self.nixosModules.uki;
});
pre-commit = {
@ -193,8 +224,16 @@
};
devShells.default = pkgs.mkShell {
shellHook = ''
shellHook =
let
systemdUkify = pkgs.systemdMinimal.override {
withEfi = true;
withUkify = true;
};
in
''
${config.pre-commit.installationScript}
export PATH=$PATH:${systemdUkify}/lib/systemd
'';
packages =

61
nix/modules/uki.nix Normal file
View File

@ -0,0 +1,61 @@
# This module introduces a simple boot loader installer that installs a UKI,
# leveraging bootspec. It is only designed to be useful in tests where
# rebuilding is unlikely/hard.
{ config, lib, pkgs, ... }:
let
cfg = config.boot.loader.uki;
in
{
options.boot.loader.uki = {
enable = lib.mkEnableOption "UKI";
stub = lib.mkOption {
type = lib.types.path;
description = "Path to the UKI stub to use.";
};
};
config =
let
systemdUkify = pkgs.systemdMinimal.override {
withEfi = true;
withUkify = true;
};
in
lib.mkIf cfg.enable {
boot.bootspec.enable = true;
boot.loader.external = {
enable = true;
installHook =
let
bootspecNamespace = ''"org.nixos.bootspec.v1"'';
installer = pkgs.writeShellApplication {
name = "install-uki";
runtimeInputs = with pkgs; [ jq systemd binutils ];
text = ''
boot_json=/nix/var/nix/profiles/system-1-link/boot.json
kernel=$(jq -r '.${bootspecNamespace}.kernel' "$boot_json")
initrd=$(jq -r '.${bootspecNamespace}.initrd' "$boot_json")
init=$(jq -r '.${bootspecNamespace}.init' "$boot_json")
${systemdUkify}/lib/systemd/ukify \
"$kernel" \
"$initrd" \
--stub=${cfg.stub} \
--cmdline="init=$init ${builtins.toString config.boot.kernelParams}" \
--os-release="@${config.system.build.etc}/etc/os-release" \
--output=uki.efi
esp=${config.boot.loader.efi.efiSysMountPoint}
bootctl install --esp-path="$esp"
install uki.efi "$esp"/EFI/Linux/
'';
};
in
"${installer}/bin/install-uki";
};
};
}

44
nix/tests/stub.nix Normal file
View File

@ -0,0 +1,44 @@
{ pkgs, runTest, ukiModule }:
let
common = _: {
imports = [ ukiModule ];
virtualisation = {
useBootLoader = true;
useEFIBoot = true;
};
boot.loader.uki.enable = true;
boot.loader.efi = {
canTouchEfiVariables = true;
};
};
in
{
# This test serves as a baseline to make sure that the custom boot installer
# script defined in the ukiModule works with the upstream systemd-stub. When
# this test fails something is very wrong.
systemd-stub = runTest {
name = "systemd-stub";
nodes.machine = _: {
imports = [ common ];
boot.loader.uki.stub = "${pkgs.systemd}/lib/systemd/boot/efi/linuxx64.efi.stub";
};
testScript = ''
machine.start()
print(machine.succeed("bootctl status"))
'';
};
fatStub = runTest {
name = "fat-stub";
nodes.machine = _: {
imports = [ common ];
};
testScript = ''
machine.start()
print(machine.succeed("bootctl status"))
'';
};
}

View File

@ -16,10 +16,15 @@ bitflags = "2.2.1"
log = { version = "0.4.17", default-features = false, features = [ "max_level_info", "release_max_level_warn" ]}
# Use software implementation because the UEFI target seems to need it.
sha2 = { version = "0.10.6", default-features = false, features = ["force-soft"] }
sha2 = { version = "0.10.6", default-features = false, features = ["force-soft"], optional = true }
# SHA1 for TPM TCG interface version 1.
sha1_smol = "1.0.0"
[features]
default = [ "thin" ]
thin = ["dep:sha2"]
fat = []
[profile.release]
opt-level = "s"
lto = true

35
rust/stub/src/common.rs Normal file
View File

@ -0,0 +1,35 @@
use alloc::vec::Vec;
use uefi::{prelude::*, CStr16, CString16, Result};
use crate::linux_loader::InitrdLoader;
use crate::pe_loader::Image;
use crate::pe_section::pe_section_as_string;
/// Extract a string, stored as UTF-8, from a PE section.
pub fn extract_string(pe_data: &[u8], section: &str) -> Result<CString16> {
let string = pe_section_as_string(pe_data, section).ok_or(Status::INVALID_PARAMETER)?;
Ok(CString16::try_from(string.as_str()).map_err(|_| Status::INVALID_PARAMETER)?)
}
/// Boot the Linux kernel without checking the PE signature.
///
/// We assume that the caller has made sure that the image is safe to
/// be loaded using other means.
pub fn boot_linux_unchecked(
handle: Handle,
system_table: SystemTable<Boot>,
kernel_data: Vec<u8>,
kernel_cmdline: &CStr16,
initrd_data: Vec<u8>,
) -> uefi::Result<()> {
let kernel =
Image::load(system_table.boot_services(), &kernel_data).expect("Failed to load the kernel");
let mut initrd_loader = InitrdLoader::new(system_table.boot_services(), handle, initrd_data)?;
let status = unsafe { kernel.start(handle, &system_table, kernel_cmdline) };
initrd_loader.uninstall(system_table.boot_services())?;
status.to_result()
}

67
rust/stub/src/fat.rs Normal file
View File

@ -0,0 +1,67 @@
use alloc::vec::Vec;
use uefi::{prelude::*, CString16, Result};
use crate::common::{boot_linux_unchecked, extract_string};
use crate::pe_section::pe_section;
use crate::uefi_helpers::booted_image_file;
/// Extract bytes from a PE section.
pub fn extract_bytes(pe_data: &[u8], section: &str) -> Result<Vec<u8>> {
let bytes: Vec<u8> = pe_section(pe_data, section)
.ok_or(Status::INVALID_PARAMETER)?
.try_into()
.map_err(|_| Status::INVALID_PARAMETER)?;
Ok(bytes)
}
/// The configuration that is embedded at build time.
///
/// After this stub is built, configuration need to be embedded into the binary by adding PE
/// sections. This struct represents that information.
struct EmbeddedConfiguration {
/// The kernel command-line.
cmdline: CString16,
/// The kernel as raw bytes.
kernel: Vec<u8>,
/// The initrd as raw bytes.
initrd: Vec<u8>,
}
impl EmbeddedConfiguration {
fn new(file_data: &[u8]) -> Result<Self> {
Ok(Self {
kernel: extract_bytes(file_data, ".linux")?,
initrd: extract_bytes(file_data, ".initrd")?,
cmdline: extract_string(file_data, ".cmdline")?,
})
}
}
pub fn boot_linux(handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
uefi_services::init(&mut system_table).unwrap();
// SAFETY: We get a slice that represents our currently running
// image and then parse the PE data structures from it. This is
// safe, because we don't touch any data in the data sections that
// might conceivably change while we look at the slice.
let config = unsafe {
EmbeddedConfiguration::new(
booted_image_file(system_table.boot_services())
.unwrap()
.as_slice(),
)
.expect("Failed to extract configuration from binary.")
};
boot_linux_unchecked(
handle,
system_table,
config.kernel,
&config.cmdline,
config.initrd,
)
.status()
}

View File

@ -4,6 +4,7 @@
extern crate alloc;
mod common;
mod efivars;
mod linux_loader;
mod measure;
@ -13,19 +14,19 @@ mod tpm;
mod uefi_helpers;
mod unified_sections;
use alloc::vec::Vec;
#[cfg(feature = "fat")]
mod fat;
#[cfg(feature = "thin")]
mod thin;
use efivars::{export_efi_variables, get_loader_features, EfiLoaderFeatures};
use log::{info, warn};
use log::info;
use measure::measure_image;
use pe_loader::Image;
use pe_section::{pe_section, pe_section_as_string};
use sha2::{Digest, Sha256};
use tpm::tpm_available;
use uefi::{prelude::*, proto::loaded_image::LoadedImage, CStr16, CString16, Result};
use uefi::prelude::*;
use crate::{linux_loader::InitrdLoader, uefi_helpers::booted_image_file};
type Hash = sha2::digest::Output<Sha256>;
use crate::uefi_helpers::booted_image_file;
/// Print the startup logo on boot.
fn print_logo() {
@ -42,177 +43,12 @@ fn print_logo() {
);
}
/// The configuration that is embedded at build time.
///
/// After lanzaboote is built, lzbt needs to embed configuration
/// into the binary. This struct represents that information.
struct EmbeddedConfiguration {
/// The filename of the kernel to be booted. This filename is
/// relative to the root of the volume that contains the
/// lanzaboote binary.
kernel_filename: CString16,
/// The cryptographic hash of the kernel.
kernel_hash: Hash,
/// The filename of the initrd to be passed to the kernel. See
/// `kernel_filename` for how to interpret these filenames.
initrd_filename: CString16,
/// The cryptographic hash of the initrd. This hash is computed
/// over the whole PE binary, not only the embedded initrd.
initrd_hash: Hash,
/// The kernel command-line.
cmdline: CString16,
}
/// Extract a string, stored as UTF-8, from a PE section.
fn extract_string(pe_data: &[u8], section: &str) -> Result<CString16> {
let string = pe_section_as_string(pe_data, section).ok_or(Status::INVALID_PARAMETER)?;
Ok(CString16::try_from(string.as_str()).map_err(|_| Status::INVALID_PARAMETER)?)
}
/// Extract a Blake3 hash from a PE section.
fn extract_hash(pe_data: &[u8], section: &str) -> Result<Hash> {
let array: [u8; 32] = pe_section(pe_data, section)
.ok_or(Status::INVALID_PARAMETER)?
.try_into()
.map_err(|_| Status::INVALID_PARAMETER)?;
Ok(array.into())
}
impl EmbeddedConfiguration {
fn new(file_data: &[u8]) -> Result<Self> {
Ok(Self {
kernel_filename: extract_string(file_data, ".kernelp")?,
kernel_hash: extract_hash(file_data, ".kernelh")?,
initrd_filename: extract_string(file_data, ".initrdp")?,
initrd_hash: extract_hash(file_data, ".initrdh")?,
cmdline: extract_string(file_data, ".cmdline")?,
})
}
}
/// Boot the Linux kernel without checking the PE signature.
///
/// We assume that the caller has made sure that the image is safe to
/// be loaded using other means.
fn boot_linux_unchecked(
handle: Handle,
system_table: SystemTable<Boot>,
kernel_data: Vec<u8>,
kernel_cmdline: &CStr16,
initrd_data: Vec<u8>,
) -> uefi::Result<()> {
let kernel =
Image::load(system_table.boot_services(), &kernel_data).expect("Failed to load the kernel");
let mut initrd_loader = InitrdLoader::new(system_table.boot_services(), handle, initrd_data)?;
let status = unsafe { kernel.start(handle, &system_table, kernel_cmdline) };
initrd_loader.uninstall(system_table.boot_services())?;
status.to_result()
}
/// Boot the Linux kernel via the UEFI PE loader.
///
/// This should only succeed when UEFI Secure Boot is off (or
/// broken...), because the Lanzaboote tool does not sign the kernel.
///
/// In essence, we can use this routine to detect whether Secure Boot
/// is actually enabled.
fn boot_linux_uefi(
handle: Handle,
system_table: SystemTable<Boot>,
kernel_data: Vec<u8>,
kernel_cmdline: &CStr16,
initrd_data: Vec<u8>,
) -> uefi::Result<()> {
let kernel_handle = system_table.boot_services().load_image(
handle,
uefi::table::boot::LoadImageSource::FromBuffer {
buffer: &kernel_data,
file_path: None,
},
)?;
let mut kernel_image = system_table
.boot_services()
.open_protocol_exclusive::<LoadedImage>(kernel_handle)?;
unsafe {
kernel_image.set_load_options(
kernel_cmdline.as_ptr() as *const u8,
// This unwrap is "safe" in the sense that any
// command-line that doesn't fit 4G is surely broken.
u32::try_from(kernel_cmdline.num_bytes()).unwrap(),
);
}
let mut initrd_loader = InitrdLoader::new(system_table.boot_services(), handle, initrd_data)?;
let status = system_table
.boot_services()
.start_image(kernel_handle)
.status();
initrd_loader.uninstall(system_table.boot_services())?;
status.to_result()
}
#[entry]
fn main(handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
uefi_services::init(&mut system_table).unwrap();
print_logo();
// SAFETY: We get a slice that represents our currently running
// image and then parse the PE data structures from it. This is
// safe, because we don't touch any data in the data sections that
// might conceivably change while we look at the slice.
let config: EmbeddedConfiguration = unsafe {
EmbeddedConfiguration::new(
booted_image_file(system_table.boot_services())
.unwrap()
.as_slice(),
)
.expect("Failed to extract configuration from binary. Did you run lzbt?")
};
let kernel_data;
let initrd_data;
{
let mut file_system = system_table
.boot_services()
.get_image_file_system(handle)
.expect("Failed to get file system handle");
kernel_data = file_system
.read(&*config.kernel_filename)
.expect("Failed to read kernel file into memory");
initrd_data = file_system
.read(&*config.initrd_filename)
.expect("Failed to read initrd file into memory");
}
let is_kernel_hash_correct = Sha256::digest(&kernel_data) == config.kernel_hash;
let is_initrd_hash_correct = Sha256::digest(&initrd_data) == config.initrd_hash;
if !is_kernel_hash_correct {
warn!("Hash mismatch for kernel!");
}
if !is_initrd_hash_correct {
warn!("Hash mismatch for initrd!");
}
if tpm_available(system_table.boot_services()) {
info!("TPM available, will proceed to measurements.");
unsafe {
@ -237,46 +73,17 @@ fn main(handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
}
export_efi_variables(&system_table).expect("Failed to export stub EFI variables");
if is_kernel_hash_correct && is_initrd_hash_correct {
boot_linux_unchecked(
handle,
system_table,
kernel_data,
&config.cmdline,
initrd_data,
)
.status()
} else {
// There is no good way to detect whether Secure Boot is
// enabled. This is unfortunate, because we want to give the
// user a way to recover from hash mismatches when Secure Boot
// is off.
//
// So in case we get a hash mismatch, we will try to load the
// Linux image using LoadImage. What happens then depends on
// whether Secure Boot is enabled:
//
// **With Secure Boot**, the firmware will reject loading the
// image with status::SECURITY_VIOLATION.
//
// **Without Secure Boot**, the firmware will just load the
// Linux kernel.
//
// This is the behavior we want. A slight turd is that we
// increase the attack surface here by exposing the unverfied
// Linux image to the UEFI firmware. But in case the PE loader
// of the firmware is broken, we have little hope of security
// anyway.
let status;
warn!("Trying to continue as non-Secure Boot. This will fail when Secure Boot is enabled.");
#[cfg(feature = "fat")]
{
status = fat::boot_linux(handle, system_table)
}
boot_linux_uefi(
handle,
system_table,
kernel_data,
&config.cmdline,
initrd_data,
)
.status()
#[cfg(feature = "thin")]
{
status = thin::boot_linux(handle, system_table)
}
status
}

193
rust/stub/src/thin.rs Normal file
View File

@ -0,0 +1,193 @@
use alloc::vec::Vec;
use log::warn;
use sha2::{Digest, Sha256};
use uefi::{prelude::*, proto::loaded_image::LoadedImage, CStr16, CString16, Result};
use crate::common::{boot_linux_unchecked, extract_string};
use crate::pe_section::pe_section;
use crate::{linux_loader::InitrdLoader, uefi_helpers::booted_image_file};
type Hash = sha2::digest::Output<Sha256>;
/// The configuration that is embedded at build time.
///
/// After this stub is built, lzbt needs to embed configuration into the binary by adding PE
/// sections. This struct represents that information.
struct EmbeddedConfiguration {
/// The filename of the kernel to be booted. This filename is
/// relative to the root of the volume that contains the
/// lanzaboote binary.
kernel_filename: CString16,
/// The cryptographic hash of the kernel.
kernel_hash: Hash,
/// The filename of the initrd to be passed to the kernel. See
/// `kernel_filename` for how to interpret these filenames.
initrd_filename: CString16,
/// The cryptographic hash of the initrd. This hash is computed
/// over the whole PE binary, not only the embedded initrd.
initrd_hash: Hash,
/// The kernel command-line.
cmdline: CString16,
}
/// Extract a SHA256 hash from a PE section.
fn extract_hash(pe_data: &[u8], section: &str) -> Result<Hash> {
let array: [u8; 32] = pe_section(pe_data, section)
.ok_or(Status::INVALID_PARAMETER)?
.try_into()
.map_err(|_| Status::INVALID_PARAMETER)?;
Ok(array.into())
}
impl EmbeddedConfiguration {
fn new(file_data: &[u8]) -> Result<Self> {
Ok(Self {
kernel_filename: extract_string(file_data, ".kernelp")?,
kernel_hash: extract_hash(file_data, ".kernelh")?,
initrd_filename: extract_string(file_data, ".initrdp")?,
initrd_hash: extract_hash(file_data, ".initrdh")?,
cmdline: extract_string(file_data, ".cmdline")?,
})
}
}
/// Boot the Linux kernel via the UEFI PE loader.
///
/// This should only succeed when UEFI Secure Boot is off (or
/// broken...), because the Lanzaboote tool does not sign the kernel.
///
/// In essence, we can use this routine to detect whether Secure Boot
/// is actually enabled.
fn boot_linux_uefi(
handle: Handle,
system_table: SystemTable<Boot>,
kernel_data: Vec<u8>,
kernel_cmdline: &CStr16,
initrd_data: Vec<u8>,
) -> uefi::Result<()> {
let kernel_handle = system_table.boot_services().load_image(
handle,
uefi::table::boot::LoadImageSource::FromBuffer {
buffer: &kernel_data,
file_path: None,
},
)?;
let mut kernel_image = system_table
.boot_services()
.open_protocol_exclusive::<LoadedImage>(kernel_handle)?;
unsafe {
kernel_image.set_load_options(
kernel_cmdline.as_ptr() as *const u8,
// This unwrap is "safe" in the sense that any
// command-line that doesn't fit 4G is surely broken.
u32::try_from(kernel_cmdline.num_bytes()).unwrap(),
);
}
let mut initrd_loader = InitrdLoader::new(system_table.boot_services(), handle, initrd_data)?;
let status = system_table
.boot_services()
.start_image(kernel_handle)
.status();
initrd_loader.uninstall(system_table.boot_services())?;
status.to_result()
}
pub fn boot_linux(handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
uefi_services::init(&mut system_table).unwrap();
// SAFETY: We get a slice that represents our currently running
// image and then parse the PE data structures from it. This is
// safe, because we don't touch any data in the data sections that
// might conceivably change while we look at the slice.
let config = unsafe {
EmbeddedConfiguration::new(
booted_image_file(system_table.boot_services())
.unwrap()
.as_slice(),
)
.expect("Failed to extract configuration from binary. Did you run lzbt?")
};
let kernel_data;
let initrd_data;
{
let mut file_system = system_table
.boot_services()
.get_image_file_system(handle)
.expect("Failed to get file system handle");
kernel_data = file_system
.read(&*config.kernel_filename)
.expect("Failed to read kernel file into memory");
initrd_data = file_system
.read(&*config.initrd_filename)
.expect("Failed to read initrd file into memory");
}
let is_kernel_hash_correct = Sha256::digest(&kernel_data) == config.kernel_hash;
let is_initrd_hash_correct = Sha256::digest(&initrd_data) == config.initrd_hash;
if !is_kernel_hash_correct {
warn!("Hash mismatch for kernel!");
}
if !is_initrd_hash_correct {
warn!("Hash mismatch for initrd!");
}
if is_kernel_hash_correct && is_initrd_hash_correct {
boot_linux_unchecked(
handle,
system_table,
kernel_data,
&config.cmdline,
initrd_data,
)
.status()
} else {
// There is no good way to detect whether Secure Boot is
// enabled. This is unfortunate, because we want to give the
// user a way to recover from hash mismatches when Secure Boot
// is off.
//
// So in case we get a hash mismatch, we will try to load the
// Linux image using LoadImage. What happens then depends on
// whether Secure Boot is enabled:
//
// **With Secure Boot**, the firmware will reject loading the
// image with status::SECURITY_VIOLATION.
//
// **Without Secure Boot**, the firmware will just load the
// Linux kernel.
//
// This is the behavior we want. A slight turd is that we
// increase the attack surface here by exposing the unverfied
// Linux image to the UEFI firmware. But in case the PE loader
// of the firmware is broken, we have little hope of security
// anyway.
warn!("Trying to continue as non-Secure Boot. This will fail when Secure Boot is enabled.");
boot_linux_uefi(
handle,
system_table,
kernel_data,
&config.cmdline,
initrd_data,
)
.status()
}
}