diff --git a/judge/README.md b/judge/README.md index a8935cd..bc69cc3 100644 --- a/judge/README.md +++ b/judge/README.md @@ -232,6 +232,33 @@ work 为 nosuid/nodev,保留后续编译和执行能力;tmp 另加 noexec。 参见 [pivot_root](https://man7.org/linux/man-pages/man2/pivot_root.2.html) 和 [mount](https://man7.org/linux/man-pages/man2/mount.2.html)。 +## 受限 proc/dev 与初始化 FD 防护 + +`isolation/proc_dev.rs` 固定 OS 策略,`launcher/proc_dev.rs` 的 `RestrictedRootTarget` +在已完成的 `RootedTarget` 上推进独立握手;两个类型均没有用户 exec 方法。 +fresh exec 的 bootstrap/init 在创建内部 FD 前扫描自己的 FD,只允许 0/1/2, +精确排除扫描器自己持有的 FD。任何额外继承文件或目录都在 namespace 操作前拒绝, +避免原始 close 与 Rust FD 所有权冲突。初始化后监督器再次确认 init 和 bootstrap +的根、cwd 及无目录 FD,拒绝 cwd 留在私有 root/work 以外的情况。 + +仅 namespace PID 1 在切根后重新挂载 proc,使用 ro/nosuid/nodev/noexec、 +`hidepid=2,subset=pid`。监督器核对实际文件系统、挂载选项、PID 1/PPid 0, +以及全局 sys/sysrq-trigger/kcore/keys 等入口缺失;选项或挂载能力不支持则拒绝。 +dev 使用 64 KiB、16 inode 的私有 tmpfs,只创建 root 所有、0666 的四个字符设备: +null (1:3)、zero (1:5)、random (1:8)、urandom (1:9),随后把目录挂载设为只读。 +dev 为 nosuid/noexec,允许这四个设备 I/O 所以不设置 nodev;不提供磁盘、mem、tty 或 pts。 +固定 dev 的容量是初始化开销,work/tmp 的申请预算和内存计费政策另行管理。 + +`proc_dev_vm.rs` 在规定的独立 VM 中验证新 PID 视图、固定设备 I/O、非 root 固定探针对 +helper/外部 PID、旧根和跨任务文件的访问拒绝,并验证意外继承 FD 的启动拒绝。 +VM 专用故障注入器让内核对 mknodat 返回 EPERM,验证部分 proc/dev 初始化的真实清理。 +探针的 UID 切换及故障过滤器仅属于 VM 验收,不能当作生产身份或 seccomp 实现。 +测试输入尚未安全绑定,工件校验/提取、生产身份/caps/seccomp 和完整用户 stdio/监督 +仍待实现,因此完整 proc/dev 与输入保护 TODO 暂不标记完成,run 继续拒绝。 + +参见 [proc 的 hidepid/subset 选项](https://man7.org/linux/man-pages/man5/procfs.5.html) +及 [null/zero 设备](https://man7.org/linux/man-pages/man4/null.4.html)。 + ## 可信运行配置与启动前检查 ```bash diff --git a/judge/crates/sandbox/src/isolation/mod.rs b/judge/crates/sandbox/src/isolation/mod.rs index ddbc7d9..4b67ae2 100644 --- a/judge/crates/sandbox/src/isolation/mod.rs +++ b/judge/crates/sandbox/src/isolation/mod.rs @@ -1,4 +1,5 @@ //! Fixed Linux isolation operations; launcher owns process/control orchestration. pub mod namespaces; +pub(crate) mod proc_dev; pub(crate) mod root; diff --git a/judge/crates/sandbox/src/isolation/proc_dev.rs b/judge/crates/sandbox/src/isolation/proc_dev.rs new file mode 100644 index 0000000..061121a --- /dev/null +++ b/judge/crates/sandbox/src/isolation/proc_dev.rs @@ -0,0 +1,194 @@ +//! Fixed post-pivot proc/dev policy. No host mount/device path is request data. +use rustix::fs::{AtFlags, FileType, Mode, OFlags, StatVfsMountFlags}; +use rustix::mount::MountFlags; +use std::io; + +const DEV_BYTES: u64 = 64 * 1024; +const DEVICES: [(&str, u32); 4] = [("null", 3), ("zero", 5), ("random", 8), ("urandom", 9)]; + +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} + +/// The root role already proved single-threaded before pivot; a fresh proc +/// mount is needed before repeating that check. Only namespace PID 1 calls us. +pub(crate) fn install() -> io::Result<()> { + if rustix::process::getpid().as_raw_pid() != 1 { + return Err(denied("proc setup requires namespace init")); + } + rustix::mount::mount( + "proc", + "/proc", + "proc", + MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC, + Some(c"hidepid=2,subset=pid"), + )?; + crate::launcher::entry::ensure_single_threaded()?; + rustix::mount::mount( + "tmpfs", + "/dev", + "tmpfs", + MountFlags::NOSUID | MountFlags::NOEXEC, + Some(c"size=65536,nr_inodes=16,mode=0755"), + )?; + let dev = rustix::fs::open( + "/dev", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + // Single-threaded fresh init owns this process-wide umask change. Restore + // it even when mknod fails; the caller terminates the namespace on error. + let previous = rustix::process::umask(Mode::empty()); + let created = (|| { + for (name, minor) in DEVICES { + rustix::fs::mknodat( + &dev, + name, + FileType::CharacterDevice, + Mode::from_raw_mode(0o666), + rustix::fs::makedev(1, minor), + )?; + } + Ok::<_, io::Error>(()) + })(); + rustix::process::umask(previous); + created?; + drop(dev); + // Read-only directory entries still permit I/O on the fixed character + // devices. NODEV must remain absent here; all other mounts retain NODEV. + rustix::mount::mount_remount( + "/dev", + MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NOEXEC, + c"", + )?; + Ok(()) +} + +pub(crate) fn verify(pid: u32) -> io::Result<()> { + let base = format!("/proc/{pid}/root"); + let proc = rustix::fs::open( + format!("{base}/proc"), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + let required = + StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NOEXEC; + if rustix::fs::fstatfs(&proc)?.f_type != rustix::fs::PROC_SUPER_MAGIC + || !rustix::fs::fstatvfs(&proc)? + .f_flag + .contains(required | StatVfsMountFlags::NODEV) + { + return Err(denied("restricted proc mount mismatch")); + } + let status = std::fs::read_to_string(format!("{base}/proc/1/status"))?; + if !status.lines().any(|line| line == "Pid:\t1") + || !status.lines().any(|line| line == "PPid:\t0") + { + return Err(denied("proc is not rooted in namespace PID 1")); + } + let proc_mountinfo = std::fs::read_to_string(format!("{base}/proc/1/mountinfo"))?; + let options = proc_mountinfo + .lines() + .find_map(|line| { + let (left, right) = line.split_once(" - ")?; + (left.split_whitespace().nth(4) == Some("/proc")) + .then(|| right.split_whitespace().nth(2)) + .flatten() + }) + .ok_or_else(|| denied("missing proc mount options"))?; + verify_proc_options(options)?; + for path in [ + "sys", + "sysrq-trigger", + "kcore", + "keys", + "meminfo", + "cpuinfo", + ] { + match rustix::fs::statat(&proc, path, AtFlags::SYMLINK_NOFOLLOW) { + Err(rustix::io::Errno::NOENT) => {} + _ => return Err(denied("global proc entry exposed")), + } + } + let dev = rustix::fs::open( + format!("{base}/dev"), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + let stat = rustix::fs::fstatvfs(&dev)?; + if rustix::fs::fstatfs(&dev)?.f_type as u64 != 0x0102_1994 + || stat.f_blocks.checked_mul(stat.f_frsize) != Some(DEV_BYTES) + || !stat.f_flag.contains(required) + || stat.f_flag.contains(StatVfsMountFlags::NODEV) + { + return Err(denied("restricted dev mount mismatch")); + } + let mut entries = std::fs::read_dir(format!("{base}/dev"))? + .map(|entry| entry.map(|e| e.file_name())) + .collect::>>()?; + entries.sort(); + let mut expected: Vec<_> = DEVICES + .iter() + .map(|(name, _)| std::ffi::OsString::from(name)) + .collect(); + expected.sort(); + if entries != expected { + return Err(denied("unexpected dev entry")); + } + for (name, minor) in DEVICES { + let stat = rustix::fs::statat(&dev, name, AtFlags::SYMLINK_NOFOLLOW)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::CharacterDevice + || stat.st_rdev != rustix::fs::makedev(1, minor) + || stat.st_uid != 0 + || stat.st_gid != 0 + || stat.st_mode & 0o7777 != 0o666 + { + return Err(denied("device type/identity/mode mismatch")); + } + } + Ok(()) +} + +fn verify_proc_options(options: &str) -> io::Result<()> { + let hide: Vec<_> = options + .split(',') + .filter(|s| s.starts_with("hidepid=")) + .collect(); + let subset: Vec<_> = options + .split(',') + .filter(|s| s.starts_with("subset=")) + .collect(); + // Kernel show_options renders HIDEPID_INVISIBLE as a named value, even + // when mount received the numeric value. Accept only this exact policy. + if !matches!(hide.as_slice(), ["hidepid=2"] | ["hidepid=invisible"]) + || !matches!(subset.as_slice(), ["subset=pid"]) + { + return Err(denied("proc restriction options mismatch")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::verify_proc_options; + #[test] + fn kernel_numeric_and_named_invisible_policy_are_equivalent() { + for options in ["rw,hidepid=2,subset=pid", "rw,hidepid=invisible,subset=pid"] { + assert!(verify_proc_options(options).is_ok()); + } + } + #[test] + fn weaker_missing_or_ambiguous_proc_options_are_rejected() { + for options in [ + "rw,subset=pid", + "rw,hidepid=invisible", + "rw,hidepid=off,subset=pid", + "rw,hidepid=noaccess,subset=pid", + "rw,hidepid=ptraceable,subset=pid", + "rw,hidepid=2,hidepid=off,subset=pid", + "rw,hidepid=2,subset=pid,subset=all", + ] { + assert!(verify_proc_options(options).is_err(), "{options}"); + } + } +} diff --git a/judge/crates/sandbox/src/isolation/root.rs b/judge/crates/sandbox/src/isolation/root.rs index 5863338..6f6d4ad 100644 --- a/judge/crates/sandbox/src/isolation/root.rs +++ b/judge/crates/sandbox/src/isolation/root.rs @@ -208,10 +208,9 @@ impl RootLease { OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), )?; - if !rustix::fs::fstatvfs(&root)? - .f_flag - .contains(StatVfsMountFlags::RDONLY) - { + if !rustix::fs::fstatvfs(&root)?.f_flag.contains( + StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NODEV, + ) { return Err(invalid("image root is not read-only")); } for (name, bytes, tmp) in [ @@ -240,6 +239,13 @@ impl RootLease { return Err(invalid("initialization directory FD leaked")); } } + let cwd = std::fs::metadata(format!("/proc/{pid}/cwd"))?; + let work = std::fs::metadata(format!("/proc/{pid}/root/work"))?; + if (cwd.dev(), cwd.ino()) != (actual.dev(), actual.ino()) + && (cwd.dev(), cwd.ino()) != (work.dev(), work.ino()) + { + return Err(invalid("initialization cwd is outside private root/work")); + } Ok(()) } pub(crate) fn cleanup(&mut self) -> io::Result<()> { diff --git a/judge/crates/sandbox/src/launcher/entry.rs b/judge/crates/sandbox/src/launcher/entry.rs index 254074a..0c6c625 100644 --- a/judge/crates/sandbox/src/launcher/entry.rs +++ b/judge/crates/sandbox/src/launcher/entry.rs @@ -77,3 +77,38 @@ pub fn ensure_single_threaded() -> io::Result<()> { } Ok(()) } + +/// Fresh exec roles reject unowned inherited descriptors before creating any +/// channel duplicates. Closing unknown raw FDs would risk Rust ownership; fail +/// closed instead. The single-threaded scan owns and excludes exactly its FD. +pub(crate) fn ensure_inherited_descriptors() -> io::Result<()> { + use rustix::fd::AsRawFd; + use rustix::fs::{Dir, Mode, OFlags}; + ensure_single_threaded()?; + let fd = rustix::fs::open( + "/proc/self/fd", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let mut directory = Dir::new(fd)?; + let scanner = directory.fd()?.as_raw_fd(); + let mut count = 0; + while let Some(entry) = directory.read() { + let entry = entry?; + let name = entry.file_name().to_bytes(); + if name == b"." || name == b".." { + continue; + } + count += 1; + let fd = std::str::from_utf8(name) + .ok() + .and_then(|s| s.parse::().ok()); + if count > 128 || !fd.is_some_and(|fd| (0..=2).contains(&fd) || fd == scanner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unexpected inherited descriptor", + )); + } + } + Ok(()) +} diff --git a/judge/crates/sandbox/src/launcher/mod.rs b/judge/crates/sandbox/src/launcher/mod.rs index f1ca86b..82da481 100644 --- a/judge/crates/sandbox/src/launcher/mod.rs +++ b/judge/crates/sandbox/src/launcher/mod.rs @@ -14,6 +14,8 @@ pub mod entry; #[cfg(target_os = "linux")] pub mod namespaces; #[cfg(target_os = "linux")] +pub mod proc_dev; +#[cfg(target_os = "linux")] pub mod protocol; #[cfg(target_os = "linux")] pub mod root; diff --git a/judge/crates/sandbox/src/launcher/namespaces.rs b/judge/crates/sandbox/src/launcher/namespaces.rs index cd73688..e3b34a2 100644 --- a/judge/crates/sandbox/src/launcher/namespaces.rs +++ b/judge/crates/sandbox/src/launcher/namespaces.rs @@ -39,6 +39,7 @@ fn inherited_channel(parent_pid: u32) -> io::Result { /// unshare; changing PID namespaces changes SO_PEERCRED's PID translation. pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()> { super::entry::ensure_single_threaded()?; + super::entry::ensure_inherited_descriptors()?; rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?; if expected_parent <= 1 || rustix::process::getppid().map(|pid| pid.as_raw_pid() as u32) != Some(expected_parent) @@ -53,7 +54,7 @@ pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()> return Ok(()); } NamespaceCommand::Initialize {} => {} - NamespaceCommand::PrepareRoot { .. } => { + NamespaceCommand::PrepareRoot { .. } | NamespaceCommand::PrepareProcDev {} => { return Err(denied("root command before namespaces")); } } @@ -130,6 +131,7 @@ fn status_value(status: &str, name: &str) -> io::Result { /// in the new PID namespace. Later root setup must replace this inherited proc. pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::Result<()> { super::entry::ensure_single_threaded()?; + super::entry::ensure_inherited_descriptors()?; if rustix::process::getpid().as_raw_pid() != 1 || rustix::process::getppid().is_some() { return Err(denied("not the namespace init")); } @@ -171,7 +173,20 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io:: return Err(error); } channel.send(&NamespaceEvent::RootInitialized {})?; - let _ = channel.receive::()?; + match channel.receive::()? { + NamespaceCommand::PrepareProcDev {} => { + if let Err(error) = crate::isolation::proc_dev::install() { + channel.send(&NamespaceEvent::Failed { + errno: error.raw_os_error().and_then(|n| u16::try_from(n).ok()), + })?; + return Err(error); + } + channel.send(&NamespaceEvent::ProcDevInitialized {})?; + let _ = channel.receive::()?; + } + NamespaceCommand::Cancel {} => return Ok(()), + _ => {} + } } NamespaceCommand::Cancel {} => return Ok(()), _ => {} @@ -186,6 +201,8 @@ pub struct NamespacedTarget { control: ControlChannel, init_pidfd: OwnedFd, allow_root: bool, + root_ready: bool, + proc_dev_ready: bool, init_pid: u32, ids: NamespaceIds, } @@ -276,6 +293,8 @@ impl NamespacedTarget { control, init_pidfd, allow_root, + root_ready: false, + proc_dev_ready: false, }), Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))), } @@ -285,18 +304,43 @@ impl NamespacedTarget { &mut self, budget: crate::isolation::root::RootBudget, ) -> std::io::Result<()> { - if !self.allow_root { + if !self.allow_root || self.root_ready { return Err(denied("root descriptors not inherited")); } - self.control.send(&NamespaceCommand::PrepareRoot { - work_bytes: budget.work_bytes, - tmp_bytes: budget.tmp_bytes, - })?; + self.initialize_filesystem( + NamespaceCommand::PrepareRoot { + work_bytes: budget.work_bytes, + tmp_bytes: budget.tmp_bytes, + }, + NamespaceEvent::RootInitialized {}, + )?; + self.root_ready = true; + Ok(()) + } + + pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> { + if !self.root_ready || self.proc_dev_ready { + return Err(denied("proc/dev initialization order")); + } + self.initialize_filesystem( + NamespaceCommand::PrepareProcDev {}, + NamespaceEvent::ProcDevInitialized {}, + )?; + self.proc_dev_ready = true; + Ok(()) + } + + fn initialize_filesystem( + &mut self, + command: NamespaceCommand, + expected: NamespaceEvent, + ) -> io::Result<()> { + self.control.send(&command)?; self.prepared .cgroup .begin_root_initialization(&[self.prepared.pid(), self.init_pid], &self.init_pidfd)?; match self.control.receive::()? { - NamespaceEvent::RootInitialized {} => {} + event if event == expected => {} NamespaceEvent::Failed { errno: Some(errno) } => { return Err(io::Error::from_raw_os_error(i32::from(errno))); } diff --git a/judge/crates/sandbox/src/launcher/proc_dev.rs b/judge/crates/sandbox/src/launcher/proc_dev.rs new file mode 100644 index 0000000..d7f2f1b --- /dev/null +++ b/judge/crates/sandbox/src/launcher/proc_dev.rs @@ -0,0 +1,31 @@ +//! A separately gated restricted-filesystem stage, with no user execution API. +use super::blocked::InitializationFailure; +use super::root::RootedTarget; +use crate::runtime::ValidatedRuntimeConfig; +use crate::spec::ValidatedSpec; +use std::path::Path; + +pub struct RestrictedRootTarget { + root: RootedTarget, +} + +impl RestrictedRootTarget { + pub fn prepare( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + executable: &Path, + ) -> Result { + let mut root = RootedTarget::prepare(config, spec, executable)?; + if let Err(error) = root.initialize_proc_dev() { + return Err(root.refuse_root_error(&error)); + } + Ok(Self { root }) + } + pub fn root(&self) -> &RootedTarget { + &self.root + } + /// Input binding, identity/capabilities/seccomp and supervision are absent. + pub fn refuse_remaining_isolation(self) -> InitializationFailure { + self.root.refuse_remaining_isolation() + } +} diff --git a/judge/crates/sandbox/src/launcher/protocol.rs b/judge/crates/sandbox/src/launcher/protocol.rs index 98c1afc..fc1675f 100644 --- a/judge/crates/sandbox/src/launcher/protocol.rs +++ b/judge/crates/sandbox/src/launcher/protocol.rs @@ -41,6 +41,7 @@ pub enum ChildMessage { pub enum NamespaceCommand { Initialize {}, PrepareRoot { work_bytes: u64, tmp_bytes: u64 }, + PrepareProcDev {}, Cancel {}, } @@ -49,6 +50,7 @@ pub enum NamespaceCommand { pub enum NamespaceEvent { Initialized { init_pid: u32 }, RootInitialized {}, + ProcDevInitialized {}, Failed { errno: Option }, Cancelled {}, } diff --git a/judge/crates/sandbox/src/launcher/root.rs b/judge/crates/sandbox/src/launcher/root.rs index dbe27d2..a1478c2 100644 --- a/judge/crates/sandbox/src/launcher/root.rs +++ b/judge/crates/sandbox/src/launcher/root.rs @@ -72,6 +72,7 @@ impl RootedTarget { if let Err(error) = (|| { target.initialize_root(lease.budget)?; lease.verify_root(target.init_pid())?; + lease.verify_root(target.bootstrap_pid())?; Ok::<_, io::Error>(()) })() { let mut failure = target @@ -95,6 +96,22 @@ impl RootedTarget { self.lease.budget } + pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> { + self.target.initialize_proc_dev()?; + self.lease.verify_root(self.init_pid())?; + self.lease.verify_root(self.bootstrap_pid())?; + crate::isolation::proc_dev::verify(self.init_pid()) + } + + pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure { + let mut failure = self + .target + .prepared + .refuse_at(ExecutionStage::RootFilesystem, Some(error)); + attach_cleanup(&mut failure, self.lease.cleanup().err()); + failure + } + /// Restricted proc/dev, identity, capabilities and seccomp are still absent. pub fn refuse_remaining_isolation(mut self) -> InitializationFailure { let mut failure = self diff --git a/judge/crates/sandbox/tests/fixtures/proc_dev_deny.c b/judge/crates/sandbox/tests/fixtures/proc_dev_deny.c new file mode 100644 index 0000000..81e069d --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/proc_dev_deny.c @@ -0,0 +1,29 @@ +/* VM-only kernel failure injector. It execs one fixed trusted test case. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +int main(void) { + const char *vm=getenv("KF_SANDBOX_INDEPENDENT_VM"); + if(!vm||vm[0]!='1'||vm[1]||access("/kf/INDEPENDENT_VM",R_OK))return 2; + struct sock_filter filter[]={ + BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,arch)), + BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,AUDIT_ARCH_X86_64,1,0), + BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_KILL_PROCESS), + BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,nr)), + BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,__NR_mknodat,0,1), + BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ERRNO|EPERM), + BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ALLOW) + }; + struct sock_fprog program={.len=sizeof filter/sizeof *filter,.filter=filter}; + if(prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0)||prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&program))return 3; + setenv("KF_SANDBOX_DENY_MKNOD","1",1); + execl("/kf/bin/proc-dev-vm-tests","proc-dev-vm-tests","--ignored","--test-threads=1","--exact","kernel_mknod_failure_rolls_back_proc_dev_and_root","--nocapture",(char*)0); + return 4; +} diff --git a/judge/crates/sandbox/tests/fixtures/proc_dev_probe.c b/judge/crates/sandbox/tests/fixtures/proc_dev_probe.c new file mode 100644 index 0000000..567d4d5 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/proc_dev_probe.c @@ -0,0 +1,65 @@ +/* Fixed bounded filesystem probe, compiled/run only in the capped test VM. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int absent(const char *p) { + struct stat st; + return stat(p,&st)<0 && (errno==ENOENT || errno==EACCES); +} +static int probe(int mnt,int pidns,int root,const char *peer) { + alarm(8); + if(fchdir(root)||chroot(".")||chdir("/"))return 10; + close(mnt);close(pidns);close(root); + int created=open("/dev/new-file",O_CREAT|O_WRONLY|O_CLOEXEC,0600); + if(created>=0||errno!=EROFS)return 24; + /* Test-only identity, not a production identity implementation. */ + if(setgroups(0,NULL)||setresgid(100000,100000,100000)||setresuid(100000,100000,100000))return 11; + if(getpid()!=2 || !absent("/proc/1/root") || !absent("/proc/1/fd"))return 12; + DIR *d=opendir("/proc");if(!d)return 13;struct dirent *e; + while((e=readdir(d)))if(e->d_name[0]>='0'&&e->d_name[0]<='9'&&strcmp(e->d_name,"2"))return 14; + closedir(d); + const char *global[]={"/proc/sys","/proc/sysrq-trigger","/proc/kcore","/proc/keys","/proc/cpuinfo","/proc/meminfo"}; + for(unsigned i=0;i=0||errno!=EROFS)return 25; + if(!absent("/proc/self/root/../../kf")||!absent("/proc/self/cwd/../../sys")||!absent("/escape-image/outside-sentinel"))return 26; + d=opendir("/proc/self/fd");if(!d)return 27;int scanner=dirfd(d); + while((e=readdir(d))) { + if(e->d_name[0]=='.'||atoi(e->d_name)==scanner)continue; + snprintf(path,sizeof path,"/proc/self/fd/%d",atoi(e->d_name)); + struct stat st;if(stat(path,&st)||S_ISDIR(st.st_mode))return 28; + } + closedir(d);return 0; +} +int main(int argc,char **argv) { + alarm(15);if(argc!=3)return 2; + for(int i=1;i<3;i++)for(char *p=argv[i];*p;p++)if(*p<'0'||*p>'9')return 2; + char path[128];snprintf(path,sizeof path,"/proc/%s/ns/mnt",argv[1]);int mnt=open(path,O_RDONLY|O_CLOEXEC); + snprintf(path,sizeof path,"/proc/%s/ns/pid",argv[1]);int pidns=open(path,O_RDONLY|O_CLOEXEC); + snprintf(path,sizeof path,"/proc/%s/root",argv[1]);int root=open(path,O_RDONLY|O_DIRECTORY|O_CLOEXEC); + if(mnt<0||pidns<0||root<0||setns(mnt,CLONE_NEWNS)||setns(pidns,CLONE_NEWPID))return 3; + pid_t child=fork();if(child==0)_exit(probe(mnt,pidns,root,argv[2])); + close(mnt);close(pidns);close(root);int status=0; + if(child<0||waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status)){fprintf(stderr,"proc/dev probe status=%d\n",status);return 4;} + puts("KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1");return 0; +} diff --git a/judge/crates/sandbox/tests/proc_dev_vm.rs b/judge/crates/sandbox/tests/proc_dev_vm.rs new file mode 100644 index 0000000..8584b4b --- /dev/null +++ b/judge/crates/sandbox/tests/proc_dev_vm.rs @@ -0,0 +1,123 @@ +#![cfg(target_os = "linux")] +//! Real restricted proc/dev verification, only in an independently capped VM. +mod support; +use kf_sandbox::launcher::proc_dev::RestrictedRootTarget; +use kf_sandbox::outcome::ExecutionStage; +use std::fs; +use std::path::Path; +use std::process::Command; +use support::{Root, binary, children, spec}; + +#[test] +#[ignore = "requires independent VM with externally verified whole-VM caps"] +fn restricted_proc_devices_and_root_boundaries_are_real() { + let root = Root::new(); + let before = fs::read_to_string("/proc/self/mountinfo").unwrap(); + let one = + RestrictedRootTarget::prepare(&root.validated(), &spec("proc-one", 64 << 20, 8), &binary()) + .unwrap(); + let two = + RestrictedRootTarget::prepare(&root.validated(), &spec("proc-two", 64 << 20, 8), &binary()) + .unwrap(); + let a = format!("/proc/{}/root", one.root().init_pid()); + let b = format!("/proc/{}/root", two.root().init_pid()); + fs::write(format!("{b}/work/peer-only"), "isolated peer").unwrap(); + assert!(!Path::new(&format!("{a}/work/peer-only")).exists()); + let output = Command::new("/kf/bin/proc-dev-probe") + .arg(one.root().init_pid().to_string()) + .arg(two.root().init_pid().to_string()) + .env_clear() + .output() + .unwrap(); + assert!( + output.status.success(), + "fixed proc/dev probe: {:?} {:?}", + output.stdout, + output.stderr + ); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1" + ); + for target in [&one, &two] { + let info = + fs::read_to_string(format!("/proc/{}/mountinfo", target.root().init_pid())).unwrap(); + assert_eq!( + info.lines().count(), + 5, + "only root/work/tmp/proc/dev: {info}" + ); + assert!(!info.contains(" shared:") && !info.contains(" master:")); + for pid in [target.root().init_pid(), target.root().bootstrap_pid()] { + for fd in fs::read_dir(format!("/proc/{pid}/fd")).unwrap() { + assert!(!fs::metadata(fd.unwrap().path()).unwrap().is_dir()); + } + } + } + assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), before); + for (target, id) in [(one, "proc-one"), (two, "proc-two")] { + let group = root.path.join(target.root().cgroup().name()); + let error = target.refuse_remaining_isolation(); + assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem); + assert!(error.cleanup_failure.is_none()); + assert!(!group.exists()); + assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists()); + } + println!( + "KF_PROC_DEV_PROOF fresh_pid_proc=1 readonly=1 fixed_devices=1 init_and_bootstrap_no_directory_fds=1 peer_isolated=1 host_mounts_unchanged=1 refusal=1" + ); +} + +#[test] +#[ignore = "requires independent VM with externally verified whole-VM caps"] +fn unexpected_inherited_file_and_directory_fds_refuse_initialization() { + let root = Root::new(); + let before = children(); + for (path, id) in [ + ("/image-outside-sentinel", "proc-file-fd"), + ("/var/lib/kf-judge/tasks", "proc-dir-fd"), + ] { + let leak = fs::File::open(path).unwrap(); + rustix::io::fcntl_setfd(&leak, rustix::io::FdFlags::empty()).unwrap(); + let Err(error) = + RestrictedRootTarget::prepare(&root.validated(), &spec(id, 64 << 20, 8), &binary()) + else { + panic!("unexpected inherited FD must fail closed"); + }; + assert_eq!(error.failure.stage, ExecutionStage::Preparation); + assert!(error.cleanup_failure.is_none()); + assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists()); + assert!(!root.path.join(format!("kf-{id}")).exists()); + drop(leak); + } + assert_eq!(children(), before); + println!( + "KF_PROC_DEV_FD_PROOF unknown_file_and_directory_rejected=1 no_namespace_started=1 task_removed=1 child_reaped=1" + ); +} + +#[test] +#[ignore = "requires independent VM and fixed mknodat-denying test launcher"] +fn kernel_mknod_failure_rolls_back_proc_dev_and_root() { + let root = Root::new(); + assert_eq!(std::env::var("KF_SANDBOX_DENY_MKNOD").as_deref(), Ok("1")); + let before = children(); + let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap(); + let Err(error) = RestrictedRootTarget::prepare( + &root.validated(), + &spec("proc-mknod-fail", 64 << 20, 8), + &binary(), + ) else { + panic!("kernel mknodat denial must fail root stage"); + }; + assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem); + assert_eq!(error.failure.errno, Some(1)); + assert!(error.cleanup_failure.is_none()); + assert_eq!(children(), before); + assert!(!Path::new("/var/lib/kf-judge/tasks/kf-proc-mknod-fail").exists()); + assert!(!root.path.join("kf-proc-mknod-fail").exists()); + assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), mounts); + println!( + "KF_PROC_DEV_FAILURE_PROOF errno=1 partial_proc_and_dev_cleanup=1 task_root_removed=1 bootstrap_reaped=1 host_mounts_unchanged=1" + ); +}