From 6b3c81e24966088ca56e00c01c1f2cd89c339ac0 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:51:31 +0000 Subject: [PATCH] stage1: exec stage2 from a sealed memfd, deliver config on stdin Replace the write-to-/tmp-then-exec path with an in-memory, pathless launch: - Load the payload into an anonymous memfd, seal it (F_SEAL_WRITE plus SHRINK/GROW/SEAL), and execveat() it directly. The bytes measured into PCR 14 are now immutable and are exactly what runs; there is no named, writable inode to swap between measurement and exec, which closes the measure/exec TOCTOU. MFD_EXEC is requested where supported, with an EINVAL fallback for pre-6.3 kernels. - Deliver the config (the raw user-data JSON) on stdin from a second memfd instead of /tmp/stage2-config.json. stdin needs no extra-fd convention (which trips up runtimes like Bun/Node single-file executables) and, being an in-memory file, has no pipe-size limit. No stage2 artifact is left on any named path. example-stage2 now reads its config from stdin; README documents the contract. Config stays unmeasured by design (code-only PCR 14). Verified: full chain boots and powers off in both sha256 and ed25519 modes, with the config JSON received on stdin. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +- crates/example-stage2/src/main.rs | 12 ++-- crates/stage1/src/main.rs | 112 +++++++++++++++++++++++++----- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 618e084..df4da63 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Part of [Lock.Boot](https://github.com/lockboot) — see the org page for the whole boot chain. **stage1** is the netboot **UKI**: a Unified Kernel Image (Linux kernel + minimal initramfs + the `stage1` bootloader as PID 1) that [stage0](https://github.com/lockboot/stage0) fetches over the network, verifies, measures into PCR 14, and chain-loads. -Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, **admits it by a pinned `sha256` or an `ed25519` signature**, extends **PCR 14 with the payload hash** (loaded code only — never config), generates an attestation, and `exec`s it as PID 1. +Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, **admits it by a pinned `sha256` or an `ed25519` signature**, extends **PCR 14 with the payload hash** (loaded code only — never config), generates an attestation, and `exec`s it as PID 1 from a sealed in-memory image (never a file on disk). ## Build @@ -72,7 +72,9 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d **Measurement is code-only.** stage1 extends **PCR 14** with the SHA-256 of the stage2 binary and nothing else — the admission pin / key / signature and the config JSON are *not* measured. This keeps the platform quote reproducible from the boot artifacts alone (stage0 → UKI → app), and leaves a stage2 app free to measure whatever config *it* deems trust-relevant (PCR 15 is left untouched for it). -Any statically-linked Linux ELF works; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`. +**Execution is pathless.** stage1 loads the payload into a sealed `memfd` (`F_SEAL_WRITE`) and `execveat`s it directly, so the bytes measured into PCR 14 are immutable and are exactly what runs — nothing is written to a named path where it could be swapped between measurement and exec. The payload receives the raw user-data JSON on **stdin** (a second in-memory file, so any runtime that reads stdin works — no extra-fd convention that would trip up Bun/Node single-file executables), and the pre-exec attestation at `/tmp/stage1.attest`. + +Any statically-linked Linux ELF works, as long as it reads its config from stdin; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`. ## Deploy diff --git a/crates/example-stage2/src/main.rs b/crates/example-stage2/src/main.rs index 826c32c..2e4d154 100644 --- a/crates/example-stage2/src/main.rs +++ b/crates/example-stage2/src/main.rs @@ -2,6 +2,7 @@ use std::env; use std::fs; +use std::io::Read; use std::thread; use std::time::Duration; @@ -14,11 +15,12 @@ fn main() { } println!(); - // Print stage2-config.json - println!("=== /tmp/stage2-config.json ==="); - match fs::read_to_string("/tmp/stage2-config.json") { - Ok(contents) => println!("{}", contents), - Err(e) => eprintln!("Error reading /tmp/stage2-config.json: {}", e), + // stage1 delivers the config (the raw user-data JSON) on stdin. + println!("=== config (stdin) ==="); + let mut config = String::new(); + match std::io::stdin().read_to_string(&mut config) { + Ok(_) => println!("{}", config), + Err(e) => eprintln!("Error reading config from stdin: {}", e), } println!(); diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index e12f395..6012cfe 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -9,11 +9,11 @@ use rustls::crypto::CryptoProvider; use sha2::{Digest, Sha256}; use vaportpm_attest::{PcrOps, Tpm}; use vaportpm_attest as tpm; +use std::ffi::CString; use std::fs; use std::io::{self, Write}; -use std::os::unix::fs::PermissionsExt; -use std::os::unix::process::CommandExt; -use std::process::Command; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::ffi::OsStringExt; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -409,22 +409,100 @@ fn verify_checksum(data: &[u8], expected_hex: &str) -> Result<()> { Ok(()) } -fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<()> { - let tmp_path = format!("{}/stage2.exe", TMP_DIR); - fs::write(&tmp_path, data).context(format!("Failed to write binary to {}", tmp_path))?; +// Linux 6.3+: ask for an executable memfd explicitly (hardened kernels default new +// memfds to non-executable). Older kernels reject the flag with EINVAL, so we retry +// without it. Kept local because older libc releases don't export the constant. +const MFD_EXEC: libc::c_uint = 0x0010; + +/// Stage bytes into an anonymous in-memory file (never a named path). When `seal`, the +/// contents are made immutable (F_SEAL_WRITE) so they cannot change after this returns; +/// when `exec`, the file is created executable. +fn make_memfd(name: &str, data: &[u8], seal: bool, exec: bool) -> Result { + let cname = CString::new(name).expect("memfd name has no interior NUL"); + let base: libc::c_uint = libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING; + let mut raw = unsafe { libc::memfd_create(cname.as_ptr(), base | if exec { MFD_EXEC } else { 0 }) }; + if raw < 0 && exec && io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL) { + // Pre-6.3 kernel: no MFD_EXEC. New memfds are executable by default there. + raw = unsafe { libc::memfd_create(cname.as_ptr(), base) }; + } + if raw < 0 { + return Err(io::Error::last_os_error()).context("memfd_create"); + } + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; - // Make the binary executable - let mut perms = fs::metadata(&tmp_path) - .context("Failed to get file metadata")? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(&tmp_path, perms).context("Failed to set executable permissions")?; + let mut rest = data; + while !rest.is_empty() { + let n = unsafe { + libc::write(fd.as_raw_fd(), rest.as_ptr() as *const libc::c_void, rest.len()) + }; + if n < 0 { + return Err(io::Error::last_os_error()).context("write to memfd"); + } + rest = &rest[n as usize..]; + } - let json_path = format!("{}/stage2-config.json", TMP_DIR); - fs::write(&json_path, json_config).context(format!("Failed to write config to {}", json_path))?; + if seal { + // No writable mmap is outstanding (we only wrote via write(2)), so F_SEAL_WRITE + // takes. SHRINK/GROW/SEAL lock the size and the seal set itself. + let seals = libc::F_SEAL_SEAL | libc::F_SEAL_SHRINK | libc::F_SEAL_GROW | libc::F_SEAL_WRITE; + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_ADD_SEALS, seals) } < 0 { + return Err(io::Error::last_os_error()).context("F_ADD_SEALS on payload"); + } + } + Ok(fd) +} - ktseprintln!("{}: {:?}\n", tmp_path, args); +/// Build a NULL-terminated C array from owned CStrings (the CStrings must outlive it). +fn null_terminated(v: &[CString]) -> Vec<*const libc::c_char> { + v.iter().map(|c| c.as_ptr()).chain(std::iter::once(std::ptr::null())).collect() +} + +/// Exec the stage2 payload from a sealed, anonymous memfd (nothing on any named path): +/// the bytes measured into PCR 14 are sealed immutable and are exactly what runs. Config +/// (the raw user-data JSON) is delivered on stdin from a second memfd -- a universal +/// channel that needs no extra-fd convention (which trips up runtimes like Bun/Node +/// single-file executables) and, being an in-memory file, has no pipe-size limit. +fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<()> { + let exe = make_memfd("stage2", data, /*seal=*/ true, /*exec=*/ true)?; + + let cfg = make_memfd("stage2-config", json_config, false, false)?; + if unsafe { libc::lseek(cfg.as_raw_fd(), 0, libc::SEEK_SET) } < 0 { + return Err(io::Error::last_os_error()).context("rewind config memfd"); + } + if unsafe { libc::dup2(cfg.as_raw_fd(), 0) } < 0 { + return Err(io::Error::last_os_error()).context("wire config memfd to stdin"); + } - let err = Command::new(&tmp_path).args(args).exec(); - Err(anyhow!("Failed to exec binary: {}", err)) + // argv[0] = "stage2", then the (signed or inline) args; envp = inherited environment. + let argv_owned: Vec = std::iter::once("stage2".to_string()) + .chain(args.iter().cloned()) + .map(|s| CString::new(s).map_err(|_| anyhow!("stage2 argument has an interior NUL"))) + .collect::>()?; + let envp_owned: Vec = std::env::vars_os() + .filter_map(|(k, v)| { + let mut kv = k; + kv.push("="); + kv.push(v); + CString::new(kv.into_vec()).ok() + }) + .collect(); + let argv = null_terminated(&argv_owned); + let envp = null_terminated(&envp_owned); + let empty = CString::new("").unwrap(); + + ktseprintln!("exec stage2 (sealed memfd, config on stdin): {:?}", args); + + // execveat(fd, "", ..., AT_EMPTY_PATH) execs the fd directly -- no /proc dependency, + // unlike glibc's fexecve fallback. Only returns on failure. + unsafe { + libc::syscall( + libc::SYS_execveat, + exe.as_raw_fd(), + empty.as_ptr(), + argv.as_ptr(), + envp.as_ptr(), + libc::AT_EMPTY_PATH, + ); + } + Err(anyhow!("execveat stage2 failed: {}", io::Error::last_os_error())) }