From 9bb27f2e02ce117ccc8d90cf031feb890607ede2 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Wed, 7 Oct 2026 19:16:20 +0800 Subject: [PATCH] feat(sandbox): install profile seccomp with ABI and inheritance checks --- judge/Cargo.lock | 11 + judge/README.md | 24 + judge/crates/sandbox/Cargo.toml | 5 + .../crates/sandbox/examples/seccomp_probe.rs | 40 ++ judge/crates/sandbox/src/isolation/mod.rs | 1 + judge/crates/sandbox/src/isolation/seccomp.rs | 447 ++++++++++++++++++ judge/crates/sandbox/src/launcher/identity.rs | 20 +- judge/crates/sandbox/src/launcher/mod.rs | 2 + .../crates/sandbox/src/launcher/namespaces.rs | 35 +- judge/crates/sandbox/src/launcher/protocol.rs | 4 + judge/crates/sandbox/src/launcher/root.rs | 7 + judge/crates/sandbox/src/launcher/seccomp.rs | 51 ++ .../sandbox/tests/fixtures/seccomp_deny.c | 29 ++ .../tests/fixtures/seccomp_inherit_probe.c | 39 ++ judge/crates/sandbox/tests/seccomp_vm.rs | 116 +++++ 15 files changed, 826 insertions(+), 5 deletions(-) create mode 100644 judge/crates/sandbox/examples/seccomp_probe.rs create mode 100644 judge/crates/sandbox/src/isolation/seccomp.rs create mode 100644 judge/crates/sandbox/src/launcher/seccomp.rs create mode 100644 judge/crates/sandbox/tests/fixtures/seccomp_deny.c create mode 100644 judge/crates/sandbox/tests/fixtures/seccomp_inherit_probe.c create mode 100644 judge/crates/sandbox/tests/seccomp_vm.rs diff --git a/judge/Cargo.lock b/judge/Cargo.lock index 96cb418..4b825da 100644 --- a/judge/Cargo.lock +++ b/judge/Cargo.lock @@ -385,7 +385,9 @@ version = "0.0.1" dependencies = [ "clap", "kf-contracts", + "libc", "rustix", + "seccompiler", "serde", "serde_json", ] @@ -556,6 +558,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "serde" version = "1.0.229" diff --git a/judge/README.md b/judge/README.md index d4414ba..219a0ee 100644 --- a/judge/README.md +++ b/judge/README.md @@ -352,3 +352,27 @@ setuid-root 文件无法恢复权限,跨 UID 信号/ptrace/文件访问拒绝 注入真实 setgroups EPERM,验证失败回滚;它不构成生产 seccomp 策略。fixture 仅可在 规定独立 VM 使用,没有向 CLI 增加用户执行命令。后续 seccomp、全任务树/stdio/计量 和运行后清理仍未验收,`run` 继续拒绝。 + +### 生产 seccomp 初始化检查点 + +`launcher/seccomp.rs` 的 `FilteredTarget` 在已降权的固定 PID 1 安装策略,父监督器 +冻结后读回 `Seccomp=2`、`NoNewPrivs=1` 和增加一个过滤器;内核错误按 seccomp 阶段 +拒绝,并先清空/回收任务再释放身份租约。这个类型仍在 exec 阶段拒绝,CLI `run` 未启用。 + +`isolation/seccomp.rs` 固定支持 Linux x86_64,按 native、compile_c、compile_cpp +建立默认 EPERM 的 syscall 白名单;兼容 i386 ABI 终止整个调用进程,x32/未知编号 +拒绝。clone 只允许明确的普通 fork 或 pthread flags;编译 profile 另允许 vfork 和 +编译输出权限设置。clone3 的指针参数不可由经典 BPF 检查,因此返回 ENOSYS,交由 +glibc 回退到受检查的 clone。过滤器由固定 Rust 数据生成,经 seccompiler 0.5.0 的 +安全 API 安装;请求不能传入 BPF。libc 仅提供 ABI 常量,依赖均限制在沙箱 crate。 + +允许的语言操作仍受独立根目录、只读输入、任务身份和 cgroup 限额约束;seccomp +不能检查路径字符串。控制通道 FD 3 的 recvmsg/sendmsg 和设置 I/O 超时仅用于可信 +初始化,FD 为 CLOEXEC;最终用户 stdio/FD 替换仍须另行验收。通用信号发送、网络 +创建、ptrace、namespace 重建和权限修改没有语言例外。pthread 取消/主动发送信号、 +用户自建 namespace、clone3 专用功能和非 x86_64 程序当前不受支持。 + +`seccomp_vm.rs` 及固定 VM fixture 验证集成 init 安装、冻结读回、失败清理,以及 +fork/exec/pthread 的真实过滤器继承、i386 SIGSYS、x32 EPERM 和 clone3 ENOSYS。 +这些 fixture 仅在无共享挂载/无凭据、整体资源受限的独立 VM 中运行;普通 BPF +回归不是内核隔离验收。进程树监督、最终 stdio 和完整运行安全验收仍待实现。 diff --git a/judge/crates/sandbox/Cargo.toml b/judge/crates/sandbox/Cargo.toml index adc526e..ce2bb01 100644 --- a/judge/crates/sandbox/Cargo.toml +++ b/judge/crates/sandbox/Cargo.toml @@ -12,6 +12,11 @@ serde.workspace = true serde_json.workspace = true rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio"] } +# Fixed BPF compilation/installation through safe APIs; libc supplies syscall +# numbers only. Both dependencies stay inside the OS sandbox boundary. +seccompiler = { version = "=0.5.0", default-features = false } +libc = "=0.2.190" + [lints] workspace = true diff --git a/judge/crates/sandbox/examples/seccomp_probe.rs b/judge/crates/sandbox/examples/seccomp_probe.rs new file mode 100644 index 0000000..89516b8 --- /dev/null +++ b/judge/crates/sandbox/examples/seccomp_probe.rs @@ -0,0 +1,40 @@ +//! VM-only fixed fixture, using the exact private production policy/reducer. +mod launcher { + pub mod entry { + pub use kf_sandbox::launcher::entry::ensure_single_threaded; + } +} +mod spec { + pub use kf_sandbox::spec::ExecutionProfile; +} +#[allow(dead_code)] +#[path = "../src/isolation/identity.rs"] +pub(crate) mod identity; +mod isolation { + pub(crate) use crate::identity; +} +#[allow(dead_code)] +#[path = "../src/isolation/seccomp.rs"] +mod policy; +#[path = "../tests/support/mod.rs"] +#[allow(dead_code)] +mod support; +use std::os::unix::process::CommandExt; +fn main() { + support::require_vm(); + let args: Vec<_> = std::env::args().collect(); + assert_eq!(args.len(), 2); + let profile = match args[1].as_str() { + "native" => spec::ExecutionProfile::Native, + "compile-c" => spec::ExecutionProfile::CompileC, + "compile-cpp" => spec::ExecutionProfile::CompileCpp, + _ => panic!("fixed fixture profile"), + }; + isolation::identity::reduce_current(100000, 100000).unwrap(); + policy::install(profile).unwrap(); + let e = std::process::Command::new("/kf/bin/seccomp-inherit-probe") + .arg(&args[1]) + .env_clear() + .exec(); + panic!("fixed fixture exec: {e}"); +} diff --git a/judge/crates/sandbox/src/isolation/mod.rs b/judge/crates/sandbox/src/isolation/mod.rs index db915c7..56e1fe4 100644 --- a/judge/crates/sandbox/src/isolation/mod.rs +++ b/judge/crates/sandbox/src/isolation/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod inputs; pub mod namespaces; pub(crate) mod proc_dev; pub(crate) mod root; +pub(crate) mod seccomp; diff --git a/judge/crates/sandbox/src/isolation/seccomp.rs b/judge/crates/sandbox/src/isolation/seccomp.rs new file mode 100644 index 0000000..32d0d1d --- /dev/null +++ b/judge/crates/sandbox/src/isolation/seccomp.rs @@ -0,0 +1,447 @@ +//! Fixed x86_64 syscall policy, not request-supplied BPF or a user execution permit. +use crate::spec::ExecutionProfile; +use seccompiler::{ + BpfProgram, SeccompAction, SeccompCmpArgLen::Qword, SeccompCmpOp, SeccompCondition, + SeccompFilter, SeccompRule, TargetArch, sock_filter, +}; +use std::{collections::BTreeMap, io}; + +const AUDIT_X86_64: u32 = 0xc000_003e; +const MAX_INSTRUCTIONS: usize = 2048; + +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} +fn condition(arg: u8, op: SeccompCmpOp, value: u64) -> io::Result { + SeccompCondition::new(arg, Qword, op, value).map_err(io::Error::other) +} +fn rules(conditions: Vec>) -> io::Result> { + conditions + .into_iter() + .map(|c| SeccompRule::new(c).map_err(io::Error::other)) + .collect() +} + +pub(crate) fn compile(profile: ExecutionProfile) -> io::Result { + if !cfg!(target_arch = "x86_64") { + return Err(denied("unsupported seccomp architecture")); + } + let mut allowed = BTreeMap::new(); + // Ordinary glibc, C/C++ I/O, loader, memory, clocks, synchronization and + // child waiting. Paths/FDs still require the independent filesystem and + // descriptor boundaries; seccomp cannot inspect pointed-to path strings. + for nr in [ + libc::SYS_read, + libc::SYS_write, + libc::SYS_readv, + libc::SYS_writev, + libc::SYS_pread64, + libc::SYS_pwrite64, + libc::SYS_preadv, + libc::SYS_pwritev, + libc::SYS_open, + libc::SYS_openat, + libc::SYS_close, + libc::SYS_close_range, + libc::SYS_lseek, + libc::SYS_stat, + libc::SYS_lstat, + libc::SYS_fstat, + libc::SYS_newfstatat, + libc::SYS_statx, + libc::SYS_access, + libc::SYS_faccessat, + libc::SYS_faccessat2, + libc::SYS_getdents64, + libc::SYS_readlink, + libc::SYS_readlinkat, + libc::SYS_getcwd, + libc::SYS_chdir, + libc::SYS_fchdir, + libc::SYS_dup, + libc::SYS_dup2, + libc::SYS_dup3, + libc::SYS_fcntl, + libc::SYS_pipe, + libc::SYS_pipe2, + libc::SYS_poll, + libc::SYS_ppoll, + libc::SYS_select, + libc::SYS_pselect6, + libc::SYS_epoll_create1, + libc::SYS_epoll_ctl, + libc::SYS_epoll_wait, + libc::SYS_epoll_pwait, + libc::SYS_eventfd2, + libc::SYS_mmap, + libc::SYS_mprotect, + libc::SYS_munmap, + libc::SYS_mremap, + libc::SYS_madvise, + libc::SYS_brk, + libc::SYS_msync, + libc::SYS_mincore, + libc::SYS_futex, + libc::SYS_set_tid_address, + libc::SYS_set_robust_list, + libc::SYS_get_robust_list, + libc::SYS_rseq, + libc::SYS_rt_sigaction, + libc::SYS_rt_sigprocmask, + libc::SYS_rt_sigreturn, + libc::SYS_sigaltstack, + libc::SYS_rt_sigsuspend, + libc::SYS_rt_sigtimedwait, + libc::SYS_restart_syscall, + libc::SYS_clock_gettime, + libc::SYS_clock_getres, + libc::SYS_clock_nanosleep, + libc::SYS_nanosleep, + libc::SYS_gettimeofday, + libc::SYS_time, + libc::SYS_sched_yield, + libc::SYS_sched_getaffinity, + libc::SYS_getrandom, + libc::SYS_uname, + libc::SYS_getpid, + libc::SYS_getppid, + libc::SYS_gettid, + libc::SYS_getuid, + libc::SYS_geteuid, + libc::SYS_getgid, + libc::SYS_getegid, + libc::SYS_getresuid, + libc::SYS_getresgid, + libc::SYS_getgroups, + libc::SYS_capget, + libc::SYS_getrusage, + libc::SYS_times, + libc::SYS_getrlimit, + libc::SYS_sysinfo, + libc::SYS_umask, + libc::SYS_fsync, + libc::SYS_fdatasync, + libc::SYS_ftruncate, + libc::SYS_truncate, + libc::SYS_fallocate, + libc::SYS_mkdir, + libc::SYS_mkdirat, + libc::SYS_rmdir, + libc::SYS_unlink, + libc::SYS_unlinkat, + libc::SYS_rename, + libc::SYS_renameat, + libc::SYS_renameat2, + libc::SYS_utimensat, + libc::SYS_statfs, + libc::SYS_fstatfs, + libc::SYS_fork, + libc::SYS_wait4, + libc::SYS_waitid, + libc::SYS_execve, + libc::SYS_exit, + libc::SYS_exit_group, + ] { + allowed.insert(nr, vec![]); + } + // clone is the inspectable glibc fallback. Unknown/high bits, new + // namespaces, ptrace, pidfd and parent reassignment never match. + let child_flags = (libc::CLONE_CHILD_SETTID | libc::CLONE_CHILD_CLEARTID) as u64; + let thread_required = (libc::CLONE_VM + | libc::CLONE_FS + | libc::CLONE_FILES + | libc::CLONE_SIGHAND + | libc::CLONE_THREAD) as u64; + let thread_optional = + child_flags | (libc::CLONE_SYSVSEM | libc::CLONE_SETTLS | libc::CLONE_PARENT_SETTID) as u64; + let mut clones = vec![ + vec![condition( + 0, + SeccompCmpOp::MaskedEq(!child_flags), + libc::SIGCHLD as u64, + )?], + vec![condition( + 0, + SeccompCmpOp::MaskedEq(!thread_optional), + thread_required, + )?], + ]; + if profile != ExecutionProfile::Native { + clones.push(vec![condition( + 0, + SeccompCmpOp::Eq, + (libc::CLONE_VM | libc::CLONE_VFORK | libc::SIGCHLD) as u64, + )?]); + for nr in [ + libc::SYS_vfork, + libc::SYS_chmod, + libc::SYS_fchmod, + libc::SYS_fchmodat, + ] { + allowed.insert(nr, vec![]); + } + } + allowed.insert(libc::SYS_clone, rules(clones)?); + // TLS setup is needed by dynamic/static glibc, but user code cannot mutate + // GS or use arbitrary arch_prctl extensions. + allowed.insert( + libc::SYS_arch_prctl, + rules(vec![vec![condition( + 0, + SeccompCmpOp::Eq, + 0x1002, // ARCH_SET_FS + )?]])?, + ); + // Read-only process information; mutating privilege, dumpability and + // seccomp settings is not part of the language contract. + allowed.insert( + libc::SYS_prctl, + rules( + [ + libc::PR_GET_NO_NEW_PRIVS, + libc::PR_GET_SECCOMP, + libc::PR_GET_SECUREBITS, + libc::PR_GET_KEEPCAPS, + libc::PR_GET_PDEATHSIG, + ] + .into_iter() + .map(|op| Ok(vec![condition(0, SeccompCmpOp::Eq, op as u64)?])) + .collect::>()?, + )?, + ); + // glibc reads its own limit through prlimit64(pid=0,new_limit=NULL). + allowed.insert( + libc::SYS_prlimit64, + rules(vec![vec![ + condition(0, SeccompCmpOp::Eq, 0)?, + condition(2, SeccompCmpOp::Eq, 0)?, + ]])?, + ); + // Initialization transport only. The FD is fixed, authenticated and + // CLOEXEC; the final execution stage must additionally close/replace stdio. + for nr in [libc::SYS_recvmsg, libc::SYS_sendmsg] { + allowed.insert(nr, rules(vec![vec![condition(0, SeccompCmpOp::Eq, 3)?]])?); + } + // UnixStream::write uses send(MSG_NOSIGNAL), encoded as sendto with no + // destination on x86_64. It is not a network address exception. + allowed.insert( + libc::SYS_sendto, + rules(vec![vec![ + condition(0, SeccompCmpOp::Eq, 3)?, + condition(4, SeccompCmpOp::Eq, 0)?, + condition(5, SeccompCmpOp::Eq, 0)?, + ]])?, + ); + allowed.insert( + libc::SYS_shutdown, + rules(vec![vec![condition(0, SeccompCmpOp::Eq, 3)?]])?, + ); + allowed.insert( + libc::SYS_setsockopt, + rules( + [libc::SO_RCVTIMEO, libc::SO_SNDTIMEO] + .into_iter() + .map(|opt| { + Ok(vec![ + condition(0, SeccompCmpOp::Eq, 3)?, + condition(1, SeccompCmpOp::Eq, libc::SOL_SOCKET as u64)?, + condition(2, SeccompCmpOp::Eq, opt as u64)?, + ]) + }) + .collect::>()?, + )?, + ); + let base: BpfProgram = SeccompFilter::new( + allowed, + SeccompAction::Errno(libc::EPERM as u32), + SeccompAction::Allow, + TargetArch::x86_64, + ) + .map_err(io::Error::other)? + .try_into() + .map_err(io::Error::other)?; + // clone3 has pointer arguments that classic BPF cannot safely inspect. + // Return ENOSYS to select glibc's argument-checked clone fallback. Guard + // the ABI before this exception; the generated base also checks the ABI. + let mut program = vec![ + sock_filter { + code: 0x20, + jt: 0, + jf: 0, + k: 4, + }, + sock_filter { + code: 0x15, + jt: 1, + jf: 0, + k: AUDIT_X86_64, + }, + sock_filter { + code: 0x06, + jt: 0, + jf: 0, + k: libc::SECCOMP_RET_KILL_PROCESS, + }, + sock_filter { + code: 0x20, + jt: 0, + jf: 0, + k: 0, + }, + sock_filter { + code: 0x15, + jt: 0, + jf: 1, + k: libc::SYS_clone3 as u32, + }, + sock_filter { + code: 0x06, + jt: 0, + jf: 0, + k: libc::SECCOMP_RET_ERRNO | libc::ENOSYS as u32, + }, + ]; + program.extend(base); + if program.len() > MAX_INSTRUCTIONS { + return Err(denied("seccomp instruction budget")); + } + Ok(program) +} + +pub(crate) fn filter_count(status: &str) -> io::Result { + let field = |name| { + status + .lines() + .find_map(|line| line.strip_prefix(name)) + .and_then(|s| s.trim().parse::().ok()) + }; + if field("Seccomp:") != Some(2) || field("NoNewPrivs:") != Some(1) { + return Err(denied("seccomp status readback")); + } + field("Seccomp_filters:").ok_or_else(|| denied("missing seccomp filter count")) +} + +pub(crate) fn install(profile: ExecutionProfile) -> io::Result<()> { + crate::launcher::entry::ensure_single_threaded()?; + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + if !crate::isolation::identity::valid(uid, gid) { + return Err(denied("seccomp task identity precondition")); + } + crate::isolation::identity::verify_status( + &std::fs::read_to_string("/proc/self/status")?, + uid, + gid, + )?; + let program = compile(profile)?; + seccompiler::apply_filter_all_threads(&program).map_err(|e| match e { + seccompiler::Error::Prctl(e) | seccompiler::Error::Seccomp(e) => e, + other => io::Error::other(other), + })?; + filter_count(&std::fs::read_to_string("/proc/self/status")?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + // Evaluate generated classic BPF against synthetic seccomp_data; this is + // an ordinary policy regression, not evidence of kernel installation. + fn evaluate(p: &[sock_filter], arch: u32, nr: u32, args: [u64; 6]) -> u32 { + let mut data = [0_u8; 64]; + data[..4].copy_from_slice(&nr.to_le_bytes()); + data[4..8].copy_from_slice(&arch.to_le_bytes()); + for (i, arg) in args.into_iter().enumerate() { + data[16 + i * 8..24 + i * 8].copy_from_slice(&arg.to_le_bytes()); + } + let (mut pc, mut a) = (0_usize, 0_u32); + for _ in 0..MAX_INSTRUCTIONS { + let ins = &p[pc]; + pc += 1; + match ins.code { + 0x20 => { + a = u32::from_le_bytes( + data[ins.k as usize..ins.k as usize + 4].try_into().unwrap(), + ) + } + 0x54 => a &= ins.k, + 0x05 => pc += ins.k as usize, + 0x15 => pc += if a == ins.k { ins.jt } else { ins.jf } as usize, + 0x25 => pc += if a > ins.k { ins.jt } else { ins.jf } as usize, + 0x35 => pc += if a >= ins.k { ins.jt } else { ins.jf } as usize, + 0x06 => return ins.k, + _ => panic!("unexpected BPF opcode"), + } + } + panic!("BPF budget exceeded"); + } + #[test] + fn abi_unknown_calls_and_clone_flags_fail_closed() { + for profile in [ + ExecutionProfile::Native, + ExecutionProfile::CompileC, + ExecutionProfile::CompileCpp, + ] { + let p = compile(profile).unwrap(); + let deny = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32; + assert_eq!( + evaluate(&p, 0x40000003, 20, [0; 6]), + libc::SECCOMP_RET_KILL_PROCESS + ); + assert_eq!( + evaluate(&p, 0x40000003, libc::SYS_clone3 as u32, [0; 6]), + libc::SECCOMP_RET_KILL_PROCESS + ); + for nr in [0x40000000 | libc::SYS_getuid as u32, u32::MAX, 1023] { + assert_eq!(evaluate(&p, AUDIT_X86_64, nr, [0; 6]), deny); + } + assert_eq!( + evaluate(&p, AUDIT_X86_64, libc::SYS_clone3 as u32, [0; 6]), + libc::SECCOMP_RET_ERRNO | libc::ENOSYS as u32 + ); + for flags in [ + libc::CLONE_NEWUSER as u64, + libc::CLONE_NEWPID as u64, + libc::CLONE_PARENT as u64, + 1_u64 << 32, + libc::CLONE_PTRACE as u64, + ] { + let mut args = [0; 6]; + args[0] = flags | libc::SIGCHLD as u64; + assert_eq!( + evaluate(&p, AUDIT_X86_64, libc::SYS_clone as u32, args), + deny + ); + } + } + } + #[test] + fn compile_profile_exception_does_not_widen_native() { + let flags = (libc::CLONE_VM | libc::CLONE_VFORK | libc::SIGCHLD) as u64; + for profile in [ + ExecutionProfile::Native, + ExecutionProfile::CompileC, + ExecutionProfile::CompileCpp, + ] { + let p = compile(profile).unwrap(); + let expected = if profile == ExecutionProfile::Native { + libc::SECCOMP_RET_ERRNO | libc::EPERM as u32 + } else { + libc::SECCOMP_RET_ALLOW + }; + assert_eq!( + evaluate( + &p, + AUDIT_X86_64, + libc::SYS_clone as u32, + [flags, 0, 0, 0, 0, 0] + ), + expected + ); + assert_eq!( + evaluate(&p, AUDIT_X86_64, libc::SYS_chmod as u32, [0; 6]), + expected + ); + } + } +} diff --git a/judge/crates/sandbox/src/launcher/identity.rs b/judge/crates/sandbox/src/launcher/identity.rs index c6d44eb..19bb572 100644 --- a/judge/crates/sandbox/src/launcher/identity.rs +++ b/judge/crates/sandbox/src/launcher/identity.rs @@ -201,15 +201,27 @@ impl IdentityReducedTarget { pub fn stdin(&self) -> Option<&crate::artifacts::SnapshotFile> { self.target.as_ref().expect("owned target").stdin() } - pub fn refuse_remaining_isolation(mut self) -> InitializationFailure { - let target = self.target.take().expect("owned target"); - let mut result = target + pub(crate) fn initialize_seccomp(&mut self) -> io::Result<()> { + self.target + .as_mut() + .expect("owned target") .target .root - .refuse_stage(ExecutionStage::Seccomp, None); + .initialize_seccomp() + } + pub(crate) fn refuse_at( + mut self, + stage: ExecutionStage, + error: Option<&io::Error>, + ) -> InitializationFailure { + let target = self.target.take().expect("owned target"); + let mut result = target.target.root.refuse_stage(stage, error); release_after_cleanup(&mut self.lease, &mut result); result } + pub fn refuse_remaining_isolation(self) -> InitializationFailure { + self.refuse_at(ExecutionStage::Seccomp, None) + } } impl Drop for IdentityReducedTarget { fn drop(&mut self) { diff --git a/judge/crates/sandbox/src/launcher/mod.rs b/judge/crates/sandbox/src/launcher/mod.rs index dcf1bee..f77849b 100644 --- a/judge/crates/sandbox/src/launcher/mod.rs +++ b/judge/crates/sandbox/src/launcher/mod.rs @@ -22,3 +22,5 @@ pub mod proc_dev; pub mod protocol; #[cfg(target_os = "linux")] pub mod root; +#[cfg(target_os = "linux")] +pub mod seccomp; diff --git a/judge/crates/sandbox/src/launcher/namespaces.rs b/judge/crates/sandbox/src/launcher/namespaces.rs index 1033110..83586b6 100644 --- a/judge/crates/sandbox/src/launcher/namespaces.rs +++ b/judge/crates/sandbox/src/launcher/namespaces.rs @@ -192,7 +192,14 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io:: match channel.receive::()? { NamespaceCommand::PrepareIdentity { uid, gid } => { super::identity::initialize(&mut channel, uid, gid)?; - let _ = channel.receive::()?; + match channel.receive::()? { + NamespaceCommand::PrepareSeccomp { profile } => { + super::seccomp::initialize(&mut channel, profile)?; + let _ = channel.receive::()?; + } + NamespaceCommand::Cancel {} => return Ok(()), + _ => return Err(denied("seccomp command before identity")), + } } NamespaceCommand::Cancel {} => return Ok(()), _ => return Err(denied("identity command before inputs")), @@ -219,6 +226,7 @@ pub struct NamespacedTarget { proc_dev_ready: bool, inputs_ready: bool, identity_ready: bool, + seccomp_ready: bool, init_pid: u32, ids: NamespaceIds, } @@ -313,6 +321,7 @@ impl NamespacedTarget { proc_dev_ready: false, inputs_ready: false, identity_ready: false, + seccomp_ready: false, }), Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))), } @@ -405,6 +414,30 @@ impl NamespacedTarget { Ok(()) } + pub(crate) fn initialize_seccomp( + &mut self, + profile: crate::spec::ExecutionProfile, + ) -> io::Result<()> { + if !self.identity_ready || self.seccomp_ready { + return Err(denied("seccomp initialization order")); + } + let status = std::fs::read_to_string(format!("/proc/{}/status", self.init_pid))?; + let before = status_value(&status, "Seccomp_filters:")?; + self.initialize_filesystem( + NamespaceCommand::PrepareSeccomp { profile }, + NamespaceEvent::SeccompInitialized {}, + )?; + let after = crate::isolation::seccomp::filter_count(&std::fs::read_to_string(format!( + "/proc/{}/status", + self.init_pid + ))?)?; + if before.checked_add(1) != Some(after) { + return Err(denied("seccomp filter count mismatch")); + } + self.seccomp_ready = true; + Ok(()) + } + fn expect_event(&mut self, expected: NamespaceEvent) -> io::Result<()> { match self.control.receive::()? { event if event == expected => Ok(()), diff --git a/judge/crates/sandbox/src/launcher/protocol.rs b/judge/crates/sandbox/src/launcher/protocol.rs index bc0d55e..d98ab79 100644 --- a/judge/crates/sandbox/src/launcher/protocol.rs +++ b/judge/crates/sandbox/src/launcher/protocol.rs @@ -67,6 +67,9 @@ pub enum NamespaceCommand { uid: u32, gid: u32, }, + PrepareSeccomp { + profile: crate::spec::ExecutionProfile, + }, Cancel {}, } @@ -80,6 +83,7 @@ pub enum NamespaceEvent { InputAttached {}, InputsInitialized {}, IdentityInitialized {}, + SeccompInitialized {}, Failed { errno: Option }, Cancelled {}, } diff --git a/judge/crates/sandbox/src/launcher/root.rs b/judge/crates/sandbox/src/launcher/root.rs index 83165e8..4da5bb6 100644 --- a/judge/crates/sandbox/src/launcher/root.rs +++ b/judge/crates/sandbox/src/launcher/root.rs @@ -154,6 +154,13 @@ impl RootedTarget { self.lease.verify_root(self.bootstrap_pid()) } + pub(crate) fn initialize_seccomp(&mut self) -> io::Result<()> { + self.target + .initialize_seccomp(self.request.spec().profile)?; + self.lease.verify_root(self.init_pid())?; + self.lease.verify_root(self.bootstrap_pid()) + } + pub(crate) fn refuse_stage( mut self, stage: ExecutionStage, diff --git a/judge/crates/sandbox/src/launcher/seccomp.rs b/judge/crates/sandbox/src/launcher/seccomp.rs new file mode 100644 index 0000000..b38dbf1 --- /dev/null +++ b/judge/crates/sandbox/src/launcher/seccomp.rs @@ -0,0 +1,51 @@ +//! Seccomp initialization checkpoint; the missing user supervisor still refuses exec. +use super::{blocked::InitializationFailure, identity::IdentityReducedTarget}; +use crate::{ + artifacts::ArtifactRoot, outcome::ExecutionStage, runtime::ValidatedRuntimeConfig, + spec::ValidatedSpec, +}; +use std::{io, path::Path}; + +pub struct FilteredTarget { + target: IdentityReducedTarget, +} +impl FilteredTarget { + pub fn prepare( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + executable: &Path, + artifacts: &ArtifactRoot, + ) -> Result { + let mut target = IdentityReducedTarget::prepare(config, spec, executable, artifacts)?; + if let Err(e) = target.initialize_seccomp() { + return Err(target.refuse_at(ExecutionStage::Seccomp, Some(&e))); + } + Ok(Self { target }) + } + pub fn root(&self) -> &super::root::RootedTarget { + self.target.root() + } + pub fn identity(&self) -> (u32, u32) { + self.target.identity() + } + pub fn stdin(&self) -> Option<&crate::artifacts::SnapshotFile> { + self.target.stdin() + } + pub fn refuse_remaining_isolation(self) -> InitializationFailure { + self.target.refuse_at(ExecutionStage::Exec, None) + } +} + +pub(crate) fn initialize( + channel: &mut super::protocol::ControlChannel, + profile: crate::spec::ExecutionProfile, +) -> io::Result<()> { + let result = crate::isolation::seccomp::install(profile); + if let Err(e) = result { + let _ = channel.send(&super::protocol::NamespaceEvent::Failed { + errno: e.raw_os_error().and_then(|n| u16::try_from(n).ok()), + }); + return Err(e); + } + channel.send(&super::protocol::NamespaceEvent::SeccompInitialized {}) +} diff --git a/judge/crates/sandbox/tests/fixtures/seccomp_deny.c b/judge/crates/sandbox/tests/fixtures/seccomp_deny.c new file mode 100644 index 0000000..eab8bad --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/seccomp_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_seccomp,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_SECCOMP","1",1); + execl("/kf/bin/seccomp-vm-tests","seccomp-vm-tests","--ignored","--test-threads=1","--exact","kernel_seccomp_failure_refuses_and_cleans","--nocapture",(char*)0); + return 4; +} diff --git a/judge/crates/sandbox/tests/fixtures/seccomp_inherit_probe.c b/judge/crates/sandbox/tests/fixtures/seccomp_inherit_probe.c new file mode 100644 index 0000000..87d12cc --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/seccomp_inherit_probe.c @@ -0,0 +1,39 @@ +/* Fixed independent-VM probe; never installed as a user execution endpoint. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static void require(int yes,const char *why){if(!yes){fprintf(stderr,"seccomp probe: %s errno=%d\n",why,errno);exit(1);}} +static void filtered(void){ + require(prctl(PR_GET_SECCOMP)==2,"mode"); + require(prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)==1,"nnp"); + FILE *f=fopen("/proc/self/status","r");require(f!=NULL,"status"); + char line[256];unsigned count=0; + while(fgets(line,sizeof line,f))if(sscanf(line,"Seccomp_filters: %u",&count)==1)break; + fclose(f);require(count==1,"one production filter"); + errno=0;require(syscall(0x40000000|SYS_getuid)==-1&&errno==EPERM,"x32 rejected"); + errno=0;require(syscall(SYS_clone3,NULL,0)==-1&&errno==ENOSYS,"clone3 fallback"); + errno=0;require(syscall(1023)==-1&&errno==EPERM,"unknown rejected"); +} +static void *thread(void *p){filtered();return p;} +int main(int argc,char **argv){ + require(argc==2,"fixed args");filtered(); + if(!strcmp(argv[1],"--exec-child")){puts("KF_SECCOMP_EXEC_INHERIT_PROOF");return 0;} + pthread_t t;require(pthread_create(&t,NULL,thread,(void*)17)==0,"pthread create"); + void *result=NULL;require(pthread_join(t,&result)==0&&result==(void*)17,"pthread join"); + pid_t p=fork();require(p>=0,"fork"); + if(!p){filtered();execl("/kf/bin/seccomp-inherit-probe","seccomp-inherit-probe","--exec-child",(char*)0);_exit(3);} + int status=0;require(waitpid(p,&status,0)==p&&WIFEXITED(status)&&WEXITSTATUS(status)==0,"fork exec wait"); + p=fork();require(p>=0,"i386 fork"); + if(!p){long value;__asm__ volatile("int $0x80":"=a"(value):"0"(20):"memory");_exit(value?4:5);} + require(waitpid(p,&status,0)==p&&WIFSIGNALED(status)&&WTERMSIG(status)==SIGSYS,"i386 killed"); + printf("KF_SECCOMP_INHERIT_PROOF profile=%s mode=2 nnp=1 filters=1 fork_exec_thread=1 x32_eperm=1 i386_sigsys=1 clone3_enosys=1 unknown_eperm=1\n",argv[1]); + return 0; +} diff --git a/judge/crates/sandbox/tests/seccomp_vm.rs b/judge/crates/sandbox/tests/seccomp_vm.rs new file mode 100644 index 0000000..3d8e6ad --- /dev/null +++ b/judge/crates/sandbox/tests/seccomp_vm.rs @@ -0,0 +1,116 @@ +#![cfg(target_os = "linux")] +mod support; +use kf_sandbox::{ + artifacts::ArtifactRoot, + launcher::seccomp::FilteredTarget, + outcome::ExecutionStage, + spec::{ExecutionProfile, SpecPolicy}, +}; +use std::{fs, os::unix::fs::PermissionsExt, path::Path, process::Command}; + +fn check(profile: ExecutionProfile, name: &str) { + let root = support::Root::new(); + let before = support::children(); + let path = format!("/kf/seccomp-publications-{}", std::process::id()); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(format!("{path}/program-001"), b"fixed-non-task-payload").unwrap(); + fs::write(format!("{path}/input-001"), b"private-stdin").unwrap(); + let artifacts = ArtifactRoot::from_directory( + rustix::fs::open( + &path, + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(), + ) + .unwrap(); + let mut spec = support::spec("seccomp-fixture", 64 << 20, 8).spec().clone(); + spec.run_id = format!("seccomp-{name}"); + spec.profile = profile; + if profile != ExecutionProfile::Native { + spec.argv = vec![ + if profile == ExecutionProfile::CompileC { + "/usr/bin/gcc" + } else { + "/usr/bin/g++" + } + .into(), + "/work/main".into(), + ]; + } + let result = FilteredTarget::prepare( + &root.validated(), + &spec.validate(&SpecPolicy::default()).unwrap(), + &support::binary(), + &artifacts, + ); + if std::env::var("KF_SANDBOX_DENY_SECCOMP").as_deref() == Ok("1") { + let Err(e) = result else { + panic!("kernel policy failure ignored") + }; + assert_eq!(e.failure.stage, ExecutionStage::Seccomp); + assert_eq!(e.failure.errno, Some(1)); + assert!(e.cleanup_failure.is_none()); + println!("KF_SECCOMP_FAILURE_PROOF real_eperm=1 no_ack=1 target_reaped=1 lease_released=1"); + } else { + let target = result.unwrap(); + let status = + fs::read_to_string(format!("/proc/{}/status", target.root().init_pid())).unwrap(); + assert!(status.contains("Seccomp:\t2\n")); + assert!(status.contains("Seccomp_filters:\t1\n")); + assert!(status.contains("NoNewPrivs:\t1\n")); + assert_eq!(target.identity(), (100000, 100000)); + assert!(target.root().snapshot_outputs().unwrap().is_empty()); + let e = target.refuse_remaining_isolation(); + assert_eq!(e.failure.stage, ExecutionStage::Exec); + assert!(e.cleanup_failure.is_none()); + let output = Command::new("/kf/bin/seccomp-probe") + .arg(name) + .env_clear() + .env("KF_SANDBOX_INDEPENDENT_VM", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{:?} {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + print!("{}", String::from_utf8_lossy(&output.stdout)); + println!( + "KF_SECCOMP_INIT_PROOF profile={name} frozen=1 filter_count=1 identity=1 run_refused=1" + ); + } + assert_eq!(support::children(), before); + assert_eq!( + fs::read_dir("/var/lib/kf-judge/identities") + .unwrap() + .count(), + 0 + ); + assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-seccomp-{name}")).exists()); + assert!(!root.path.join(format!("kf-seccomp-{name}")).exists()); + fs::remove_dir_all(path).unwrap(); +} +#[test] +#[ignore = "requires independent capped VM"] +fn native_policy_installs_and_inherits() { + check(ExecutionProfile::Native, "native"); +} +#[test] +#[ignore = "requires independent capped VM"] +fn compile_c_policy_installs_and_inherits() { + check(ExecutionProfile::CompileC, "compile-c"); +} +#[test] +#[ignore = "requires independent capped VM"] +fn compile_cpp_policy_installs_and_inherits() { + check(ExecutionProfile::CompileCpp, "compile-cpp"); +} +#[test] +#[ignore = "requires independent capped VM with kernel seccomp failure injector"] +fn kernel_seccomp_failure_refuses_and_cleans() { + assert_eq!(std::env::var("KF_SANDBOX_DENY_SECCOMP").as_deref(), Ok("1")); + check(ExecutionProfile::Native, "native"); +}