| //! The Linux backend: namespaces, natively and unprivileged. | |
| //! | |
| //! The child unshares user, mount, network, uts and ipc namespaces, maps itself to the | |
| //! caller's uid, builds a new root on a tmpfs from the plan's bind mounts, pivots into it, | |
| //! and execs the relay. The relay (`amem relay`) then unshares a pid namespace and | |
| //! spawns the harness, mounting `/proc` from inside it, so the harness is pid 1 of its | |
| //! own world and sees no other process. | |
| //! | |
| //! No network interface exists inside except loopback. The only way out is the unix | |
| //! socket bind-mounted from the host, which the relay forwards to. The last step before | |
| //! the exec sets `no_new_privs` and installs a seccomp filter that refuses | |
| //! what a network namespace does not cover: vsock and io_uring. | |
| mod seccomp; | |
| use crate::{Error, Mount, Plan, Resources}; | |
| use nix::mount::{mount, MsFlags}; | |
| use nix::sched::{unshare, CloneFlags}; | |
| use nix::unistd::{getgid, getuid, pivot_root, sethostname}; | |
| use std::ffi::OsString; | |
| use std::os::unix::process::CommandExt; | |
| use std::path::{Path, PathBuf}; | |
| use std::process::Command; | |
| fn confine(e: impl std::fmt::Display) -> Error { | |
| Error::Confine(e.to_string()) | |
| } | |
| fn write_file(path: &str, text: &str) -> Result<(), Error> { | |
| std::fs::write(path, text).map_err(|e| confine(format!("{path}: {e}"))) | |
| } | |
| /// Become root in a fresh user namespace, mapped to the caller outside. | |
| fn enter_user_namespace(uid: u32, gid: u32) -> Result<(), Error> { | |
| unshare( | |
| CloneFlags::CLONE_NEWUSER | |
| | CloneFlags::CLONE_NEWNS | |
| | CloneFlags::CLONE_NEWNET | |
| | CloneFlags::CLONE_NEWUTS | |
| | CloneFlags::CLONE_NEWIPC, | |
| ) | |
| .map_err(|e| confine(format!("unshare: {e}")))?; | |
| write_file("/proc/self/setgroups", "deny")?; | |
| write_file("/proc/self/uid_map", &format!("{uid} {uid} 1\n"))?; | |
| write_file("/proc/self/gid_map", &format!("{gid} {gid} 1\n"))?; | |
| Ok(()) | |
| } | |
| /// Why this host will not let an unprivileged process confine anything, in a sentence | |
| /// that names the fix. | |
| fn restricted_why() -> String { | |
| let what = "this host will not let an unprivileged process enter a user namespace"; | |
| let apparmor = | |
| std::fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") | |
| .is_ok_and(|v| v.trim() == "1"); | |
| if apparmor { | |
| format!( | |
| "{what}: kernel.apparmor_restrict_unprivileged_userns is 1, so AppArmor moves a \ | |
| binary that has no profile of its own into one without CAP_SYS_ADMIN. Give amem a \ | |
| profile that allows it: packaging/apparmor/amem" | |
| ) | |
| } else { | |
| format!("{what}; --unconfined runs the harness without a sandbox") | |
| } | |
| } | |
| /// Why the sandbox's seccomp filter cannot be installed. A container runtime's profile | |
| /// can refuse `seccomp(2)` itself. | |
| const NO_SECCOMP: &str = "the sandbox cannot install its seccomp filter here: it needs a \ | |
| kernel with seccomp filters, and a seccomp profile that allows \ | |
| seccomp(2)"; | |
| /// Why a read-only grant cannot be made read-only below its submounts. A kernel before | |
| /// Linux 5.12 has no `mount_setattr`, and a container runtime's seccomp profile that does | |
| /// not allow the call answers it with `ENOSYS` or `EPERM`. | |
| const NO_MOUNT_SETATTR: &str = "mount_setattr is not available here: it needs Linux 5.12 or \ | |
| later, and a seccomp profile that allows it"; | |
| /// Whether `mount_setattr` answers here, asked with an empty path and a size below the | |
| /// first `mount_attr`: a kernel that has the call refuses that size with `EINVAL` before | |
| /// it checks anything else, so any other answer is a filter in the way. | |
| fn has_mount_setattr() -> bool { | |
| // SAFETY: an empty NUL-terminated path, no mount_attr, and a size of zero; the kernel | |
| // refuses the call before it reads either pointer. | |
| let r = unsafe { | |
| libc::syscall( | |
| libc::SYS_mount_setattr, | |
| -1, | |
| c"".as_ptr(), | |
| 0, | |
| std::ptr::null::<libc::mount_attr>(), | |
| 0, | |
| ) | |
| }; | |
| r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL) | |
| } | |
| /// Whether this host lets an unprivileged process build the sandbox at all: can it | |
| /// create a user namespace and map itself into it, make a read-only grant read-only | |
| /// below its submounts, and install the seccomp filter? | |
| /// | |
| /// Probed in a child, because the answer is a namespace the asking process would | |
| /// otherwise be stuck in, and by calling [`enter_user_namespace`] itself rather than a | |
| /// copy of it: a probe that unshares a different set of namespaces, or writes the maps | |
| /// in a different order, can disagree with the launch it is meant to predict, and a | |
| /// check that says a working host is broken is worse than no check. The first cut of | |
| /// this did exactly that. | |
| /// | |
| /// A sentence rather than a flag, since what to do differs: a kernel without user | |
| /// namespaces is one thing, Ubuntu's AppArmor moving an unprofiled binary into a profile | |
| /// without CAP_SYS_ADMIN is another, and the second is fixed by installing a profile | |
| /// rather than by giving up on confinement. | |
| /// | |
| /// The child also asks for `mount_setattr`, without which every launch fails in | |
| /// [`read_only_below`]. | |
| /// | |
| /// The child also installs the launch's seccomp filter, compiled before the fork as a | |
| /// launch compiles it. | |
| /// | |
| /// # Errors | |
| /// | |
| /// Why not, in a sentence `amem doctor` and the launch line can carry. | |
| pub fn availability() -> Result<(), String> { | |
| let filter = seccomp::program().map_err(|e| e.to_string())?; | |
| // SAFETY: fork in a process that only waits for the child; the child makes the same | |
| // syscalls a launch does and _exits without running atexit handlers, which belong to | |
| // the parent. | |
| let pid = unsafe { libc::fork() }; | |
| if pid < 0 { | |
| return Err(format!("fork: {}", std::io::Error::last_os_error())); | |
| } | |
| if pid == 0 { | |
| let code = if enter_user_namespace(getuid().as_raw(), getgid().as_raw()).is_err() { | |
| 1 | |
| } else if !has_mount_setattr() { | |
| 2 | |
| } else if seccomp::install(&filter).is_err() { | |
| 3 | |
| } else { | |
| 0 | |
| }; | |
| // SAFETY: exiting the probe child without running the parent's atexit handlers. | |
| unsafe { libc::_exit(code) }; | |
| } | |
| let mut status: libc::c_int = 0; | |
| // SAFETY: waitpid on our own child with a valid status pointer. | |
| if unsafe { libc::waitpid(pid, &mut status, 0) } < 0 { | |
| return Err(format!("waitpid: {}", std::io::Error::last_os_error())); | |
| } | |
| if !libc::WIFEXITED(status) { | |
| return Err("the user-namespace probe did not exit normally".to_string()); | |
| } | |
| match libc::WEXITSTATUS(status) { | |
| 0 => Ok(()), | |
| 2 => Err(NO_MOUNT_SETATTR.to_string()), | |
| 3 => Err(NO_SECCOMP.to_string()), | |
| _ => Err(restricted_why()), | |
| } | |
| } | |
| fn bind(m: &Mount, root: &Path) -> Result<(), Error> { | |
| let target = root.join(m.target.strip_prefix("/").unwrap_or(&m.target)); | |
| if m.source.is_dir() { | |
| std::fs::create_dir_all(&target) | |
| .map_err(|e| confine(format!("{}: {e}", target.display())))?; | |
| } else { | |
| if let Some(p) = target.parent() { | |
| std::fs::create_dir_all(p).map_err(|e| confine(format!("{}: {e}", p.display())))?; | |
| } | |
| if !target.exists() { | |
| std::fs::write(&target, b"") | |
| .map_err(|e| confine(format!("{}: {e}", target.display())))?; | |
| } | |
| } | |
| mount( | |
| Some(&m.source), | |
| &target, | |
| None::<&str>, | |
| MsFlags::MS_BIND | MsFlags::MS_REC, | |
| None::<&str>, | |
| ) | |
| .map_err(|e| { | |
| confine(format!( | |
| "bind {} -> {}: {e}", | |
| m.source.display(), | |
| target.display() | |
| )) | |
| })?; | |
| if m.read_only { | |
| mount( | |
| None::<&str>, | |
| &target, | |
| None::<&str>, | |
| MsFlags::MS_BIND | |
| | MsFlags::MS_REMOUNT | |
| | MsFlags::MS_RDONLY | |
| | MsFlags::MS_NOSUID | |
| | kept_flags(&target), | |
| None::<&str>, | |
| ) | |
| .map_err(|e| confine(format!("remount ro {}: {e}", target.display())))?; | |
| read_only_below(&target)?; | |
| } | |
| Ok(()) | |
| } | |
| /// Make every mount under `target` read-only and nosuid as well. A bind with `MS_REC` | |
| /// copies the source's submounts, `/usr/lib/wsl/lib` under `/usr` on WSL2 say, and the | |
| /// remount reaches the top mount only. `mount_setattr(2)` with `AT_RECURSIVE` sets the | |
| /// flags on the whole tree at once, a submount another mount hides included. | |
| /// | |
| /// Where `mount_setattr` is not available, [`NO_MOUNT_SETATTR`], the launch fails rather | |
| /// than leave a submount writable under a read-only grant. | |
| fn read_only_below(target: &Path) -> Result<(), Error> { | |
| use std::os::unix::ffi::OsStrExt; | |
| let path = std::ffi::CString::new(target.as_os_str().as_bytes()) | |
| .map_err(|e| confine(format!("{}: {e}", target.display())))?; | |
| let attr = libc::mount_attr { | |
| attr_set: libc::MOUNT_ATTR_RDONLY | libc::MOUNT_ATTR_NOSUID, | |
| attr_clr: 0, | |
| propagation: 0, | |
| userns_fd: 0, | |
| }; | |
| // SAFETY: a NUL-terminated path and a mount_attr passed with its own size; the kernel | |
| // only reads both. | |
| let r = unsafe { | |
| libc::syscall( | |
| libc::SYS_mount_setattr, | |
| libc::AT_FDCWD, | |
| path.as_ptr(), | |
| libc::AT_RECURSIVE, | |
| &attr, | |
| std::mem::size_of::<libc::mount_attr>(), | |
| ) | |
| }; | |
| if r == 0 { | |
| return Ok(()); | |
| } | |
| let e = std::io::Error::last_os_error(); | |
| if e.raw_os_error() == Some(libc::ENOSYS) { | |
| return Err(confine(format!( | |
| "cannot make the mounts under {} read-only: {NO_MOUNT_SETATTR}", | |
| target.display() | |
| ))); | |
| } | |
| Err(confine(format!( | |
| "mount_setattr ro {}: {e}", | |
| target.display() | |
| ))) | |
| } | |
| /// The restricting flags a mount already carries, to carry into its read-only remount. | |
| /// A mount made outside the user namespace, a FUSE mount the launcher serves say, has | |
| /// its `nodev`, `noatime` and the like locked by the kernel: a remount that leaves one | |
| /// out asks to lift it and is refused, so they are read back and kept. | |
| fn kept_flags(target: &Path) -> MsFlags { | |
| use nix::sys::statvfs::{statvfs, FsFlags}; | |
| let Ok(st) = statvfs(target) else { | |
| return MsFlags::empty(); | |
| }; | |
| let have = st.flags(); | |
| let pairs = [ | |
| (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID), | |
| (FsFlags::ST_NODEV, MsFlags::MS_NODEV), | |
| (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC), | |
| (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME), | |
| (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME), | |
| (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME), | |
| ]; | |
| pairs | |
| .into_iter() | |
| .filter(|(st, _)| have.contains(*st)) | |
| .fold(MsFlags::empty(), |acc, (_, ms)| acc | ms) | |
| } | |
| fn tmpfs(path: &Path, opts: &str) -> Result<(), Error> { | |
| std::fs::create_dir_all(path).map_err(|e| confine(format!("{}: {e}", path.display())))?; | |
| mount( | |
| Some("tmpfs"), | |
| path, | |
| Some("tmpfs"), | |
| MsFlags::MS_NOSUID | MsFlags::MS_NODEV, | |
| Some(opts), | |
| ) | |
| .map_err(|e| confine(format!("tmpfs {}: {e}", path.display()))) | |
| } | |
| fn dev_nodes(root: &Path) -> Result<(), Error> { | |
| let dev = root.join("dev"); | |
| tmpfs(&dev, "mode=755,size=1m")?; | |
| for n in ["null", "zero", "full", "random", "urandom", "tty"] { | |
| let src = PathBuf::from("/dev").join(n); | |
| if src.exists() { | |
| bind( | |
| &Mount { | |
| source: src, | |
| target: PathBuf::from("/dev").join(n), | |
| read_only: false, | |
| }, | |
| root, | |
| )?; | |
| } | |
| } | |
| // pseudo-terminals, so an interactive harness can open the one it was given | |
| let pts = PathBuf::from("/dev/pts"); | |
| if pts.exists() { | |
| bind( | |
| &Mount { | |
| source: pts, | |
| target: PathBuf::from("/dev/pts"), | |
| read_only: false, | |
| }, | |
| root, | |
| )?; | |
| std::os::unix::fs::symlink("pts/ptmx", dev.join("ptmx")).ok(); | |
| } | |
| tmpfs(&dev.join("shm"), "mode=1777,size=64m")?; | |
| for (link, to) in [ | |
| ("fd", "/proc/self/fd"), | |
| ("stdin", "/proc/self/fd/0"), | |
| ("stdout", "/proc/self/fd/1"), | |
| ("stderr", "/proc/self/fd/2"), | |
| ] { | |
| std::os::unix::fs::symlink(to, dev.join(link)).ok(); | |
| } | |
| Ok(()) | |
| } | |
| /// Bring the loopback interface up. A fresh network namespace has `lo` down, and the | |
| /// relay listens on it. | |
| fn loopback_up() -> Result<(), Error> { | |
| // SAFETY: a datagram socket and two ioctls on a zeroed, correctly sized ifreq whose | |
| // name is "lo"; the socket is closed before returning. | |
| unsafe { | |
| let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0); | |
| if fd < 0 { | |
| return Err(confine(format!( | |
| "socket: {}", | |
| std::io::Error::last_os_error() | |
| ))); | |
| } | |
| let mut req: libc::ifreq = std::mem::zeroed(); | |
| for (i, b) in b"lo".iter().enumerate() { | |
| req.ifr_name[i] = *b as libc::c_char; | |
| } | |
| let r = libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req); | |
| if r < 0 { | |
| libc::close(fd); | |
| return Err(confine(format!( | |
| "SIOCGIFFLAGS lo: {}", | |
| std::io::Error::last_os_error() | |
| ))); | |
| } | |
| req.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short; | |
| let r = libc::ioctl(fd, libc::SIOCSIFFLAGS, &req); | |
| libc::close(fd); | |
| if r < 0 { | |
| return Err(confine(format!( | |
| "SIOCSIFFLAGS lo: {}", | |
| std::io::Error::last_os_error() | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Build the root and pivot into it. Runs in the child, after `enter_user_namespace`. | |
| fn build_root(plan: &Plan, root: &Path) -> Result<(), Error> { | |
| // make every mount private so nothing propagates back to the host | |
| mount( | |
| None::<&str>, | |
| "/", | |
| None::<&str>, | |
| MsFlags::MS_REC | MsFlags::MS_PRIVATE, | |
| None::<&str>, | |
| ) | |
| .map_err(|e| confine(format!("make / private: {e}")))?; | |
| tmpfs(root, "mode=755")?; | |
| // /tmp and /dev first, so a plan mount under them (a working tree in /tmp, say) lands | |
| // on top rather than being hidden by them | |
| tmpfs(&root.join("tmp"), "mode=1777")?; | |
| dev_nodes(root)?; | |
| for m in &plan.mounts { | |
| bind(m, root)?; | |
| } | |
| // merged-usr: /bin -> usr/bin and so on, recreated so `#!/bin/sh` resolves inside; | |
| // the plan took them from the rootfs it runs in | |
| for (name, target) in &plan.links { | |
| let inside = root.join(name); | |
| if !inside.exists() { | |
| std::os::unix::fs::symlink(target, &inside) | |
| .map_err(|e| confine(format!("symlink {}: {e}", inside.display())))?; | |
| } | |
| } | |
| std::fs::create_dir_all(root.join("proc")).map_err(confine)?; | |
| // The procfs for the harness's pid namespace, mounted here rather than over /proc | |
| // after the pivot. `mount -t proc` from a non-initial user namespace is refused | |
| // unless a fully visible procfs already exists in this mount namespace: | |
| // fs/proc/root.c, mount_too_revealing(). The host's is still visible at this point | |
| // and satisfies that; after pivot_root detaches the old root, nothing does, and the | |
| // mount fails with EPERM on kernels that enforce the check (seen on 6.17). | |
| // | |
| // Binding the host's procfs instead is not an option: the kernel refuses to clone | |
| // it into the new namespace at all, recursively or not, with EINVAL. | |
| // | |
| // This is why the pid namespace is entered before the root is built: the mount | |
| // shows the pid namespace it was made in, so it has to come after the fork. | |
| mount( | |
| Some("proc"), | |
| &root.join("proc"), | |
| Some("proc"), | |
| MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC, | |
| None::<&str>, | |
| ) | |
| .map_err(|e| confine(format!("mount proc: {e}")))?; | |
| std::fs::create_dir_all(root.join("run")).map_err(confine)?; | |
| let old = root.join(".old-root"); | |
| std::fs::create_dir_all(&old).map_err(confine)?; | |
| pivot_root(root, &old).map_err(|e| confine(format!("pivot_root: {e}")))?; | |
| std::env::set_current_dir("/").map_err(confine)?; | |
| nix::mount::umount2("/.old-root", nix::mount::MntFlags::MNT_DETACH) | |
| .map_err(|e| confine(format!("umount old root: {e}")))?; | |
| std::fs::remove_dir("/.old-root").ok(); | |
| std::env::set_current_dir(&plan.cwd) | |
| .map_err(|e| confine(format!("chdir {}: {e}", plan.cwd.display())))?; | |
| Ok(()) | |
| } | |
| /// Run the plan: fork, confine the child, exec the relay. Returns the exit code and what | |
| /// the confined tree cost the host. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the sandbox cannot be built or the relay cannot be started. | |
| pub fn run(plan: &Plan) -> Result<(i32, Resources), Error> { | |
| let (code, _, r) = measured(plan, false)?; | |
| Ok((code, r)) | |
| } | |
| /// As [`run`], with the harness's standard output captured; standard error is inherited. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the sandbox cannot be built or the relay cannot be started. | |
| pub fn run_captured(plan: &Plan) -> Result<(i32, Vec<u8>, Resources), Error> { | |
| measured(plan, true) | |
| } | |
| fn measured(plan: &Plan, capture: bool) -> Result<(i32, Vec<u8>, Resources), Error> { | |
| let scratch = crate::scratch::Scratch::make(&std::env::temp_dir())?; | |
| let (mut cmd, _) = command(plan)?; | |
| let ran = crate::measure::run(&mut cmd, capture) | |
| .map_err(|e| confine(format!("spawn {}: {e}", plan.argv[0]))); | |
| drop(scratch); | |
| ran | |
| } | |
| /// The command that builds the sandbox in `pre_exec` and execs the relay, and the | |
| /// scratch directory it pivots into, which the caller holds as a [`crate::scratch::Scratch`]. | |
| fn command(plan: &Plan) -> Result<(Command, PathBuf), Error> { | |
| let uid = getuid().as_raw(); | |
| let gid = getgid().as_raw(); | |
| // made and locked by the caller's Scratch; not made again here, where it would | |
| // not be locked | |
| let root = crate::scratch::dir(&std::env::temp_dir()); | |
| let launcher = nix::unistd::getpid(); | |
| let plan2 = plan.clone(); | |
| let root2 = root.clone(); | |
| // compiled here, since compiling allocates and installing it does not | |
| let filter = seccomp::program()?; | |
| let open_files = open_files_limit(); | |
| let mut cmd = Command::new(&plan.argv[0]); | |
| cmd.args(&plan.argv[1..]); | |
| cmd.env_clear(); | |
| for (k, v) in &plan.env { | |
| cmd.env(k, v); | |
| } | |
| cmd.current_dir(&plan.cwd); | |
| // SAFETY: everything in pre_exec is syscalls and small allocations in a fresh | |
| // single-threaded child; nothing touches the parent's state. | |
| unsafe { | |
| cmd.pre_exec(move || { | |
| let go = || -> Result<(), Error> { | |
| enter_user_namespace(uid, gid)?; | |
| sethostname(&plan2.hostname).map_err(|e| confine(format!("hostname: {e}")))?; | |
| loopback_up()?; | |
| // the run ends with the launcher, however the launcher ends | |
| die_with_parent(launcher)?; | |
| // the intermediate, as pid 1 can still tell it after the fork: its | |
| // parent is outside its namespace, and getppid reads 0 there | |
| let intermediate = pidfd_of_self(); | |
| // before build_root: it mounts this namespace's procfs, which must be | |
| // made after the fork that enters the namespace and before pivot_root | |
| // takes the host's procfs out of view | |
| become_pid_one()?; | |
| // pid 1: the namespace, and the harness in it, ends with the intermediate | |
| die_with_intermediate(intermediate)?; | |
| build_root(&plan2, &root2)?; | |
| Ok(()) | |
| }; | |
| go().map_err(|e| { | |
| // std carries only the raw errno back to the parent, so say what | |
| // failed here, where stderr is still the terminal | |
| eprintln!("amem: sandbox: {e}"); | |
| std::io::Error::other(e.to_string()) | |
| })?; | |
| cloexec_from_three(open_files) | |
| .inspect_err(|_| say(b"cannot mark inherited descriptors close-on-exec"))?; | |
| // last, so the setup above may make calls the filter refuses | |
| seccomp::install(&filter).inspect_err(|_| say(NO_SECCOMP.as_bytes())) | |
| }); | |
| } | |
| Ok((cmd, root)) | |
| } | |
| /// Say what failed between fork and exec, on stderr, without allocating. | |
| fn say(what: &[u8]) { | |
| for part in [b"amem: sandbox: ".as_slice(), what, b"\n"] { | |
| // SAFETY: a write of a buffer with its own length to stderr. | |
| unsafe { libc::write(2, part.as_ptr().cast(), part.len()) }; | |
| } | |
| } | |
| /// The soft limit on open files, the highest descriptor number this process can hold. | |
| fn open_files_limit() -> libc::c_int { | |
| let mut limit = libc::rlimit { | |
| rlim_cur: 0, | |
| rlim_max: 0, | |
| }; | |
| // SAFETY: getrlimit with a valid pointer to write into. | |
| if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) } != 0 { | |
| return 1024; | |
| } | |
| libc::c_int::try_from(limit.rlim_cur).unwrap_or(libc::c_int::MAX) | |
| } | |
| /// Mark every descriptor from 3 up close-on-exec, so the harness inherits only its | |
| /// standard streams. A socket the launcher left open would otherwise work inside: a | |
| /// vsock socket made outside the filter reaches the host. The launcher passes nothing | |
| /// into the sandbox as a descriptor; the egress socket is bind-mounted as a path. | |
| /// | |
| /// `close_range` with `CLOSE_RANGE_CLOEXEC` (Linux 5.11) does it in one call. Before | |
| /// that, `fcntl` sets `FD_CLOEXEC` on each number below `limit`, the open-files limit | |
| /// read before the fork. Allocates nothing. | |
| fn cloexec_from_three(limit: libc::c_int) -> std::io::Result<()> { | |
| // SAFETY: close_range with integer arguments only. | |
| let r = unsafe { | |
| libc::syscall( | |
| libc::SYS_close_range, | |
| 3, | |
| libc::c_uint::MAX, | |
| libc::CLOSE_RANGE_CLOEXEC, | |
| ) | |
| }; | |
| if r == 0 { | |
| return Ok(()); | |
| } | |
| let e = std::io::Error::last_os_error(); | |
| // ENOSYS before Linux 5.9, EINVAL for the flag before 5.11 | |
| if !matches!(e.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) { | |
| return Err(e); | |
| } | |
| for fd in 3..limit { | |
| // SAFETY: fcntl on a descriptor number; one that is not open fails with EBADF | |
| // and has nothing to mark. | |
| unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }; | |
| } | |
| Ok(()) | |
| } | |
| /// Enter a pid namespace and become its pid 1. Runs between fork and exec, while the | |
| /// process still holds the user namespace's capabilities (exec of a non-root uid drops | |
| /// them, which is why the relay cannot do this itself). | |
| /// | |
| /// `unshare(CLONE_NEWPID)` only moves the caller's *children* into the new namespace, so | |
| /// this forks once more: the parent waits for the child and exits with its status; the | |
| /// child, now pid 1, goes on to build the root and exec the relay. | |
| /// | |
| /// Called before [`build_root`], which mounts the procfs for this namespace while the | |
| /// host's is still visible. A procfs mount shows the pid namespace it was made in, so it | |
| /// has to happen after this fork, and the visibility the kernel demands of it is gone | |
| /// once `pivot_root` has detached the old root -- which leaves only the window between | |
| /// the two. | |
| fn become_pid_one() -> Result<(), Error> { | |
| unshare(CloneFlags::CLONE_NEWPID).map_err(|e| confine(format!("unshare pid: {e}")))?; | |
| // SAFETY: fork between fork and exec in an already single-threaded child; the parent | |
| // branch only closes descriptors, waits and exits, the child branch only mounts and | |
| // returns to exec. | |
| let pid = unsafe { libc::fork() }; | |
| if pid < 0 { | |
| return Err(confine(format!( | |
| "fork: {}", | |
| std::io::Error::last_os_error() | |
| ))); | |
| } | |
| if pid > 0 { | |
| // The intermediate never execs, so its copies of the launcher's descriptors | |
| // stay open until it exits: among them std's exec-error channel, whose end of | |
| // file is what lets `spawn` return. The child, pid 1, keeps its own copies and | |
| // reports through that channel as before. | |
| close_from_three(); | |
| let mut status: libc::c_int = 0; | |
| // SAFETY: waitpid on our own child with a valid status pointer. | |
| let r = unsafe { libc::waitpid(pid, &mut status, 0) }; | |
| // the code as crate::measure::exit_code gives it, said without allocating | |
| let code = if r < 0 { | |
| 1 | |
| } else if libc::WIFSIGNALED(status) { | |
| let signal = libc::WTERMSIG(status); | |
| if crate::measure::is_said(signal) { | |
| say_the_relay_died(signal, libc::WCOREDUMP(status)); | |
| } | |
| 128 + signal | |
| } else if libc::WIFEXITED(status) { | |
| libc::WEXITSTATUS(status) | |
| } else { | |
| 1 | |
| }; | |
| // SAFETY: exiting the intermediate process without running atexit handlers, | |
| // which belong to the parent that forked us. | |
| unsafe { libc::_exit(code) }; | |
| } | |
| Ok(()) | |
| } | |
| /// Say on standard error that the relay died of `signal`, as [`crate::measure::exit_code`] | |
| /// says it, in one `write` from a buffer on the stack. This runs after a fork from a | |
| /// launcher with other threads, where allocating or taking stderr's lock can hang on a | |
| /// lock one of them held at the fork. | |
| fn say_the_relay_died(signal: i32, core_dumped: bool) { | |
| fn put(buf: &mut [u8], at: usize, bytes: &[u8]) -> usize { | |
| let end = (at + bytes.len()).min(buf.len()); | |
| buf[at..end].copy_from_slice(&bytes[..end - at]); | |
| end | |
| } | |
| let mut digits = [0u8; 10]; | |
| let mut first = digits.len(); | |
| let mut rest = signal.unsigned_abs(); | |
| loop { | |
| first -= 1; | |
| digits[first] = b'0' + u8::try_from(rest % 10).unwrap_or(0); | |
| rest /= 10; | |
| if rest == 0 || first == 0 { | |
| break; | |
| } | |
| } | |
| let number = &digits[first..]; | |
| let mut buf = [0u8; 96]; | |
| let mut n = put(&mut buf, 0, b"amem: the relay was killed by "); | |
| if let Some(name) = crate::measure::signal_name(signal) { | |
| n = put(&mut buf, n, name.as_bytes()); | |
| n = put(&mut buf, n, b" (signal "); | |
| n = put(&mut buf, n, number); | |
| n = put(&mut buf, n, b")"); | |
| } else { | |
| n = put(&mut buf, n, b"signal "); | |
| n = put(&mut buf, n, number); | |
| } | |
| if core_dumped { | |
| n = put(&mut buf, n, b", core dumped"); | |
| } | |
| n = put(&mut buf, n, b"\n"); | |
| // SAFETY: write to fd 2 from a live buffer of at least n bytes; a failure is ignored, | |
| // as there is nowhere else to say it. | |
| unsafe { libc::write(2, buf.as_ptr().cast(), n) }; | |
| } | |
| /// Close every descriptor from 3 up. Runs between fork and exec, so it allocates nothing: | |
| /// `close_range` (Linux 5.9) in one call, or else `close` on each number below the | |
| /// open-files limit. | |
| fn close_from_three() { | |
| // SAFETY: close_range with integer arguments only. | |
| let r = unsafe { libc::syscall(libc::SYS_close_range, 3, libc::c_uint::MAX, 0) }; | |
| if r == 0 { | |
| return; | |
| } | |
| for fd in 3..open_files_limit() { | |
| // SAFETY: close of a descriptor number; one that is not open fails with EBADF. | |
| unsafe { libc::close(fd) }; | |
| } | |
| } | |
| /// Have the kernel SIGKILL this process when the thread that forked it ends, and fail if | |
| /// `parent` is no longer its parent: gone before the request took hold. Runs between fork | |
| /// and exec; the setting outlives the exec. Allocates only to say why it failed. | |
| fn die_with_parent(parent: nix::unistd::Pid) -> Result<(), Error> { | |
| nix::sys::prctl::set_pdeathsig(nix::sys::signal::Signal::SIGKILL) | |
| .map_err(|e| confine(format!("parent-death signal: {e}")))?; | |
| if nix::unistd::getppid() == parent { | |
| Ok(()) | |
| } else { | |
| Err(confine("the launcher has gone")) | |
| } | |
| } | |
| /// A pidfd for this process, close-on-exec, or -1 where the kernel has none (before 5.3). | |
| fn pidfd_of_self() -> libc::c_int { | |
| // SAFETY: pidfd_open with this process's pid and no flags; it returns a new | |
| // descriptor or -1. | |
| let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, libc::getpid(), 0) }; | |
| libc::c_int::try_from(fd).unwrap_or(-1) | |
| } | |
| /// As [`die_with_parent`] for pid 1, whose parent is the intermediate behind `pidfd`: | |
| /// fail if the intermediate has already exited, which makes the pidfd readable. Without | |
| /// a pidfd the check is not made, and an intermediate that dies between its fork and | |
| /// the request leaves pid 1 to run until the harness ends. | |
| fn die_with_intermediate(pidfd: libc::c_int) -> Result<(), Error> { | |
| nix::sys::prctl::set_pdeathsig(nix::sys::signal::Signal::SIGKILL) | |
| .map_err(|e| confine(format!("parent-death signal: {e}")))?; | |
| if pidfd < 0 { | |
| return Ok(()); | |
| } | |
| let mut poll = libc::pollfd { | |
| fd: pidfd, | |
| events: libc::POLLIN, | |
| revents: 0, | |
| }; | |
| // SAFETY: poll on one valid pollfd, without waiting; then close of the pidfd this | |
| // process owns. | |
| let gone = unsafe { | |
| let ready = libc::poll(&raw mut poll, 1, 0); | |
| libc::close(pidfd); | |
| ready > 0 | |
| }; | |
| if gone { | |
| Err(confine("the intermediate has gone")) | |
| } else { | |
| Ok(()) | |
| } | |
| } | |
| /// What `amem relay` does inside: spawn the harness and wait for it. The relay is | |
| /// already pid 1 of the harness's namespace, courtesy of [`run`]. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the spawn fails. | |
| pub fn spawn_in_pid_namespace(argv: &[OsString]) -> Result<i32, Error> { | |
| let status = Command::new(&argv[0]) | |
| .args(&argv[1..]) | |
| .status() | |
| .map_err(|e| confine(format!("spawn {}: {e}", argv[0].to_string_lossy())))?; | |
| Ok(crate::measure::exit_code(status, &argv[0])) | |
| } |