diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index e23237fd..0fbcfad4 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -23,7 +23,8 @@ fn main() { // restore-stub: a core component of the restore engine (the supervisor execs // it to reconstruct a checkpoint), freestanding, no libc, no PIE. It lives // next to the checkpoint code that owns it; its binary is built into OUT_DIR - // and its path is handed to the crate via the RESTORE_STUB_PATH env var. + // and its path is handed to the crate via the RESTORE_STUB_PATH env var, + // which embeds it: an installed sandlock has no build tree to exec from. // // The fixed load address must match `checkpoint::restore_blob::STUB_BASE`: // the stub reconstructs the checkpoint's layout around itself, so its own @@ -99,9 +100,54 @@ fn main() { } println!("cargo:warning={fail_msg}"); } + // The crate embeds the stub with include_bytes!, so an arch without restore + // still needs a file there; the runtime treats an empty stub as unavailable. + if !stub_bin.exists() { + std::fs::write(&stub_bin, b"").unwrap(); + } // Emit the path every run (rustc-env is not cached across build-script runs), // whether or not the binary was just (re)built. println!("cargo:rustc-env=RESTORE_STUB_PATH={}", stub_bin.display()); + + // shebang-trampoline: runs a chroot script's #! interpreter (see the + // source). Freestanding, so any compiler for the target arch will do. + let tramp_src = manifest_dir.join("src/chroot/shebang-trampoline.c"); + let tramp_bin = out_dir.join("shebang-trampoline"); + let arch = target.split('-').next().unwrap_or_default(); + let arch = if is_riscv64 { "riscv64" } else { arch }; + let mut tramp_ccs = vec![format!("{arch}-linux-gnu-gcc"), format!("{arch}-unknown-linux-gnu-gcc")]; + if host.starts_with(arch) { + tramp_ccs.insert(0, "cc".to_string()); + } + let tramp_ccs: Vec<&str> = tramp_ccs.iter().map(String::as_str).collect(); + if !build_static( + &tramp_src, + &tramp_bin, + &tramp_ccs, + &[ + "-static", + "-nostdlib", + "-no-pie", + "-O2", + "-ffreestanding", + "-fno-tree-loop-distribute-patterns", + "-fno-stack-protector", + ], + ) { + let msg = format!( + "failed to compile shebang-trampoline for {arch}: no working C compiler \ + (tried {}); #! scripts cannot run under chroot", + tramp_ccs.join(", "), + ); + if matches!(arch, "x86_64" | "aarch64" | "riscv64") { + panic!("{msg}"); + } + println!("cargo:warning={msg}"); + } + if !tramp_bin.exists() { + std::fs::write(&tramp_bin, b"").unwrap(); + } + println!("cargo:rustc-env=SHEBANG_TRAMPOLINE_PATH={}", tramp_bin.display()); } /// Compile `src` to `bin` with the first working compiler in `ccs`, skipping the diff --git a/crates/sandlock-core/src/checkpoint/mod.rs b/crates/sandlock-core/src/checkpoint/mod.rs index 3471d1c7..bc10ff4d 100644 --- a/crates/sandlock-core/src/checkpoint/mod.rs +++ b/crates/sandlock-core/src/checkpoint/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod resume; pub(crate) const CTRL_FD: i32 = 3; // control-blob memfd pub(crate) const READY_FD: i32 = 4; // eventfd: stub -> supervisor ("layout done") pub(crate) const GO_FD: i32 = 5; // eventfd: supervisor -> stub ("pages written") +pub(crate) const STUB_FD: i32 = 6; // sealed memfd holding the stub image itself pub(crate) use capture::capture; diff --git a/crates/sandlock-core/src/checkpoint/restore-stub.c b/crates/sandlock-core/src/checkpoint/restore-stub.c index 7c5532f5..5efe39ae 100644 --- a/crates/sandlock-core/src/checkpoint/restore-stub.c +++ b/crates/sandlock-core/src/checkpoint/restore-stub.c @@ -55,6 +55,7 @@ #define CTRL_FD 3 #define READY_FD 4 #define GO_FD 5 +#define STUB_FD 6 /* The window this stub is linked into, mirroring restore_blob::STUB_BASE and * STUB_SPAN and the -Wl,-Ttext-segment= flag in build.rs. Used only to refuse a @@ -441,10 +442,11 @@ static void _start_c(u64 *sp) { } /* 8. Reopen the fd table. The control fds go first: a restored fd number - * may well be 3, 4 or 5, and nothing needs them from here on. */ + * may well be 3 to 6, and nothing needs them from here on. */ SC1(SYS_close, CTRL_FD); SC1(SYS_close, READY_FD); SC1(SYS_close, GO_FD); + SC1(SYS_close, STUB_FD); for (i = 0; i < h->n_fds; i++) { struct blob_fd *f = &fds[i]; i64 fd = SC4(SYS_openat, AT_FDCWD, strings + f->path_off, f->flags, 0); diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index ade96102..dc4bece6 100644 --- a/crates/sandlock-core/src/checkpoint/resume.rs +++ b/crates/sandlock-core/src/checkpoint/resume.rs @@ -18,9 +18,8 @@ use std::io; use std::os::fd::{FromRawFd, OwnedFd, RawFd}; -use std::path::PathBuf; -use crate::checkpoint::{CTRL_FD, GO_FD, READY_FD}; +use crate::checkpoint::{CTRL_FD, GO_FD, READY_FD, STUB_FD}; use crate::error::{SandboxRuntimeError, SandlockError}; /// How long to wait for the stub to finish laying out the address space. @@ -36,14 +35,15 @@ fn child_err(msg: String) -> SandlockError { SandlockError::Runtime(SandboxRuntimeError::Child(msg)) } -/// Path to the freestanding restore-stub binary, compiled by `build.rs`. -pub(crate) fn stub_path() -> PathBuf { - PathBuf::from(env!("RESTORE_STUB_PATH")) -} +/// The freestanding restore-stub, compiled by `build.rs`. Embedded rather than +/// exec'd from its build path, which does not exist once sandlock is installed. +/// Empty when this arch has no restore engine or no C compiler was found. +pub(crate) const STUB_ELF: &[u8] = include_bytes!(env!("RESTORE_STUB_PATH")); /// The fds the stub inherits, held open in the supervisor for the handshake. /// Their numbers in the child are fixed by the [`CTRL_FD`]/[`READY_FD`]/ -/// [`GO_FD`] convention; `Sandbox`'s `extra_fds` mechanism does the `dup2`. +/// [`GO_FD`]/[`STUB_FD`] convention; `Sandbox`'s `extra_fds` mechanism does the +/// `dup2`. /// /// READY is an eventfd the stub signals once the address space is laid out. GO /// is a pipe rather than an eventfd because it carries data back: the count of @@ -57,21 +57,24 @@ pub(crate) struct StubChannel { go_r: OwnedFd, /// Write end, kept here. go_w: OwnedFd, + stub: OwnedFd, } impl StubChannel { - /// Build the control-blob memfd, the READY eventfd and the GO pipe, each - /// relocated clear of the fixed child-side numbers (see [`relocate_above`]). + /// Build the control-blob memfd, the READY eventfd, the GO pipe and the + /// stub memfd, each relocated clear of the fixed child-side numbers (see + /// [`relocate_above`]). pub(crate) fn new(blob: &[u8]) -> io::Result { - let ctrl = relocate_above(memfd_with(blob)?, GO_FD + 1)?; - let ready = relocate_above(eventfd()?, GO_FD + 1)?; + let ctrl = relocate_above(memfd_with(blob)?, STUB_FD + 1)?; + let ready = relocate_above(eventfd()?, STUB_FD + 1)?; let mut pipefd = [0i32; 2]; if unsafe { libc::pipe2(pipefd.as_mut_ptr(), libc::O_CLOEXEC) } != 0 { return Err(io::Error::last_os_error()); } - let go_r = relocate_above(pipefd[0], GO_FD + 1)?; - let go_w = relocate_above(pipefd[1], GO_FD + 1)?; - Ok(StubChannel { ctrl, ready, go_r, go_w }) + let go_r = relocate_above(pipefd[0], STUB_FD + 1)?; + let go_w = relocate_above(pipefd[1], STUB_FD + 1)?; + let stub = relocate_above(stub_memfd()?, STUB_FD + 1)?; + Ok(StubChannel { ctrl, ready, go_r, go_w, stub }) } /// The `(child fd, supervisor fd)` pairs for `Sandbox`'s `extra_fds`. @@ -81,6 +84,7 @@ impl StubChannel { (CTRL_FD, self.ctrl.as_raw_fd()), (READY_FD, self.ready.as_raw_fd()), (GO_FD, self.go_r.as_raw_fd()), + (STUB_FD, self.stub.as_raw_fd()), ] } } @@ -120,6 +124,13 @@ fn memfd_with(bytes: &[u8]) -> io::Result { Ok(fd) } +/// A sealed, executable memfd holding [`STUB_ELF`]. +fn stub_memfd() -> io::Result { + use std::os::fd::IntoRawFd; + + crate::sys::syscall::sealed_exec_memfd("sandlock-restore-stub", STUB_ELF).map(IntoRawFd::into_raw_fd) +} + /// Move `fd` to a number at or above `floor` and take ownership of it. /// /// Two hazards make this mandatory rather than tidy. `dup2` returns success @@ -297,21 +308,31 @@ mod tests { #[test] fn channel_fds_land_above_the_fixed_child_numbers() { - // A control fd allocated at 3/4/5 would be dup2'd onto itself in the + // A control fd allocated at 3 to 6 would be dup2'd onto itself in the // child, which leaves FD_CLOEXEC set and closes it at execve. let ch = StubChannel::new(b"blob").expect("channel"); for fd in [ch.ctrl.as_raw_fd(), ch.ready.as_raw_fd(), - ch.go_r.as_raw_fd(), ch.go_w.as_raw_fd()] { - assert!(fd > GO_FD, "fd {fd} must sit above the fixed control fds"); + ch.go_r.as_raw_fd(), ch.go_w.as_raw_fd(), ch.stub.as_raw_fd()] { + assert!(fd > STUB_FD, "fd {fd} must sit above the fixed control fds"); } let mut pairs = ch.extra_fds(); pairs.sort(); assert_eq!( pairs.iter().map(|&(child, _)| child).collect::>(), - vec![CTRL_FD, READY_FD, GO_FD], + vec![CTRL_FD, READY_FD, GO_FD, STUB_FD], ); } + #[test] + fn stub_memfd_is_sealed_and_holds_the_stub() { + let ch = StubChannel::new(b"blob").expect("channel"); + let fd = ch.stub.as_raw_fd(); + let seals = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; + assert!(seals & libc::F_SEAL_WRITE != 0, "stub memfd must be write-sealed"); + let got = std::fs::read(format!("/proc/self/fd/{fd}")).expect("read stub memfd"); + assert_eq!(got, STUB_ELF); + } + #[test] fn control_blob_memfd_reads_back_from_offset_zero() { let ch = StubChannel::new(b"hello blob").expect("channel"); @@ -398,10 +419,11 @@ mod tests { fn stub_links_at_the_reserved_base() { use crate::checkpoint::restore_blob::{STUB_BASE, STUB_SPAN}; - let Ok(elf) = std::fs::read(stub_path()) else { + let elf = STUB_ELF; + if elf.is_empty() { eprintln!("skip: restore-stub not built"); return; - }; + } // ELF64 program headers: e_phoff@32, e_phentsize@54, e_phnum@56. // Each PT_LOAD entry: p_type@0, p_vaddr@16, p_memsz@40. let phoff = u64::from_le_bytes(elf[32..40].try_into().unwrap()) as usize; @@ -435,10 +457,11 @@ mod tests { #[test] #[cfg(target_arch = "x86_64")] fn stub_carries_no_stack_protector() { - let Ok(elf) = std::fs::read(stub_path()) else { + let elf = STUB_ELF; + if elf.is_empty() { eprintln!("skip: restore-stub not built"); return; - }; + } const CANARY_LOAD: &[u8] = &[0x64, 0x48, 0x8b, 0x04, 0x25, 0x28, 0x00, 0x00, 0x00]; assert!( !elf.windows(CANARY_LOAD.len()).any(|w| w == CANARY_LOAD), @@ -468,9 +491,8 @@ mod tests { const SENTINEL: u8 = 0x5A; const PAGE: u64 = 0x1000; - let stub = stub_path(); - if !stub.exists() { - eprintln!("skip: restore-stub not built ({})", stub.display()); + if STUB_ELF.is_empty() { + eprintln!("skip: restore-stub not built"); return; } @@ -528,10 +550,7 @@ mod tests { let plan = restore_blob::plan(&cp, None, &[]).expect("plan"); let channel = StubChannel::new(&plan.blob).expect("channel"); - // Build the exec path before fork: CString::new allocates, and - // allocating between fork() and execve() in a multithreaded process (the - // test harness) can deadlock on the allocator lock. - let stub_path = std::ffi::CString::new(stub.to_str().unwrap()).unwrap(); + let stub_name = c"sandlock-restore-stub"; // Relocate the sentinel pipe clear of every fixed number the child // installs, OUT_FD included. Left at 3/4 (which is exactly where the @@ -543,8 +562,12 @@ mod tests { let pipe_w = relocate_above(pipefd[1], OUT_FD + 1).expect("relocate pipe write end"); let (pipe_r, pipe_w) = (pipe_r.into_raw_fd(), pipe_w.into_raw_fd()); - let (ctrl, ready, go) = - (channel.ctrl.as_raw_fd(), channel.ready.as_raw_fd(), channel.go_r.as_raw_fd()); + let (ctrl, ready, go, stub) = ( + channel.ctrl.as_raw_fd(), + channel.ready.as_raw_fd(), + channel.go_r.as_raw_fd(), + channel.stub.as_raw_fd(), + ); let child = unsafe { libc::fork() }; assert!(child >= 0, "fork"); if child == 0 { @@ -554,10 +577,14 @@ mod tests { libc::dup2(ctrl, CTRL_FD); libc::dup2(ready, READY_FD); libc::dup2(go, GO_FD); + libc::dup2(stub, STUB_FD); libc::dup2(pipe_w, OUT_FD); - let argv = [stub_path.as_ptr(), std::ptr::null()]; - let envp = [std::ptr::null()]; - libc::execve(stub_path.as_ptr(), argv.as_ptr(), envp.as_ptr()); + let argv = [stub_name.as_ptr(), std::ptr::null()]; + let envp: [*const libc::c_char; 1] = [std::ptr::null()]; + libc::syscall( + libc::SYS_execveat, STUB_FD, c"".as_ptr(), argv.as_ptr(), envp.as_ptr(), + libc::AT_EMPTY_PATH, + ); libc::_exit(127); } } @@ -610,9 +637,8 @@ mod tests { const SENTINEL: u8 = 0x5A; const PAGE: u64 = 0x1000; - let stub = stub_path(); - if !stub.exists() { - eprintln!("skip: restore-stub not built ({})", stub.display()); + if STUB_ELF.is_empty() { + eprintln!("skip: restore-stub not built"); return; } @@ -676,7 +702,7 @@ mod tests { let plan = restore_blob::plan(&cp, None, &[]).expect("plan"); let channel = StubChannel::new(&plan.blob).expect("channel"); - let stub_path = std::ffi::CString::new(stub.to_str().unwrap()).unwrap(); + let stub_name = c"sandlock-restore-stub"; let mut pipefd = [0i32; 2]; assert_eq!(unsafe { libc::pipe(pipefd.as_mut_ptr()) }, 0); @@ -684,8 +710,12 @@ mod tests { let pipe_w = relocate_above(pipefd[1], OUT_FD + 1).expect("relocate pipe write end"); let (pipe_r, pipe_w) = (pipe_r.into_raw_fd(), pipe_w.into_raw_fd()); - let (ctrl, ready, go) = - (channel.ctrl.as_raw_fd(), channel.ready.as_raw_fd(), channel.go_r.as_raw_fd()); + let (ctrl, ready, go, stub) = ( + channel.ctrl.as_raw_fd(), + channel.ready.as_raw_fd(), + channel.go_r.as_raw_fd(), + channel.stub.as_raw_fd(), + ); let child = unsafe { libc::fork() }; assert!(child >= 0, "fork"); if child == 0 { @@ -693,10 +723,14 @@ mod tests { libc::dup2(ctrl, CTRL_FD); libc::dup2(ready, READY_FD); libc::dup2(go, GO_FD); + libc::dup2(stub, STUB_FD); libc::dup2(pipe_w, OUT_FD); - let argv = [stub_path.as_ptr(), std::ptr::null()]; - let envp = [std::ptr::null()]; - libc::execve(stub_path.as_ptr(), argv.as_ptr(), envp.as_ptr()); + let argv = [stub_name.as_ptr(), std::ptr::null()]; + let envp: [*const libc::c_char; 1] = [std::ptr::null()]; + libc::syscall( + libc::SYS_execveat, STUB_FD, c"".as_ptr(), argv.as_ptr(), envp.as_ptr(), + libc::AT_EMPTY_PATH, + ); libc::_exit(127); } } diff --git a/crates/sandlock-core/src/chroot/dispatch.rs b/crates/sandlock-core/src/chroot/dispatch.rs index 0ddc2ec4..55bef5b1 100644 --- a/crates/sandlock-core/src/chroot/dispatch.rs +++ b/crates/sandlock-core/src/chroot/dispatch.rs @@ -873,6 +873,84 @@ fn read_pt_interp(fd: RawFd) -> Option<(String, u64, usize)> { None } +// ============================================================ +// `#!` script helpers +// ============================================================ + +/// The kernel reads this much of a script to find its `#!` line. +const BINPRM_BUF_SIZE: usize = 256; + +struct Shebang { + interp: String, + arg: Option>, +} + +/// Parse a `#!` line exactly as fs/binfmt_script.c does, so the interpreter +/// gets the same argument it would from the kernel. None when the kernel would +/// refuse it; the exec then proceeds unchanged and fails the same way. +fn read_shebang(fd: RawFd) -> Option { + use std::os::unix::fs::FileExt; + + let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) }); + let mut buf = [0u8; BINPRM_BUF_SIZE]; + let mut n = 0; + while n < buf.len() { + match file.read_at(&mut buf[n..], n as u64) { + Ok(0) => break, + Ok(r) => n += r, + Err(_) => return None, + } + } + if !buf.starts_with(b"#!") { + return None; + } + let spacetab = |b: u8| b == b' ' || b == b'\t'; + let terminator = |b: u8| spacetab(b) || b == 0; + let buf_end = BINPRM_BUF_SIZE - 1; + let find = |from: usize, to: usize, pred: &dyn Fn(u8) -> bool| { + (from..=to).find(|&i| pred(buf[i])) + }; + + let mut i_end = match buf.iter().position(|&b| b == b'\n') { + Some(i) => i, + None => { + let name = find(2, buf_end, &|b| !spacetab(b))?; + find(name, buf_end, &terminator)?; + buf_end + } + }; + while spacetab(buf[i_end - 1]) { + i_end -= 1; + } + let i_name = find(2, i_end, &|b| !spacetab(b)).filter(|&i| i != i_end)?; + let i_sep = find(i_name, i_end, &terminator).filter(|&i| i < i_end); + let arg = i_sep + .filter(|&i| buf[i] != 0) + .and_then(|i| find(i, i_end, &|b| !spacetab(b))) + .map(|i| { + let arg = &buf[i..i_end]; + arg[..arg.iter().position(|&b| b == 0).unwrap_or(arg.len())].to_vec() + }); + let interp = String::from_utf8_lossy(&buf[i_name..i_sep.unwrap_or(i_end)]).into_owned(); + Some(Shebang { interp, arg }) +} + +/// A script for the kernel to run in place of the original: its `#!` line +/// starts the trampoline at `trampoline_fd`, and its body tells the +/// trampoline what to exec (see shebang-trampoline.c). +fn shebang_launcher(shebang: &Shebang, trampoline_fd: i32, script: &str) -> Result { + let mut body = format!("#!/proc/self/fd/{trampoline_fd}\n{}\0", shebang.interp).into_bytes(); + body.extend_from_slice(shebang.arg.as_deref().unwrap_or_default()); + body.push(0); + body.extend_from_slice(script.as_bytes()); + body.push(0); + let memfd = crate::sys::syscall::memfd_create_exec("sandlock-exec", 0).map_err(|_| libc::EIO)?; + std::fs::File::from(memfd.try_clone().map_err(|_| libc::EIO)?) + .write_all(&body) + .map_err(|_| libc::EIO)?; + Ok(memfd) +} + /// Create a memfd copy of `src_fd` with PT_INTERP patched to `new_interp`. /// Uses sendfile for efficient kernel-to-kernel copy, then patches the /// interpreter path in place. @@ -892,7 +970,7 @@ fn memfd_with_patched_interp( }; // Create memfd - let memfd = crate::sys::syscall::memfd_create("sandlock-exec", 0).ok()?; + let memfd = crate::sys::syscall::memfd_create_exec("sandlock-exec", 0).ok()?; let mfd = memfd.as_raw_fd(); // Set size @@ -940,25 +1018,12 @@ fn memfd_with_patched_interp( // execve/execveat handler // ============================================================ -pub(crate) async fn handle_chroot_exec( +fn open_exec_by_path( notif: &SeccompNotif, - chroot_state: &Arc>, - _cow_state: &Arc>, - notif_fd: RawFd, + dirfd: i64, + rel_path: String, ctx: &ChrootCtx<'_>, -) -> NotifAction { - let nr = notif.data.nr as i64; - let (dirfd, path_ptr, argv_ptr, envp_ptr) = if nr == libc::SYS_execveat { - (notif.data.args[0] as i64, notif.data.args[1], notif.data.args[2], notif.data.args[3]) - } else { - (libc::AT_FDCWD as i64, notif.data.args[0], notif.data.args[1], notif.data.args[2]) - }; - - let rel_path = match read_path(notif, path_ptr, notif_fd) { - Some(p) => p, - None => return NotifAction::Continue, - }; - +) -> Result<(RawFd, PathBuf), i32> { // Build the full virtual path from dirfd + relative path. let full_path = if Path::new(&rel_path).is_absolute() { rel_path @@ -971,13 +1036,13 @@ pub(crate) async fn handle_chroot_exec( }; match base { Some(base) => base.join(&rel_path).to_string_lossy().to_string(), - None => return NotifAction::Errno(libc::EACCES), + None => return Err(libc::EACCES), } }; let virtual_path = crate::chroot::resolve::confine(&full_path); if !ctx.can_read(&virtual_path) { - return NotifAction::Errno(libc::EACCES); + return Err(libc::EACCES); } // Open the binary directly via openat2(RESOLVE_IN_ROOT). Single atomic @@ -994,70 +1059,179 @@ pub(crate) async fn handle_chroot_exec( 0, ) { Ok(fd) => fd, - Err(_) => return NotifAction::Errno(libc::ENOENT), + Err(_) => return Err(libc::ENOENT), }; + Ok((src_fd, virtual_path)) +} - // Read PT_INTERP from the binary. If it has one, open the image's - // interpreter and create a memfd copy with PT_INTERP patched to - // point at the injected interpreter fd. This ensures the kernel loads - // the image's ld-linux (not the host's), avoiding glibc version - // mismatches between ld.so and libc.so. - let exec_fd = if let Some((interp_path, interp_offset, interp_cap)) = read_pt_interp(src_fd) { - // Open the image's interpreter from the chroot root (intentionally - // NOT mount-aware ��� the dynamic linker should come from the base - // image, not from workspace mounts). - let interp_src = match openat2_in_root( - ctx.root, - &interp_path, - libc::O_RDONLY | libc::O_CLOEXEC, - 0, - ) { - Ok(fd) => fd, - Err(_) => { - unsafe { libc::close(src_fd) }; - return NotifAction::Errno(libc::ENOENT); +/// fexecve: the guest names the image by an fd it holds, which need not have a +/// path in the root (a memfd, or a file inherited from the host). Exec it when +/// the path policy grants it, or when the guest could already read the bytes +/// and so could copy them into a memfd of its own. +fn open_exec_by_fd( + notif: &SeccompNotif, + fd: RawFd, + ctx: &ChrootCtx<'_>, +) -> Result<(RawFd, PathBuf), i32> { + use std::os::unix::fs::MetadataExt; + + if fd < 0 { + return Err(libc::EBADF); + } + let file = std::fs::File::from( + crate::seccomp::notif::dup_fd_from_pid(notif.pid, fd).map_err(|_| libc::EBADF)?, + ); + let self_path = format!("/proc/self/fd/{}", file.as_raw_fd()); + let host = std::fs::read_link(&self_path).map_err(|_| libc::EBADF)?; + let meta = file.metadata().map_err(|_| libc::EBADF)?; + // A memfd's or deleted file's link text is not a path to it. + let at_host = std::fs::metadata(&host) + .is_ok_and(|m| m.dev() == meta.dev() && m.ino() == meta.ino()); + let virtual_path = match ctx.host_to_virtual(&host).filter(|_| at_host) { + Some(vp) if ctx.can_read(&vp) => vp, + Some(_) => return Err(libc::EACCES), + None => { + let mode = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) } + & (libc::O_ACCMODE | libc::O_PATH); + if mode != libc::O_RDONLY && mode != libc::O_RDWR { + return Err(libc::EACCES); } - }; + host + } + }; + // A private description: the guest's shares its offset with ours, and the + // PT_INTERP scan seeks. + let path = CString::new(self_path).map_err(|_| libc::EBADF)?; + let src_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) }; + if src_fd < 0 { + return Err(libc::EACCES); + } + Ok((src_fd, virtual_path)) +} - // Inject the interpreter fd into the child (must survive exec) - let addfd_interp = SeccompNotifAddfd { - id: notif.id, - flags: 0, - srcfd: interp_src as u32, - newfd: 0, - newfd_flags: 0, - }; - let child_interp_fd = unsafe { - libc::ioctl( - notif_fd, - SECCOMP_IOCTL_NOTIF_ADDFD as libc::Ioctl, - &addfd_interp as *const _, - ) - }; - unsafe { libc::close(interp_src) }; +/// Inject `fd` into the exec'ing guest. +fn inject_exec_fd( + notif: &SeccompNotif, + notif_fd: RawFd, + fd: RawFd, + newfd_flags: u32, +) -> Result { + let addfd = SeccompNotifAddfd { + id: notif.id, + flags: 0, + srcfd: fd as u32, + newfd: 0, + newfd_flags, + }; + let child_fd = unsafe { + libc::ioctl(notif_fd, SECCOMP_IOCTL_NOTIF_ADDFD as libc::Ioctl, &addfd as *const _) + }; + if child_fd < 0 { Err(libc::EIO) } else { Ok(child_fd) } +} - if child_interp_fd < 0 { - unsafe { libc::close(src_fd) }; - return NotifAction::Errno(libc::EIO); +const SHEBANG_TRAMPOLINE: &[u8] = include_bytes!(env!("SHEBANG_TRAMPOLINE_PATH")); + +/// The kernel's exec_binprm() limit on nested `#!` interpreters. +const MAX_SCRIPT_DEPTH: usize = 5; + +/// Fail the exec as the kernel would when a script's interpreter chain is +/// missing, refused, or too deep. Each trampoline hop is a fresh exec the +/// kernel no longer counts, so a script naming itself would loop forever. +fn check_interp_chain(notif: &SeccompNotif, ctx: &ChrootCtx<'_>, interp: &str) -> Result<(), i32> { + let mut interp = interp.to_string(); + for _ in 0..MAX_SCRIPT_DEPTH { + let (fd, _) = open_exec_by_path(notif, libc::AT_FDCWD as i64, interp, ctx)?; + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + match read_shebang(fd.as_raw_fd()) { + Some(next) => interp = next.interp, + None => return Ok(()), } + } + Err(libc::ELOOP) +} - // Create a memfd copy with PT_INTERP patched to /proc/self/fd/ - let new_interp = format!("/proc/self/fd/{}", child_interp_fd); - match memfd_with_patched_interp(src_fd, &new_interp, interp_offset, interp_cap) { - Some(memfd) => { - unsafe { libc::close(src_fd) }; - memfd - } - None => { - // Patching failed (e.g., new path too long) — fall back to - // original binary. Host ld-linux will be used; this is the - // pre-existing behavior and may work if versions are compatible. - unsafe { OwnedFd::from_raw_fd(src_fd) } - } +/// Ready an image for the kernel to load. The kernel opens an ELF's PT_INTERP +/// and a script's `#!` interpreter itself, against the host root, so each is +/// redirected to an fd that leads back inside the chroot. +fn prepare_exec_image( + notif: &SeccompNotif, + notif_fd: RawFd, + ctx: &ChrootCtx<'_>, + src: OwnedFd, + exec_name: &str, +) -> Result { + if let Some(shebang) = read_shebang(src.as_raw_fd()) { + if SHEBANG_TRAMPOLINE.is_empty() { + return Err(libc::ENOEXEC); } + check_interp_chain(notif, ctx, &shebang.interp)?; + let trampoline = crate::sys::syscall::sealed_exec_memfd("sandlock-shebang", SHEBANG_TRAMPOLINE) + .map_err(|_| libc::EIO)?; + // The kernel opens the trampoline before it closes O_CLOEXEC fds. + let child_fd = inject_exec_fd(notif, notif_fd, trampoline.as_raw_fd(), libc::O_CLOEXEC as u32)?; + return shebang_launcher(&shebang, child_fd, exec_name); + } + + let Some((interp_path, interp_offset, interp_cap)) = read_pt_interp(src.as_raw_fd()) else { + return Ok(src); + }; + // Not mount-aware: the dynamic linker comes from the base image, not + // from workspace mounts. + let interp_src = openat2_in_root(ctx.root, &interp_path, libc::O_RDONLY | libc::O_CLOEXEC, 0) + .map_err(|_| libc::ENOENT)?; + let interp_src = unsafe { OwnedFd::from_raw_fd(interp_src) }; + let child_interp_fd = inject_exec_fd(notif, notif_fd, interp_src.as_raw_fd(), 0)?; + let new_interp = format!("/proc/self/fd/{}", child_interp_fd); + match memfd_with_patched_interp(src.as_raw_fd(), &new_interp, interp_offset, interp_cap) { + Some(memfd) => Ok(memfd), + // The host's ld.so is used, as before this patching existed. + None => Ok(src), + } +} + +pub(crate) async fn handle_chroot_exec( + notif: &SeccompNotif, + chroot_state: &Arc>, + _cow_state: &Arc>, + notif_fd: RawFd, + ctx: &ChrootCtx<'_>, +) -> NotifAction { + let nr = notif.data.nr as i64; + let (dirfd, path_ptr, argv_ptr, envp_ptr) = if nr == libc::SYS_execveat { + (notif.data.args[0] as i64, notif.data.args[1], notif.data.args[2], notif.data.args[3]) } else { - // Statically linked or not ELF — use the binary directly. - unsafe { OwnedFd::from_raw_fd(src_fd) } + (libc::AT_FDCWD as i64, notif.data.args[0], notif.data.args[1], notif.data.args[2]) + }; + + let rel_path = match read_path(notif, path_ptr, notif_fd) { + Some(p) => p, + None => return NotifAction::Continue, + }; + + let flags = if nr == libc::SYS_execveat { notif.data.args[4] as i32 } else { 0 }; + // What the kernel's alloc_bprm() hands a script's interpreter as the script. + let exec_name = if rel_path.starts_with('/') || dirfd == libc::AT_FDCWD as i64 { + rel_path.clone() + } else if rel_path.is_empty() { + format!("/dev/fd/{dirfd}") + } else { + format!("/dev/fd/{dirfd}/{rel_path}") + }; + let opened = if rel_path.is_empty() && flags & libc::AT_EMPTY_PATH != 0 { + open_exec_by_fd(notif, dirfd as i32, ctx) + } else { + open_exec_by_path(notif, dirfd, rel_path, ctx) + }; + let (src_fd, virtual_path) = match opened { + Ok(r) => r, + Err(errno) => return NotifAction::Errno(errno), + }; + + let exec_fd = match prepare_exec_image( + notif, notif_fd, ctx, unsafe { OwnedFd::from_raw_fd(src_fd) }, &exec_name, + ) { + Ok(r) => r, + Err(errno) => return NotifAction::Errno(errno), }; // Record the virtual exe path so /proc/self/exe queries return the diff --git a/crates/sandlock-core/src/chroot/shebang-trampoline.c b/crates/sandlock-core/src/chroot/shebang-trampoline.c new file mode 100644 index 00000000..2ecf91a5 --- /dev/null +++ b/crates/sandlock-core/src/chroot/shebang-trampoline.c @@ -0,0 +1,158 @@ +/* + * Runs a chroot script's interpreter. The kernel opens a #! interpreter + * against the host root, so for a script the supervisor execs a launcher + * instead: + * + * "#!/proc/self/fd/\n" interp "\0" arg "\0" script "\0" + * + * and the kernel starts us with argv = { self, launcher, args... }. We exec + * by path, which the supervisor resolves inside the image, with the + * argv the kernel would have built for the script: { interp, [arg,] script, + * args... }. An empty arg means the #! line had none. + * + * Freestanding, no libc: it is embedded in sandlock and must run in any image. + */ + +typedef unsigned long u64; + +#if defined(__x86_64__) +#define SYS_pread64 17 +#define SYS_write 1 +#define SYS_close 3 +#define SYS_execve 59 +#define SYS_exit_group 231 +static long sc4(long n, u64 a, u64 b, u64 c, u64 d) { + long r; + register u64 r10 __asm__("r10") = d; + __asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a), "S"(b), "d"(c), "r"(r10) + : "rcx", "r11", "memory"); + return r; +} +#elif defined(__aarch64__) +#define SYS_pread64 67 +#define SYS_write 64 +#define SYS_close 57 +#define SYS_execve 221 +#define SYS_exit_group 94 +static long sc4(long n, u64 a, u64 b, u64 c, u64 d) { + register long x8 __asm__("x8") = n; + register u64 x0 __asm__("x0") = a; + register u64 x1 __asm__("x1") = b; + register u64 x2 __asm__("x2") = c; + register u64 x3 __asm__("x3") = d; + __asm__ volatile("svc 0" : "+r"(x0) : "r"(x1), "r"(x2), "r"(x3), "r"(x8) : "memory"); + return (long)x0; +} +#elif defined(__riscv) && __riscv_xlen == 64 +#define SYS_pread64 67 +#define SYS_write 64 +#define SYS_close 57 +#define SYS_execve 221 +#define SYS_exit_group 94 +static long sc4(long n, u64 a, u64 b, u64 c, u64 d) { + register long a7 __asm__("a7") = n; + register u64 a0 __asm__("a0") = a; + register u64 a1 __asm__("a1") = b; + register u64 a2 __asm__("a2") = c; + register u64 a3 __asm__("a3") = d; + __asm__ volatile("ecall" : "+r"(a0) : "r"(a1), "r"(a2), "r"(a3), "r"(a7) : "memory"); + return (long)a0; +} +#else +#error "unsupported architecture" +#endif + +/* #! line, interpreter and argument (BINPRM_BUF_SIZE each at most), PATH_MAX. */ +static char buf[3 * 256 + 4096 + 1]; + +__attribute__((noreturn)) static void fail(const char *msg) { + u64 n = 0; + while (msg[n]) + n++; + sc4(SYS_write, 2, (u64)msg, n, 0); + sc4(SYS_exit_group, 127, 0, 0, 0); + __builtin_unreachable(); +} + +/* The next NUL-terminated field in buf[*at..end), or 0. */ +static char *field(long *at, long end) { + char *s = buf + *at; + while (*at < end && buf[*at]) + (*at)++; + if (*at >= end) + return 0; + (*at)++; + return s; +} + +/* `used`: the only reference is the module-level asm below. */ +__attribute__((used, noreturn)) static void trampoline(u64 *sp) { + long argc = (long)sp[0]; + char **argv = (char **)(sp + 1); + char **envp = argv + argc + 1; + + if (argc < 2) + fail("sandlock: shebang trampoline run directly\n"); + /* argv[1] is /proc/self/fd/, a path the supervisor wrote. */ + char *p = argv[1]; + for (char *q = p; *q; q++) + if (*q == '/') + p = q + 1; + long fd = 0; + for (; *p >= '0' && *p <= '9'; p++) + fd = fd * 10 + (*p - '0'); + long n = sc4(SYS_pread64, fd, (u64)buf, sizeof(buf) - 1, 0); + sc4(SYS_close, fd, 0, 0, 0); + if (n <= 0) + fail("sandlock: cannot read shebang launcher\n"); + + long at = 0; + while (at < n && buf[at] != '\n') + at++; + at++; + char *interp = field(&at, n); + char *arg = field(&at, n); + char *script = field(&at, n); + if (!script) + fail("sandlock: malformed shebang launcher\n"); + + char *nargv[argc + 2]; + long k = 0; + nargv[k++] = interp; + if (*arg) + nargv[k++] = arg; + nargv[k++] = script; + for (long i = 2; i < argc; i++) + nargv[k++] = argv[i]; + nargv[k] = 0; + sc4(SYS_execve, (u64)interp, (u64)nargv, (u64)envp, 0); + fail("sandlock: cannot exec #! interpreter\n"); +} + +#if defined(__x86_64__) +__asm__( + ".global _start\n" + "_start:\n" + " xor %rbp, %rbp\n" + " mov %rsp, %rdi\n" + " and $-16, %rsp\n" + " call trampoline\n" + " hlt\n" +); +#elif defined(__aarch64__) +__asm__( + ".global _start\n" + "_start:\n" + " mov x0, sp\n" + " bl trampoline\n" + " brk #0\n" +); +#elif defined(__riscv) && __riscv_xlen == 64 +__asm__( + ".global _start\n" + "_start:\n" + " mv a0, sp\n" + " call trampoline\n" + " unimp\n" +); +#endif diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 60bd1577..7a2b5a22 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -213,6 +213,10 @@ pub(crate) enum ChildEntry<'a> { /// already mapped, nothing is exec'd, and Landlock has no execve to /// authorize. `run` must not return; `confine_child` `_exit(0)`s if it does. InProcess { name: &'a CStr, run: fn() }, + /// `execveat` the image open at child fd `fd`. No path is resolved, so an + /// image with no place in the sandbox's filesystem (the embedded restore + /// stub) runs without a Landlock grant or a path rewrite. + ExecFd { fd: RawFd, argv: &'a [CString] }, } pub(crate) struct ChildSpawnArgs<'a> { @@ -596,7 +600,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { // 14. Terminal action: run the in-process entrypoint, or fall through to // execve the command. The in-process arm diverges (`_exit`), so the match // yields the command slice only on the `Exec` path. - let cmd: &[CString] = match entry { + let (cmd, exec_fd): (&[CString], Option) = match entry { ChildEntry::InProcess { name, run } => { // Name the PID-1 so ps / /proc//comm read correctly: there is // no execve here to set argv[0]. The child is a fork of the @@ -606,7 +610,8 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { run(); unsafe { libc::_exit(0) }; } - ChildEntry::Exec(cmd) => cmd, + ChildEntry::Exec(cmd) => (cmd, None), + ChildEntry::ExecFd { fd, argv } => (argv, Some(fd)), }; // 14. exec @@ -623,7 +628,21 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { .chain(std::iter::once(std::ptr::null())) .collect(); - if sandbox.chroot.is_some() { + if let Some(fd) = exec_fd { + extern "C" { + static environ: *const *const libc::c_char; + } + unsafe { + libc::syscall( + libc::SYS_execveat, + fd, + c"".as_ptr(), + argv_ptrs.as_ptr(), + environ, + libc::AT_EMPTY_PATH, + ) + }; + } else if sandbox.chroot.is_some() { // With chroot the seccomp handler rewrites the filename to a host path // (or /proc/self/fd/N). Pass a separate PATH_MAX buffer as the `file` // argument so the rewrite does not corrupt argv[0] — which must stay as @@ -644,7 +663,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // If we get here, exec failed - fail!(format!("execvp '{}'", cmd[0].to_string_lossy())); + fail!(format!("exec '{}'", cmd[0].to_string_lossy())); } // ============================================================ diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 18f8c960..7b25e98e 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -299,6 +299,8 @@ struct Runtime { stdout_pipe: Option, io_overrides: Option<(Option, Option, Option)>, extra_fds: Vec<(i32, i32)>, + /// Child fd to `execveat` instead of resolving `cmd[0]` as a path. + exec_fd: Option, http_acl_handle: Option, #[allow(clippy::type_complexity)] on_bind: Option) + Send + Sync>>, @@ -1236,13 +1238,12 @@ impl Sandbox { .into()); } - let stub = resume::stub_path(); - if !stub.exists() { - return Err(SandboxRuntimeError::Child(format!( - "restore-stub was not built ({}); a C compiler is required to build sandlock \ - with checkpoint restore", - stub.display() - )) + if resume::STUB_ELF.is_empty() { + return Err(SandboxRuntimeError::Child( + "restore-stub was not built; a C compiler is required to build sandlock \ + with checkpoint restore" + .into(), + ) .into()); } @@ -1258,15 +1259,10 @@ impl Sandbox { let channel = resume::StubChannel::new(&plan.blob) .map_err(|e| SandboxRuntimeError::Child(format!("restore control channel: {e}")))?; - // Landlock checks EXECUTE on the real path at execve time, so the stub - // binary has to be inside the policy's read+execute grant. It is a - // build artifact of sandlock itself, not workload-reachable state. - self.fs_readable.push(stub.clone()); - self.ensure_runtime()?; self.rt_mut().extra_fds = channel.extra_fds(); - let stub_s = stub.to_string_lossy().into_owned(); - self.create_interactive(&[stub_s.as_str()]).await?; + self.rt_mut().exec_fd = Some(crate::checkpoint::STUB_FD); + self.create_interactive(&["sandlock-restore-stub"]).await?; let pid = self.pid().ok_or(SandboxRuntimeError::NotRunning)?; // Release the parked child to execve the stub. From here the stub runs // confined, and its openat calls flow through the notify supervisor, @@ -1637,6 +1633,7 @@ impl Sandbox { stdout_pipe: pipe, io_overrides: None, extra_fds: Vec::new(), + exec_fd: None, http_acl_handle: None, on_bind: None, handlers: Vec::new(), @@ -1741,6 +1738,7 @@ impl Sandbox { stdout_pipe: None, io_overrides: None, extra_fds: Vec::new(), + exec_fd: None, http_acl_handle: None, on_bind: None, handlers: Vec::new(), @@ -2073,9 +2071,10 @@ impl Sandbox { // In-process entrypoint (OCI PID-1) names the process from cmd[0]; // otherwise execve the command. - let entry = match self.in_child_main { - Some(run) => context::ChildEntry::InProcess { name: c_cmd[0].as_c_str(), run }, - None => context::ChildEntry::Exec(&c_cmd), + let entry = match (self.in_child_main, self.rt().exec_fd) { + (Some(run), _) => context::ChildEntry::InProcess { name: c_cmd[0].as_c_str(), run }, + (None, Some(fd)) => context::ChildEntry::ExecFd { fd, argv: &c_cmd }, + (None, None) => context::ChildEntry::Exec(&c_cmd), }; context::confine_child(context::ChildSpawnArgs { sandbox: self, diff --git a/crates/sandlock-core/src/sys/syscall.rs b/crates/sandlock-core/src/sys/syscall.rs index 70ca08dd..a043e5b8 100644 --- a/crates/sandlock-core/src/sys/syscall.rs +++ b/crates/sandlock-core/src/sys/syscall.rs @@ -1,6 +1,6 @@ use std::ffi::CString; use std::io; -use std::os::unix::io::{FromRawFd, OwnedFd}; +use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; use super::structs::{ LandlockRulesetAttr, SYS_LANDLOCK_ADD_RULE, SYS_LANDLOCK_CREATE_RULESET, @@ -228,3 +228,29 @@ pub fn memfd_create(name: &str, flags: u32) -> io::Result { }; Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) }) } + +/// A memfd that will be exec'd. `MFD_EXEC` keeps a `vm.memfd_noexec=1` host +/// from sealing it non-executable; kernels before 6.3 reject the flag, and +/// their memfds are always executable. +pub fn memfd_create_exec(name: &str, flags: u32) -> io::Result { + memfd_create(name, flags | libc::MFD_EXEC).or_else(|e| match e.raw_os_error() { + Some(libc::EINVAL) => memfd_create(name, flags), + _ => Err(e), + }) +} + +/// A sealed, close-on-exec, executable memfd holding `bytes`. +pub fn sealed_exec_memfd(name: &str, bytes: &[u8]) -> io::Result { + use std::io::Write; + + let mut file = std::fs::File::from(memfd_create_exec( + name, + libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING, + )?); + file.write_all(bytes)?; + let seals = libc::F_SEAL_SEAL | libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK; + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(file.into()) +} diff --git a/crates/sandlock-core/tests/integration/test_chroot.rs b/crates/sandlock-core/tests/integration/test_chroot.rs index d537ae51..bf6f2ddf 100644 --- a/crates/sandlock-core/tests/integration/test_chroot.rs +++ b/crates/sandlock-core/tests/integration/test_chroot.rs @@ -2348,3 +2348,120 @@ async fn test_chroot_cow_symlink_stays_inside_the_rootfs() { let _ = fs::remove_dir_all(&host_dir); cleanup_rootfs(&rootfs); } + +/// fexecve names the image by fd, so the exec handler has no path to confine. +/// Both an fd to a file inside the rootfs and a memfd copy, which has no path +/// anywhere, must run instead of failing with EACCES. +#[tokio::test] +async fn test_chroot_fexecve_runs_an_image_held_by_fd() { + let rootfs = build_test_rootfs("fexecve"); + let policy = minimal_exec_policy(&rootfs).build().unwrap(); + + for source in ["fd", "memfd"] { + let out = format!("fexecve-{source}-ok"); + let r = policy + .clone() + .run(&[ + "/usr/bin/rootfs-helper", "fexecve", "/usr/bin/rootfs-helper", source, + "rootfs-helper", "echo", out.as_str(), + ]) + .await + .unwrap(); + assert!( + r.success() && r.stdout_str().unwrap_or("").contains(&out), + "fexecve from {source} should run the image, exit={:?} stderr: {}", + r.code(), + r.stderr_str().unwrap_or(""), + ); + } + + cleanup_rootfs(&rootfs); +} + +fn install_script(rootfs: &PathBuf, name: &str, body: &str) { + let path = rootfs.join("usr/bin").join(name); + fs::write(&path, body).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +/// The kernel opens a script's `#!` interpreter itself, against the host root. +/// It must be the image's, and see the argv the kernel would give it, nested +/// scripts included: rootfs-helper dispatches on argv[0] like busybox. +#[tokio::test] +async fn test_chroot_shebang_runs_the_image_interpreter() { + let rootfs = build_test_rootfs("shebang-image"); + install_script(&rootfs, "inner", "#!/usr/bin/rootfs-helper echo\n"); + install_script(&rootfs, "outer", "#!/usr/bin/inner\n"); + let policy = minimal_exec_policy(&rootfs).build().unwrap(); + + for (script, want) in [ + ("/usr/bin/inner", "/usr/bin/inner a b"), + ("/usr/bin/outer", "/usr/bin/inner /usr/bin/outer a b"), + ] { + let r = policy.clone().run(&[script, "a", "b"]).await.unwrap(); + assert!( + r.success() && r.stdout_str().unwrap_or("").trim_end() == want, + "{script} should print {want:?}, exit={:?} stdout: {:?} stderr: {}", + r.code(), + r.stdout_str(), + r.stderr_str().unwrap_or(""), + ); + } + cleanup_rootfs(&rootfs); +} + +/// A shebang naming the rootfs by its host path must not reach the host file. +#[tokio::test] +async fn test_chroot_shebang_does_not_resolve_on_the_host() { + let rootfs = build_test_rootfs("shebang-host"); + let host_helper = rootfs.join("usr/bin/rootfs-helper").canonicalize().unwrap(); + install_script(&rootfs, "probe", &format!("#!{} echo\n", host_helper.display())); + let policy = minimal_exec_policy(&rootfs).build().unwrap(); + + let r = policy.clone().run(&["/usr/bin/probe", "escaped"]).await.unwrap(); + assert!( + !r.success() && !r.stdout_str().unwrap_or("").contains("escaped"), + "host-path interpreter must not run, exit={:?} stdout: {}", + r.code(), + r.stdout_str().unwrap_or(""), + ); + cleanup_rootfs(&rootfs); +} + +/// The interpreter reads the script itself, unmodified, by the path it was +/// exec'd under. +#[tokio::test] +async fn test_chroot_shebang_interpreter_reads_the_original_script() { + let rootfs = build_test_rootfs("shebang-selfcat"); + let script = "#!/usr/bin/rootfs-helper cat\nbody\n"; + install_script(&rootfs, "selfcat", script); + let policy = minimal_exec_policy(&rootfs).build().unwrap(); + + let r = policy.clone().run(&["/usr/bin/selfcat"]).await.unwrap(); + assert!( + r.success() && r.stdout_str().unwrap_or("").trim_end() == script.trim_end(), + "cat should print the original script, exit={:?} stdout: {:?} stderr: {}", + r.code(), + r.stdout_str(), + r.stderr_str().unwrap_or(""), + ); + cleanup_rootfs(&rootfs); +} + +/// A script that is its own interpreter fails with ELOOP, as under the +/// kernel, rather than re-exec'ing forever. +#[tokio::test] +async fn test_chroot_shebang_loop_is_eloop() { + let rootfs = build_test_rootfs("shebang-loop"); + install_script(&rootfs, "loop", "#!/usr/bin/loop\n"); + let policy = minimal_exec_policy(&rootfs).build().unwrap(); + + let r = policy.clone().run(&["/usr/bin/loop"]).await.unwrap(); + let stderr = r.stderr_str().unwrap_or("").to_string(); + assert!( + !r.success() && stderr.contains("Too many levels of symbolic links"), + "self-interpreting script must fail with ELOOP, exit={:?} stderr: {stderr}", + r.code(), + ); + cleanup_rootfs(&rootfs); +} diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index c1d23f16..7c28733a 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -214,3 +214,62 @@ async fn test_restore_glibc_vdso_program_resumes() { strays.len(), ); } + +/// Restore under chroot. The stub reaches the child as a memfd, which has no +/// path inside the rootfs, so the chroot exec handler must accept an image +/// named by fd. +#[tokio::test] +async fn test_restore_resumes_inside_a_chroot() { + if cfg!(not(any(target_arch = "x86_64", target_arch = "riscv64"))) { + eprintln!("skipping: the restore engine is x86_64/riscv64 only"); + return; + } + + let rootfs = std::env::temp_dir().join(format!("sandlock-restore-chroot-{}", std::process::id())); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::create_dir_all(rootfs.join("tmp")).unwrap(); + std::fs::copy(helper_binary(), rootfs.join("usr/bin/rootfs-helper")).unwrap(); + let counter = rootfs.join("tmp/clock.cnt"); + let read_counter = || -> Option { + std::fs::read_to_string(&counter).ok().and_then(|s| s.trim().parse().ok()) + }; + + let policy = Sandbox::builder() + .chroot(&rootfs) + .fs_read("/usr") + .fs_read("/tmp") + .fs_write("/tmp") + .build().unwrap(); + + let mut sb = policy.clone().with_name("chroot-restore-src"); + { + let _stdio = StdioRedirect::to_file(&rootfs.join("tmp/helper.log")); + sb.spawn_interactive(&["/usr/bin/rootfs-helper", "clock-loop", "/tmp/clock.cnt"]) + .await.unwrap(); + } + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + let cp = sb.checkpoint().await.unwrap(); + let baseline = read_counter().expect("counter file should exist with a value"); + sb.kill().unwrap(); + let _ = sb.wait().await; + + std::fs::write(&counter, b"0\n").unwrap(); + let mut sb2 = policy.clone().with_name("chroot-restore-dst"); + let restored = sb2.restore_interactive(&cp).await.map(|_| ()); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut advanced = false; + while restored.is_ok() && std::time::Instant::now() < deadline { + if read_counter().is_some_and(|v| v > baseline) { + advanced = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let _ = sb2.kill(); + let _ = sb2.wait().await; + let _ = std::fs::remove_dir_all(&rootfs); + + restored.expect("restore under chroot"); + assert!(advanced, "restored process must resume inside the chroot past baseline {baseline}"); +} diff --git a/tests/rootfs-helper.c b/tests/rootfs-helper.c index 1163dce8..abe0659c 100644 --- a/tests/rootfs-helper.c +++ b/tests/rootfs-helper.c @@ -851,6 +851,32 @@ static int cmd_write_fd_link(int argc, char **argv) { return 0; } +/* ── fexecve (exec an image by fd, no path) ─────────────────── */ +/* + * fexecve fd|memfd : exec through an fd rather than its + * name, either the file itself or a memfd copy of it, with argv . + */ +static int cmd_fexecve(int argc, char **argv) { + if (argc < 3) { fprintf(stderr, "fexecve: need fd|memfd \n"); return 1; } + int fd = open(argv[0], O_RDONLY); + if (fd < 0) { fprintf(stderr, "fexecve: open %s: %s\n", argv[0], strerror(errno)); return 1; } + if (strcmp(argv[1], "memfd") == 0) { + int mfd = syscall(SYS_memfd_create, "fexecve-copy", 0); + if (mfd < 0) { fprintf(stderr, "fexecve: memfd_create: %s\n", strerror(errno)); return 1; } + char buf[65536]; + ssize_t n; + while ((n = read(fd, buf, sizeof(buf))) > 0) { + if (write(mfd, buf, n) != n) { fprintf(stderr, "fexecve: copy: %s\n", strerror(errno)); return 1; } + } + close(fd); + fd = mfd; + } + extern char **environ; + syscall(SYS_execveat, fd, "", &argv[2], environ, AT_EMPTY_PATH); + fprintf(stderr, "fexecve: execveat: %s\n", strerror(errno)); + return 1; +} + /* ── dispatch ───────────────────────────────────────────────── */ static int dispatch(const char *cmd, int argc, char **argv) { @@ -887,6 +913,7 @@ static int dispatch(const char *cmd, int argc, char **argv) { if (strcmp(cmd, "fstat-fd") == 0) return cmd_fstat_fd(argc, argv); if (strcmp(cmd, "spawn-loop") == 0) return cmd_spawn_loop(argc, argv); if (strcmp(cmd, "clock-loop") == 0) return cmd_clock_loop(argc, argv); + if (strcmp(cmd, "fexecve") == 0) return cmd_fexecve(argc, argv); if (strcmp(cmd, "true") == 0) return 0; if (strcmp(cmd, "false") == 0) return 1;