diff --git a/nix/tests/lanzaboote.nix b/nix/tests/lanzaboote.nix index d8f0781..cc79538 100644 --- a/nix/tests/lanzaboote.nix +++ b/nix/tests/lanzaboote.nix @@ -6,7 +6,7 @@ let inherit (pkgs) lib; - mkSecureBootTest = { name, machine ? { }, testScript }: testPkgs.nixosTest { + mkSecureBootTest = { name, machine ? { }, useSecureBoot ? true, testScript }: testPkgs.nixosTest { inherit name testScript; nodes.machine = { lib, ... }: { imports = [ @@ -17,7 +17,8 @@ let virtualisation = { useBootLoader = true; useEFIBoot = true; - useSecureBoot = true; + + inherit useSecureBoot; }; boot.loader.efi = { @@ -31,14 +32,18 @@ let }; }; - # Execute a SB test that is expected to fail because of a hash mismatch. + # Execute a boot test that has an intentionally broken secure boot + # chain. This test is expected to fail with Secure Boot and should + # succeed without. # # Takes a set `path` consisting of a `src` and a `dst` attribute. The file at # `src` is copied to `dst` inside th VM. Optionally append some random data # ("crap") to the end of the file at `dst`. This is useful to easily change # the hash of a file and produce a hash mismatch when booting the stub. - mkHashMismatchTest = { name, path, appendCrap ? false }: mkSecureBootTest { + mkHashMismatchTest = { name, path, appendCrap ? false, useSecureBoot ? true }: mkSecureBootTest { inherit name; + inherit useSecureBoot; + testScript = '' import json import os.path @@ -62,9 +67,40 @@ let machine.succeed("sync") machine.crash() machine.start() + '' + (if useSecureBoot then '' machine.wait_for_console_text("Hash mismatch") - ''; + '' else '' + # Just check that the system came up. + print(machine.succeed("bootctl", timeout=120)) + ''); }; + + # The initrd is not directly signed. Its hash is embedded into + # lanzaboote. To make integrity verification fail, we actually have + # to modify the initrd. Appending crap to the end is a harmless way + # that would make the kernel still accept it. + mkModifiedInitrdTest = { name, useSecureBoot }: mkHashMismatchTest { + inherit name useSecureBoot; + + path = { + src = "bootspec.get('initrd')"; + dst = "convert_to_esp(bootspec.get('initrd'))"; + }; + + appendCrap = true; + }; + + mkModifiedKernelTest = { name, useSecureBoot }: mkHashMismatchTest { + inherit name useSecureBoot; + + path = { + src = "bootspec.get('kernel')"; + dst = "convert_to_esp(bootspec.get('kernel'))"; + }; + + appendCrap = true; + }; + in { # TODO: user mode: OK @@ -109,31 +145,28 @@ in ''; }; - # The initrd is not directly signed. Its hash is embedded - # into the UKI. To make integrity verification fail, we - # actually have to modify the initrd. Appending crap to the - # end is a harmless way that would make the kernel still - # accept it. - secured-initrd = mkHashMismatchTest { - name = "lanzaboote-secured-initrd"; - path = { - src = "bootspec.get('initrd')"; - dst = "convert_to_esp(bootspec.get('initrd'))"; - }; - appendCrap = true; + modified-initrd-doesnt-boot-with-secure-boot = mkModifiedInitrdTest { + name = "modified-initrd-doesnt-boot-with-secure-boot"; + useSecureBoot = true; }; - secured-kernel = mkHashMismatchTest { - name = "lanzaboote-secured-kernel"; - path = { - src = "bootspec.get('kernel')"; - dst = "convert_to_esp(bootspec.get('kernel'))"; - }; - appendCrap = true; + modified-initrd-boots-without-secure-boot = mkModifiedInitrdTest { + name = "modified-initrd-boots-without-secure-boot"; + useSecureBoot = false; }; - specialisation = mkSecureBootTest { - name = "lanzaboote-specialisation"; + modified-kernel-doesnt-boot-with-secure-boot = mkModifiedKernelTest { + name = "modified-kernel-doesnt-boot-with-secure-boot"; + useSecureBoot = true; + }; + + modified-kernel-boots-without-secure-boot = mkModifiedKernelTest { + name = "modified-kernel-boots-without-secure-boot"; + useSecureBoot = false; + }; + + specialisation-works = mkSecureBootTest { + name = "specialisation-still-boot-under-secureboot"; machine = { pkgs, ... }: { specialisation.variant.configuration = { environment.systemPackages = [ diff --git a/rust/stub/src/main.rs b/rust/stub/src/main.rs index adf4c4a..c62735a 100644 --- a/rust/stub/src/main.rs +++ b/rust/stub/src/main.rs @@ -11,6 +11,7 @@ mod pe_loader; mod pe_section; mod uefi_helpers; +use alloc::vec::Vec; use pe_loader::Image; use pe_section::{pe_section, pe_section_as_string}; use sha2::{Digest, Sha256}; @@ -18,9 +19,10 @@ use uefi::{ prelude::*, proto::{ console::text::Output, + loaded_image::LoadedImage, media::file::{File, FileAttribute, FileMode, RegularFile}, }, - CString16, Result, + CStr16, CString16, Result, }; use crate::{ @@ -106,6 +108,74 @@ impl EmbeddedConfiguration { } } +/// 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, + kernel_data: Vec, + kernel_cmdline: &CStr16, + initrd_data: Vec, +) -> 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.into() +} + +/// 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, + kernel_data: Vec, + kernel_cmdline: &CStr16, + initrd_data: Vec, +) -> 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::(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.into() +} + #[entry] fn main(handle: Handle, mut system_table: SystemTable) -> Status { uefi_services::init(&mut system_table).unwrap(); @@ -153,32 +223,66 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { initrd_data = read_all(&mut initrd_file).expect("Failed to read kernel file into memory"); } - if Sha256::digest(&kernel_data) != config.kernel_hash { + 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 { system_table .stdout() - .output_string(cstr16!("Hash mismatch for kernel. Refusing to load!\r\n")) + .output_string(cstr16!("Hash mismatch for kernel!\r\n")) .unwrap(); - return Status::SECURITY_VIOLATION; } - if Sha256::digest(&initrd_data) != config.initrd_hash { + if !is_initrd_hash_correct { system_table .stdout() - .output_string(cstr16!("Hash mismatch for initrd. Refusing to load!\r\n")) + .output_string(cstr16!("Hash mismatch for initrd!\r\n")) .unwrap(); - return Status::SECURITY_VIOLATION; } - let kernel = - Image::load(system_table.boot_services(), &kernel_data).expect("Failed to load the kernel"); + 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 mut initrd_loader = InitrdLoader::new(system_table.boot_services(), handle, initrd_data) - .expect("Failed to load the initrd. It may not be there or it is not signed"); + system_table + .stdout() + .output_string(cstr16!("WARNING: Trying to continue as non-Secure Boot. This will fail when Secure Boot is enabled.\r\n")) + .unwrap(); - let status = unsafe { kernel.start(handle, &system_table, &config.cmdline) }; - - initrd_loader - .uninstall(system_table.boot_services()) - .expect("Failed to uninstall the initrd protocols"); - status + boot_linux_uefi( + handle, + system_table, + kernel_data, + &config.cmdline, + initrd_data, + ) + .status() + } }