diff --git a/judge/README.md b/judge/README.md index d5265a1..7cec149 100644 --- a/judge/README.md +++ b/judge/README.md @@ -396,3 +396,23 @@ socketpair、ptrace 和 dumpability 可用,再安装真实生产策略验证 TMPDIR 指向私有 `/tmp`,native 不带 PATH。请求仍不能指定 PATH、TMPDIR、HOME 或 loader 覆盖;最终 exec 必须先清空环境、进入私有根,再应用这份环境。VM 语言 探针使用相同固定编译 PATH,仅将 TMPDIR 显式指向其有界测试工作区。 + +### Namespace 进程树回收原语 + +私有 `isolation/process_tree.rs` 为单线程、已降权/过滤的 namespace PID 1 定义回收 +角色。PID 1 保留运行,主程序在独立子进程 exec;主进程 pidfd + waitid 与 wait-any +配合,处理主进程退出和孤儿后代收养的竞态,只有 ECHILD 才确认树为空。取消、控制 +通道失联或监控异常都使 PID 1 退出,由内核终止该 namespace 的所有剩余进程;外层 +监督器仍必须检查 cgroup 为空并回收 bootstrap,不能把主进程退出当作整个任务结束。 + +exec 参数与固定环境在 fork 前分配,fork 前重复验证单线程/PID 1/凭据/过滤器与 +SIGCHLD wait 所有权。受审计的 unsafe 仅在沙箱内部:子进程用 async-signal-safe +系统操作绑定 FD 0/1/2、关闭其他 FD、设置 cwd/SIGPIPE,随后 execve 或 _exit。 +FD 3 仅为 CLOEXEC 的 exec errno 管道;错误管道 EOF 只表示没有收到 exec 错误, +单凭 EOF 不能证明程序已经 exec(例如放行前被终止)。普通信号/pidfd_send_signal/ +pidfd_getfd 仍拒绝,仅增加可见进程的只读 pidfd_open 以支持等待。 + +`tree_vm.rs`/固定 `tree_probe` 在受限独立 VM 实际验证正常主进程、孤儿收养/回收、 +主进程退出仍有后代时取消、控制端断开及真实 exec ENOENT。探针使用与生产相同的 +私有回收/凭据/seccomp 原语,在新 PID namespace 的 PID 1 运行;它不是完整任务 +执行 API。CLI run 继续拒绝,stdio/限额/计量与完整监督全部验收前不暴露执行放行。 diff --git a/judge/crates/sandbox/Cargo.toml b/judge/crates/sandbox/Cargo.toml index ce2bb01..2a6cf34 100644 --- a/judge/crates/sandbox/Cargo.toml +++ b/judge/crates/sandbox/Cargo.toml @@ -10,10 +10,11 @@ kf-contracts.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true -rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio"] } +rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event"] } # Fixed BPF compilation/installation through safe APIs; libc supplies syscall -# numbers only. Both dependencies stay inside the OS sandbox boundary. +# numbers and the audited fork/exec primitive. Both dependencies stay inside +# the OS sandbox boundary. seccompiler = { version = "=0.5.0", default-features = false } libc = "=0.2.190" diff --git a/judge/crates/sandbox/examples/tree_probe.rs b/judge/crates/sandbox/examples/tree_probe.rs new file mode 100644 index 0000000..5be562c --- /dev/null +++ b/judge/crates/sandbox/examples/tree_probe.rs @@ -0,0 +1,266 @@ +//! Fixed guarded VM role using the exact private production reaper/reducer. +//! No production CLI user-exec gate or arbitrary fixture argv is added. +mod launcher { + pub mod entry { + pub use kf_sandbox::launcher::entry::ensure_single_threaded; + } +} +mod outcome { + pub use kf_sandbox::outcome::Termination; +} +mod spec { + pub use kf_sandbox::spec::{ExecutionProfile, ValidatedSpec}; +} +#[allow(dead_code)] +#[path = "../src/isolation/identity.rs"] +pub(crate) mod identity; +#[allow(dead_code)] +#[path = "../src/isolation/namespaces.rs"] +mod namespaces; +#[allow(dead_code)] +#[path = "../src/isolation/process_tree.rs"] +mod process_tree; +#[allow(dead_code)] +#[path = "../src/isolation/seccomp.rs"] +pub(crate) mod seccomp; +mod isolation { + pub(crate) use crate::{identity, seccomp}; +} +#[allow(dead_code)] +#[path = "../tests/support/mod.rs"] +mod support; +use rustix::fd::{AsRawFd, OwnedFd}; +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +fn init(mode: &str) { + assert_eq!(rustix::process::getpid(), rustix::process::Pid::INIT); + rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::KILL)).unwrap(); + let mut control = + UnixStream::from(rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3).unwrap()); + assert_eq!(control.as_raw_fd(), 3); + control + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + control + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + // Original proc still shows outer PIDs. The fixed fresh PID 1 must replace + // it before single-threaded/proc-based preconditions and credential checks. + rustix::mount::mount( + "proc", + "/proc", + "proc", + rustix::mount::MountFlags::NOSUID + | rustix::mount::MountFlags::NODEV + | rustix::mount::MountFlags::NOEXEC, + Some(c"hidepid=2,subset=pid"), + ) + .unwrap(); + let (input, in_write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap(); + let (out_read, output) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap(); + let (err_read, error) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap(); + drop(in_write); // fixed empty stdin / EOF + let mut request = support::spec("tree-fixture", 64 << 20, 8).spec().clone(); + request.argv = vec!["/work/main".into(), mode.into()]; + if mode == "exec-fail" { + request.argv[0] = "/work/missing".into(); + request.inputs[0].path = "missing".into(); + } + let plan = process_tree::ExecPlan::new( + &request + .validate(&kf_sandbox::spec::SpecPolicy::default()) + .unwrap(), + ) + .unwrap(); + identity::reduce_current(100000, 100000).unwrap(); + seccomp::install(spec::ExecutionProfile::Native).unwrap(); + let mut reaper = process_tree::Reaper::spawn( + plan, + process_tree::ChildStdio { + input, + output, + error, + }, + ) + .unwrap(); + control.write_all(b"READY\n").unwrap(); + let mut main_reported = false; + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let observed = reaper.poll().unwrap(); + if let Some(status) = observed.main.as_ref().filter(|_| !main_reported) { + assert_eq!( + *status, + outcome::Termination::Exited { + code: if mode == "exec-fail" { 126 } else { 7 } + } + ); + if mode == "exec-fail" { + assert_eq!( + observed.exec, + process_tree::ExecObservation::Failed { errno: 2 } + ); + } + control.write_all(b"MAIN\n").unwrap(); + main_reported = true; + } + if observed.empty { + assert!(main_reported); + if mode == "orphan-short" { + assert_eq!(observed.reaped_descendants, 1); + } + let mut out = std::fs::File::from(out_read); + let mut err = std::fs::File::from(err_read); + let mut output = Vec::new(); + let mut error = Vec::new(); + out.read_to_end(&mut output).unwrap(); + err.read_to_end(&mut error).unwrap(); + if mode != "exec-fail" { + assert!(String::from_utf8_lossy(&output).contains("KF_TREE_USER_PROOF")); + } + if mode == "orphan-short" { + assert!(String::from_utf8_lossy(&error).contains("KF_ORPHAN_ADOPTED_PROOF")); + } + control.write_all(b"DRAINED\n").unwrap(); + reaper.shutdown(process_tree::NamespaceEnd::Drained); + } + let mut poll = [rustix::event::PollFd::new( + &control, + rustix::event::PollFlags::IN, + )]; + rustix::event::poll( + &mut poll, + Some(&rustix::event::Timespec { + tv_sec: 0, + tv_nsec: 2_000_000, + }), + ) + .unwrap(); + if !poll[0].revents().is_empty() { + let mut byte = [0]; + // The fixed init uses the admitted read syscall; std UnixStream::read + // uses recvfrom, which is deliberately outside this policy. + match rustix::io::read(&control, &mut byte) { + Ok(0) => reaper.shutdown(process_tree::NamespaceEnd::ControlLost), + Ok(1) if byte[0] == b'C' => { + control.write_all(b"CANCELLED\n").unwrap(); + reaper.shutdown(process_tree::NamespaceEnd::Cancelled); + } + _ => reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed), + } + } + if Instant::now() >= deadline { + reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed); + } + } +} +fn line(control: &mut UnixStream) -> String { + let mut bytes = Vec::new(); + loop { + let mut b = [0]; + control.read_exact(&mut b).unwrap(); + if b[0] == b'\n' { + break; + } + bytes.push(b[0]); + assert!(bytes.len() < 32); + } + String::from_utf8(bytes).unwrap() +} +fn main() { + support::require_vm(); + let args: Vec<_> = std::env::args().collect(); + assert_eq!(args.len(), 3); + assert!( + [ + "normal", + "orphan-short", + "orphan-cancel", + "orphan-disconnect", + "exec-fail" + ] + .contains(&args[2].as_str()) + ); + if args[1] == "init" { + init(&args[2]); + return; + } + assert_eq!(args[1], "fixed"); + launcher::entry::ensure_single_threaded().unwrap(); + let (mut parent, child) = UnixStream::pair().unwrap(); + parent + .set_read_timeout(Some(Duration::from_secs(4))) + .unwrap(); + parent + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let own = std::env::current_exe().unwrap(); + namespaces::create().unwrap(); + let mut process = Command::new(own) + .args(["init", &args[2]]) + .env_clear() + .env("KF_SANDBOX_INDEPENDENT_VM", "1") + .stdin(Stdio::from(OwnedFd::from(child))) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let pidfd = rustix::process::pidfd_open( + rustix::process::Pid::from_raw(process.id() as i32).unwrap(), + rustix::process::PidfdFlags::empty(), + ) + .unwrap(); + assert_eq!(line(&mut parent), "READY"); + assert_eq!(line(&mut parent), "MAIN"); + if args[2] == "orphan-cancel" || args[2] == "orphan-disconnect" { + assert!( + process.try_wait().unwrap().is_none(), + "main exit abandoned descendants" + ); + if args[2] == "orphan-cancel" { + parent.write_all(b"C").unwrap(); + assert_eq!(line(&mut parent), "CANCELLED"); + } + drop(parent); + } else { + assert_eq!(line(&mut parent), "DRAINED"); + drop(parent); + } + let deadline = Instant::now() + Duration::from_secs(4); + let status = loop { + if let Some(s) = process.try_wait().unwrap() { + break s; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(2)); + }; + if args[2] == "orphan-disconnect" { + assert_eq!(status.code(), Some(125)); + } else { + assert!(status.success(), "{status}"); + } + let mut poll = [rustix::event::PollFd::new( + &pidfd, + rustix::event::PollFlags::IN, + )]; + assert_eq!( + rustix::event::poll( + &mut poll, + Some(&rustix::event::Timespec { + tv_sec: 0, + tv_nsec: 0 + }) + ) + .unwrap(), + 1 + ); + println!( + "KF_TREE_PROOF mode={} pid1_reaper=1 main_pidfd=1 main_status_verified=1 init_pidfd_ready=1 namespace_cleanup=1", + args[2] + ); +} diff --git a/judge/crates/sandbox/src/isolation/mod.rs b/judge/crates/sandbox/src/isolation/mod.rs index 56e1fe4..b87e9fa 100644 --- a/judge/crates/sandbox/src/isolation/mod.rs +++ b/judge/crates/sandbox/src/isolation/mod.rs @@ -6,3 +6,6 @@ pub mod namespaces; pub(crate) mod proc_dev; pub(crate) mod root; pub(crate) mod seccomp; +// No public exec gate until resource supervision and lifecycle acceptance. +#[allow(dead_code)] +pub(crate) mod process_tree; diff --git a/judge/crates/sandbox/src/isolation/process_tree.rs b/judge/crates/sandbox/src/isolation/process_tree.rs new file mode 100644 index 0000000..bf4fb1f --- /dev/null +++ b/judge/crates/sandbox/src/isolation/process_tree.rs @@ -0,0 +1,321 @@ +//! Single-threaded namespace PID 1. Private until all execution gates are ready. +//! The init never execs the user program: it owns a pidfd and reaps orphaned +//! descendants. Exiting init is the namespace-wide cancellation mechanism. +use crate::{outcome::Termination, spec::ValidatedSpec}; +use rustix::{ + fd::{AsFd, AsRawFd, OwnedFd}, + process::{Pid, PidfdFlags, WaitId, WaitIdOptions, WaitOptions, WaitStatus}, +}; +use std::{ffi::CString, io}; + +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} + +pub(crate) struct ChildStdio { + pub input: OwnedFd, + pub output: OwnedFd, + pub error: OwnedFd, +} +impl ChildStdio { + pub(crate) fn validate(&self) -> io::Result<()> { + for (fd, access) in [ + (&self.input, rustix::fs::OFlags::RDONLY), + (&self.output, rustix::fs::OFlags::WRONLY), + (&self.error, rustix::fs::OFlags::WRONLY), + ] { + let s = rustix::fs::fstat(fd)?; + if fd.as_raw_fd() < 4 + || s.st_mode & libc::S_IFMT != libc::S_IFIFO + || rustix::fs::fcntl_getfl(fd)?.intersection(rustix::fs::OFlags::ACCMODE) != access + || !rustix::io::fcntl_getfd(fd)?.contains(rustix::io::FdFlags::CLOEXEC) + { + return Err(denied("stdio requires owned CLOEXEC pipe ends")); + } + } + Ok(()) + } +} + +/// Allocate/validate strings before fork. No request shell or host environment. +pub(crate) struct ExecPlan { + argv: Vec, + env: Vec, + cwd: CString, +} +impl ExecPlan { + pub(crate) fn new(spec: &ValidatedSpec) -> io::Result { + let string = |s: String| CString::new(s).map_err(|_| denied("exec string")); + Ok(Self { + argv: spec + .spec() + .argv + .iter() + .cloned() + .map(string) + .collect::>()?, + env: spec + .execution_environment() + .into_iter() + .map(|(k, v)| string(format!("{k}={v}"))) + .collect::>()?, + cwd: string(if spec.spec().cwd == "." { + "/work".into() + } else { + format!("/work/{}", spec.spec().cwd) + })?, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ExecObservation { + Pending, + NoErrorObserved, + Failed { errno: u16 }, +} +#[derive(Clone, Debug)] +pub(crate) struct TreeObservation { + pub main: Option, + pub exec: ExecObservation, + pub reaped_descendants: u32, + pub empty: bool, +} +pub(crate) enum NamespaceEnd { + Drained, + Cancelled, + ControlLost, + MonitorFailed, +} + +pub(crate) struct Reaper { + pid: Pid, + pidfd: OwnedFd, + exec_error: OwnedFd, + errno_bytes: [u8; 4], + errno_len: usize, + observation: TreeObservation, +} +impl Reaper { + pub(crate) fn spawn(plan: ExecPlan, stdio: ChildStdio) -> io::Result { + crate::launcher::entry::ensure_single_threaded()?; + if rustix::process::getpid() != Pid::INIT { + return Err(denied("reaper requires namespace PID 1")); + } + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + let status = std::fs::read_to_string("/proc/self/status")?; + if !crate::isolation::identity::valid(uid, gid) { + return Err(denied("reaper identity")); + } + crate::isolation::identity::verify_status(&status, uid, gid)?; + crate::isolation::seccomp::filter_count(&status)?; + // PID ownership requires no inherited SIGCHLD auto-reap disposition. + check_sigchld()?; + stdio.validate()?; + let (read, write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?; + rustix::fs::fcntl_setfl(&read, rustix::fs::OFlags::NONBLOCK)?; + let pid = fork_exec(&plan, &stdio, &write)?; + drop(write); + drop(stdio); // Init must not keep users' pipe ends open. + let pidfd = match rustix::process::pidfd_open(pid, PidfdFlags::empty()) { + Ok(fd) => fd, + Err(_) => std::process::exit(125), // PID 1 exit kills the spawned tree. + }; + Ok(Self { + pid, + pidfd, + exec_error: read, + errno_bytes: [0; 4], + errno_len: 0, + observation: TreeObservation { + main: None, + exec: ExecObservation::Pending, + reaped_descendants: 0, + empty: false, + }, + }) + } + pub(crate) fn poll(&mut self) -> io::Result { + self.poll_exec()?; + if self.observation.main.is_none() { + match rustix::process::waitid( + WaitId::PidFd(self.pidfd.as_fd()), + WaitIdOptions::EXITED | WaitIdOptions::NOHANG, + ) { + Ok(Some(s)) => { + self.observation.main = Some(if let Some(code) = s.exit_status() { + Termination::Exited { code: code as u8 } + } else if let Some(signal) = s.terminating_signal() { + Termination::Signaled { + signal: signal as u8, + core_dumped: s.dumped(), + } + } else { + return Err(denied("unexpected main waitid state")); + }); + } + Ok(None) | Err(rustix::io::Errno::INTR) => {} + Err(e) => return Err(e.into()), + } + } + // At most the validated pids ceiling per tick; don't starve control/I/O. + for _ in 0..128 { + match rustix::process::wait(WaitOptions::NOHANG) { + Ok(Some((pid, status))) if pid == self.pid => { + // Main exited between pidfd polling and wait-any. Record + // its actual status, never mistake it for a descendant. + if self.observation.main.is_some() { + return Err(denied("main reaped twice")); + } + self.observation.main = Some(termination(status)?); + } + Ok(Some(_)) => { + self.observation.reaped_descendants = self + .observation + .reaped_descendants + .checked_add(1) + .ok_or_else(|| denied("reap counter overflow"))? + } + Ok(None) => break, + Err(rustix::io::Errno::INTR) => continue, + Err(rustix::io::Errno::CHILD) => { + if self.observation.main.is_none() { + return Err(denied("main wait ownership lost")); + } + self.observation.empty = true; + break; + } + Err(e) => return Err(e.into()), + } + } + if self.observation.main.is_some() { + self.poll_exec()?; + } + Ok(self.observation.clone()) + } + fn poll_exec(&mut self) -> io::Result<()> { + if self.observation.exec != ExecObservation::Pending { + return Ok(()); + } + loop { + match rustix::io::read(&self.exec_error, &mut self.errno_bytes[self.errno_len..]) { + Ok(0) if self.errno_len == 0 => { + self.observation.exec = ExecObservation::NoErrorObserved; + return Ok(()); + } + Ok(0) => return Err(denied("partial exec error")), + Ok(n) => { + self.errno_len += n; + if self.errno_len == 4 { + let errno = i32::from_ne_bytes(self.errno_bytes); + let errno = u16::try_from(errno) + .ok() + .filter(|n| *n > 0) + .ok_or_else(|| denied("exec errno"))?; + self.observation.exec = ExecObservation::Failed { errno }; + return Ok(()); + } + } + Err(rustix::io::Errno::AGAIN) => return Ok(()), + Err(rustix::io::Errno::INTR) => continue, + Err(e) => return Err(e.into()), + } + } + } + pub(crate) fn shutdown(self, reason: NamespaceEnd) -> ! { + let code = match reason { + NamespaceEnd::Drained | NamespaceEnd::Cancelled => 0, + NamespaceEnd::ControlLost | NamespaceEnd::MonitorFailed => 125, + }; + // Constructor proved PID 1. Kernel SIGKILLs and waits for the entire + // namespace; the outside supervisor must still verify cgroup empty. + std::process::exit(code) + } +} +impl Drop for Reaper { + fn drop(&mut self) { + // Monitor panic/error cannot abandon a live descendant namespace. + std::process::exit(125); + } +} +fn termination(s: WaitStatus) -> io::Result { + if let Some(code) = s.exit_status() { + Ok(Termination::Exited { code: code as u8 }) + } else if let Some(signal) = s.terminating_signal() { + Ok(Termination::Signaled { + signal: signal as u8, + core_dumped: s.as_raw() & 0x80 != 0, + }) + } else { + Err(denied("unexpected descendant wait state")) + } +} + +#[allow(unsafe_code)] +fn check_sigchld() -> io::Result<()> { + // SAFETY: sigaction query initializes the supplied C struct, no handler or + // global disposition is changed. Only the checked single-threaded init. + unsafe { + let mut s: libc::sigaction = std::mem::zeroed(); + if libc::sigaction(libc::SIGCHLD, std::ptr::null(), &mut s) != 0 { + return Err(io::Error::last_os_error()); + } + if s.sa_sigaction != libc::SIG_DFL || s.sa_flags & libc::SA_NOCLDWAIT != 0 { + return Err(denied("SIGCHLD must preserve wait ownership")); + } + } + Ok(()) +} + +#[allow(unsafe_code)] +fn fork_exec(plan: &ExecPlan, stdio: &ChildStdio, error: &OwnedFd) -> io::Result { + let mut argv: Vec<_> = plan.argv.iter().map(|s| s.as_ptr()).collect(); + argv.push(std::ptr::null()); + let mut env: Vec<_> = plan.env.iter().map(|s| s.as_ptr()).collect(); + env.push(std::ptr::null()); + let sources = [ + stdio.input.as_raw_fd(), + stdio.output.as_raw_fd(), + stdio.error.as_raw_fd(), + ]; + let err = error.as_raw_fd(); + let program = plan.argv[0].as_ptr(); + let cwd = plan.cwd.as_ptr(); + if err < 4 { + return Err(denied("exec-error descriptor alias")); + } + // SAFETY: fresh, checked single-threaded PID 1, trusted image only, no user + // code/threads/atfork hooks loaded. All allocations/C strings/raw FD values + // prepared above and borrowed alive in both processes. Child uses only + // async-signal-safe libc syscalls and exec/_exit, never Rust allocation, + // panic, unwinding or descriptor destructors. fork copies, not shares, the + // FD table; only its copy is rewritten/closed. All source FDs are >=4. + unsafe { + let raw = libc::fork(); + if raw < 0 { + return Err(io::Error::last_os_error()); + } + if raw == 0 { + if libc::dup3(err, 3, libc::O_CLOEXEC) < 0 { + let errno = *libc::__errno_location(); + let _ = libc::write(err, (&errno as *const i32).cast(), 4); + libc::_exit(126); + } + let setup = sources + .iter() + .enumerate() + .all(|(target, source)| libc::dup2(*source, target as i32) >= 0) + && libc::syscall(libc::SYS_close_range, 4_u32, u32::MAX, 0_u32) == 0 + && libc::chdir(cwd) == 0 + && libc::signal(libc::SIGPIPE, libc::SIG_DFL) != libc::SIG_ERR; + if setup { + libc::execve(program, argv.as_ptr(), env.as_ptr()); + } + let errno = *libc::__errno_location(); + let _ = libc::write(3, (&errno as *const i32).cast(), 4); + libc::_exit(126); + } + Ok(Pid::from_raw(raw).expect("positive fork PID")) + } +} diff --git a/judge/crates/sandbox/src/isolation/seccomp.rs b/judge/crates/sandbox/src/isolation/seccomp.rs index ac0e659..6b44333 100644 --- a/judge/crates/sandbox/src/isolation/seccomp.rs +++ b/judge/crates/sandbox/src/isolation/seccomp.rs @@ -137,6 +137,9 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result { libc::SYS_fork, libc::SYS_wait4, libc::SYS_waitid, + // Holding/waiting on visible children is read-only; signalling via + // pidfds remains forbidden, as does pidfd_getfd. + libc::SYS_pidfd_open, libc::SYS_execve, libc::SYS_exit, libc::SYS_exit_group, diff --git a/judge/crates/sandbox/tests/fixtures/tree_main.c b/judge/crates/sandbox/tests/fixtures/tree_main.c new file mode 100644 index 0000000..344fb51 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/tree_main.c @@ -0,0 +1,25 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +static void require(int yes){if(!yes){fprintf(stderr,"tree user proof failure errno=%d\n",errno);_exit(2);}} +int main(int argc,char **argv){ + require(argc==2&&getpid()>1&&getppid()==1&&getuid()==100000&&prctl(PR_GET_SECCOMP)==2); + DIR *dir=opendir("/proc/self/fd");require(dir!=NULL);int scanner=dirfd(dir);struct dirent *e; + while((e=readdir(dir)))if(e->d_name[0]!='.'){int fd=atoi(e->d_name);require(fd<=2||fd==scanner);} + closedir(dir);char data;require(read(0,&data,1)==0); + puts("KF_TREE_USER_PROOF pid_gt1=1 parent_init=1 reduced=1 filtered=1 fd012_only=1 eof=1");fflush(stdout); + if(!strcmp(argv[1],"normal"))return 7; + pid_t child=fork();require(child>=0); + if(child)return 7; + if(!strcmp(argv[1],"orphan-short")){ + for(unsigned i=0;i<100&&getppid()!=1;i++)usleep(1000); + require(getppid()==1);fputs("KF_ORPHAN_ADOPTED_PROOF\n",stderr);return 3; + } + for(;;)sleep(10); +} diff --git a/judge/crates/sandbox/tests/tree_vm.rs b/judge/crates/sandbox/tests/tree_vm.rs new file mode 100644 index 0000000..4334b5a --- /dev/null +++ b/judge/crates/sandbox/tests/tree_vm.rs @@ -0,0 +1,32 @@ +#![cfg(target_os = "linux")] +#[allow(dead_code)] +mod support; +use std::process::Command; +#[test] +#[ignore = "requires independent capped VM with fixed namespace role fixture"] +fn namespace_init_reaps_main_and_orphans_and_cancels_surviving_tree() { + support::require_vm(); + for mode in [ + "normal", + "orphan-short", + "orphan-cancel", + "orphan-disconnect", + "exec-fail", + ] { + let output = Command::new("/kf/bin/tree-probe") + .args(["fixed", mode]) + .env_clear() + .env("KF_SANDBOX_INDEPENDENT_VM", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{mode}: {:?} {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let proof = String::from_utf8(output.stdout).unwrap(); + assert!(proof.contains(&format!("KF_TREE_PROOF mode={mode}"))); + print!("{proof}"); + } +}