diff --git a/judge/README.md b/judge/README.md index fe1dbb8..d4414ba 100644 --- a/judge/README.md +++ b/judge/README.md @@ -329,3 +329,26 @@ stdin 只保留在监督器的独立快照中,不挂载到用户工作区, `inputs_vm.rs` 在独立受限 VM 验收真实只读写入 EROFS、执行/noexec 区分、输入祖先 保护、stdin 不可见、不同任务隔离,以及链接/已有文件/空间不足导致的部分挂载回滚。 身份降权、capabilities/seccomp、用户进程树与 stdio 仍须完成后才能启用 `run`。 + +## 生产身份与权限收缩 + +`launcher/identity.rs` 的 `IdentityReducedTarget` 先在可信 state 根下的 root-owned +0700 `identities/uid-N` 目录中原子租用任务身份,再准备输入和推进降权。活跃、遗留或 +未知标记一律不接管;池耗尽返回 EAGAIN。目标树终止、子进程回收和 root 清理均成功后 +才移除自有租约;清理失败保留标记、独立报告错误,Drop 也遵循同样顺序。服务重启 +后的遗留资源核验/回收仍是后续阶段,当前不会自动复用残留身份。 + +`isolation/identity.rs` 仅在固定单线程 PID 1 中执行:清空 supplementary groups, +关闭 keepcaps/ambient;锁定 root 特权、ambient 提升与 keepcaps,保留并锁定正常 UID +切换清权行为;遍历内核实际支持的全部 capability 位清空 bounding set,设置 +`no_new_privs`,统一 real/effective/saved/filesystem UID/GID,并显式清空 +effective/permitted/inheritable。凭据变更后重新设置 PDEATHSIG=SIGKILL。 +初始化器读取 securebits/keepcaps/NNP/全部 capability 与组,监督器读取同一已持有 PID +的内核 status,再校验 root/cwd/目录 FD 和 STOP/freeze。未知 ABI、失败或不一致拒绝。 + +`identity_vm.rs` 与固定 VM fixture 验收真正的集成 init 凭据、并发独占/池耗尽/清理 +后复用/失败留租约,以及精确生产 reducer 代码在 fork/线程/exec 中的权限继承;固定 +setuid-root 文件无法恢复权限,跨 UID 信号/ptrace/文件访问拒绝。测试故障过滤器只 +注入真实 setgroups EPERM,验证失败回滚;它不构成生产 seccomp 策略。fixture 仅可在 +规定独立 VM 使用,没有向 CLI 增加用户执行命令。后续 seccomp、全任务树/stdio/计量 +和运行后清理仍未验收,`run` 继续拒绝。 diff --git a/judge/crates/sandbox/Cargo.toml b/judge/crates/sandbox/Cargo.toml index e319e47..adc526e 100644 --- a/judge/crates/sandbox/Cargo.toml +++ b/judge/crates/sandbox/Cargo.toml @@ -14,3 +14,7 @@ rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mou [lints] workspace = true + +[[example]] +name = "identity_probe" +test = false diff --git a/judge/crates/sandbox/examples/identity_probe.rs b/judge/crates/sandbox/examples/identity_probe.rs new file mode 100644 index 0000000..fd81c31 --- /dev/null +++ b/judge/crates/sandbox/examples/identity_probe.rs @@ -0,0 +1,66 @@ +//! Fixed VM-only fixture; compiles the exact private production reducer. +//! It supplies no production user execution capability or arbitrary command. +mod launcher { + pub mod entry { + pub use kf_sandbox::launcher::entry::ensure_single_threaded; + } +} +#[allow(dead_code)] +#[path = "../src/isolation/identity.rs"] +mod identity; +#[path = "../tests/support/mod.rs"] +#[allow(dead_code)] +mod support; + +fn main() { + support::require_vm(); + let args: Vec<_> = std::env::args().collect(); + assert_eq!(args.len(), 3); + assert_eq!(args[1], "fixed-probe"); + let control = std::process::Command::new("/kf/bin/identity-inherit-probe") + .arg("--suid-control-child") + .env_clear() + .output() + .unwrap(); + assert!( + control.status.success(), + "{}", + String::from_utf8_lossy(&control.stderr) + ); + assert_eq!( + control.stdout, + b"KF_SUID_CONTROL_PROOF works_without_nnp=1\n" + ); + print!("{}", String::from_utf8_lossy(&control.stdout)); + rustix::thread::set_thread_groups(&[rustix::process::Gid::from_raw(123)]).unwrap(); + let mut caps = rustix::thread::capabilities(None).unwrap(); + caps.inheritable |= rustix::thread::CapabilitySet::CHOWN; + rustix::thread::set_capabilities(None, caps).unwrap(); + rustix::thread::configure_capability_in_ambient_set(rustix::thread::CapabilitySet::CHOWN, true) + .unwrap(); + rustix::thread::set_keep_capabilities(true).unwrap(); + identity::reduce_current(100000, 100000).unwrap(); + for program in [ + "/kf/bin/identity-inherit-probe", + "/kf/bin/identity-suid-probe", + ] { + let mut command = std::process::Command::new(program); + command.env_clear(); + if let Some(pid) = args.get(2) { + let pid: u32 = pid.parse().unwrap(); + assert!(pid > 1); + command.arg(pid.to_string()); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{:?} {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout,b"KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1\n"); + } + println!( + "KF_IDENTITY_REDUCER_PROOF primed_inheritable_ambient_groups_keepcaps_cleared=1 setuid_exec_denied=1 fork_thread_inherit=1 peer_access_denied=1" + ); +} diff --git a/judge/crates/sandbox/src/isolation/identity.rs b/judge/crates/sandbox/src/isolation/identity.rs new file mode 100644 index 0000000..5ad8bea --- /dev/null +++ b/judge/crates/sandbox/src/isolation/identity.rs @@ -0,0 +1,144 @@ +//! Irreversible credential reduction, called only by the fixed single-threaded init. +use rustix::process::{Gid, Signal, Uid}; +use rustix::thread::{CapabilitiesSecureBits as Bits, CapabilitySet, CapabilitySets}; +use std::io; + +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} +const SECURE: Bits = Bits::NO_ROOT + .union(Bits::NO_ROOT_LOCKED) + .union(Bits::NO_SETUID_FIXUP_LOCKED) + .union(Bits::KEEP_CAPS_LOCKED) + .union(Bits::NO_CAP_AMBIENT_RAISE) + .union(Bits::NO_CAP_AMBIENT_RAISE_LOCKED); + +pub(crate) fn valid(uid: u32, gid: u32) -> bool { + uid >= 100_000 && gid >= 100_000 && uid != u32::MAX && gid != u32::MAX +} + +// Probe every supported bit rather than silently dropping only constants known +// to this library. The 64-bit capability ABI is a deliberate fail-closed bound. +fn last_capability() -> io::Result { + for bit in 0..64 { + match rustix::thread::capability_is_in_bounding_set(CapabilitySet::from_bits_retain( + 1 << bit, + )) { + Ok(_) => {} + Err(rustix::io::Errno::INVAL) if bit > 0 => return Ok(bit - 1), + Err(error) => return Err(error.into()), + } + } + Err(denied("unsupported capability ABI")) +} + +pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> { + crate::launcher::entry::ensure_single_threaded()?; + if !valid(uid, gid) || !rustix::process::geteuid().is_root() { + return Err(denied("task identity precondition")); + } + let last = last_capability()?; + rustix::thread::set_thread_groups(&[])?; + rustix::thread::set_keep_capabilities(false)?; + rustix::thread::clear_ambient_capability_set()?; + // Lock keepcaps and no-setuid-fixup OFF, not ON. Normal UID transition must + // still clear privilege; root and ambient acquisition are locked off too. + rustix::thread::set_capabilities_secure_bits(SECURE)?; + for bit in 0..=last { + rustix::thread::remove_capability_from_bounding_set(CapabilitySet::from_bits_retain( + 1 << bit, + ))?; + } + rustix::thread::set_no_new_privs(true)?; + let gid = Gid::from_raw(gid); + let uid = Uid::from_raw(uid); + rustix::thread::set_thread_res_gid(gid, gid, gid)?; + rustix::thread::set_thread_res_uid(uid, uid, uid)?; + let empty = CapabilitySets { + effective: CapabilitySet::empty(), + permitted: CapabilitySet::empty(), + inheritable: CapabilitySet::empty(), + }; + rustix::thread::set_capabilities(None, empty)?; + rustix::thread::clear_ambient_capability_set()?; + // Linux clears PDEATHSIG on credential changes. Restore it before ACK; + // the supervisor also checks the same held bootstrap/init parent relation. + rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?; + if rustix::thread::capabilities(None)? != empty + || !rustix::process::getgroups()?.is_empty() + || rustix::process::getuid() != uid + || rustix::process::geteuid() != uid + || rustix::process::getgid() != gid + || rustix::process::getegid() != gid + || rustix::thread::get_keep_capabilities()? + || rustix::thread::capabilities_secure_bits()? != SECURE + || !rustix::thread::no_new_privs()? + || rustix::process::parent_process_death_signal()? != Some(Signal::KILL) + { + return Err(denied("task credential readback")); + } + for bit in 0..=last { + let cap = CapabilitySet::from_bits_retain(1 << bit); + if rustix::thread::capability_is_in_bounding_set(cap)? + || rustix::thread::capability_is_in_ambient_set(cap)? + { + return Err(denied("task capability readback")); + } + } + verify_status( + &std::fs::read_to_string("/proc/self/status")?, + uid.as_raw(), + gid.as_raw(), + ) +} + +pub(crate) fn reduce_init(uid: u32, gid: u32) -> io::Result<()> { + if rustix::process::getpid().as_raw_pid() != 1 { + return Err(denied("credential stage requires namespace init")); + } + reduce_current(uid, gid) +} + +fn field<'a>(status: &'a str, name: &str) -> io::Result<&'a str> { + status + .lines() + .find_map(|line| line.strip_prefix(name)) + .ok_or_else(|| denied("missing credential field")) +} + +pub(crate) fn verify_status(status: &str, uid: u32, gid: u32) -> io::Result<()> { + for (name, expected) in [("Uid:", uid), ("Gid:", gid)] { + let ids: Vec<_> = field(status, name)?.split_whitespace().collect(); + if ids.len() != 4 || ids.iter().any(|s| s.parse::().ok() != Some(expected)) { + return Err(denied("real/effective/saved/filesystem identity")); + } + } + if !field(status, "Groups:")?.trim().is_empty() || field(status, "NoNewPrivs:")?.trim() != "1" { + return Err(denied("groups/no_new_privs")); + } + for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] { + if u64::from_str_radix(field(status, name)?.trim(), 16).ok() != Some(0) { + return Err(denied("nonempty capability set")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + const STATUS: &str = "Uid:\t100000 100000 100000 100000\nGid:\t100001 100001 100001 100001\nGroups:\t\nNoNewPrivs:\t1\nCapInh:\t0000\nCapPrm:\t0000\nCapEff:\t0000\nCapBnd:\t0000\nCapAmb:\t0000\n"; + #[test] + fn readback_rejects_saved_ids_groups_missing_fields_and_any_capability() { + verify_status(STATUS, 100000, 100001).unwrap(); + for invalid in [ + STATUS.replace("100000 100000 100000 100000", "100000 100000 0 100000"), + STATUS.replace("Groups:\t", "Groups:\t0"), + STATUS.replace("NoNewPrivs:\t1", "NoNewPrivs:\t0"), + STATUS.replace("CapBnd:\t0000", "CapBnd:\t0001"), + STATUS.replace("CapAmb:\t0000\n", ""), + ] { + assert!(verify_status(&invalid, 100000, 100001).is_err()); + } + } +} diff --git a/judge/crates/sandbox/src/isolation/mod.rs b/judge/crates/sandbox/src/isolation/mod.rs index 6886b87..db915c7 100644 --- a/judge/crates/sandbox/src/isolation/mod.rs +++ b/judge/crates/sandbox/src/isolation/mod.rs @@ -1,5 +1,6 @@ //! Fixed Linux isolation operations; launcher owns process/control orchestration. +pub(crate) mod identity; pub(crate) mod inputs; pub mod namespaces; pub(crate) mod proc_dev; diff --git a/judge/crates/sandbox/src/isolation/root.rs b/judge/crates/sandbox/src/isolation/root.rs index 6f6d4ad..a3b28d2 100644 --- a/judge/crates/sandbox/src/isolation/root.rs +++ b/judge/crates/sandbox/src/isolation/root.rs @@ -98,7 +98,7 @@ fn safe_directory(fd: &OwnedFd) -> io::Result<()> { } Ok(()) } -fn open_directory(path: &Path) -> io::Result { +pub(crate) fn open_directory(path: &Path) -> io::Result { if !path.is_absolute() { return Err(invalid("root capability must be absolute")); } diff --git a/judge/crates/sandbox/src/launcher/identity.rs b/judge/crates/sandbox/src/launcher/identity.rs new file mode 100644 index 0000000..c6d44eb --- /dev/null +++ b/judge/crates/sandbox/src/launcher/identity.rs @@ -0,0 +1,240 @@ +//! Identity ownership is retained through target teardown, including failed cleanup. +use super::blocked::{InitializationFailure, failure}; +use super::inputs::InputBoundTarget; +use crate::artifacts::ArtifactRoot; +use crate::isolation::root::RootError; +use crate::outcome::{ErrorCode, ExecutionStage}; +use crate::runtime::ValidatedRuntimeConfig; +use crate::spec::ValidatedSpec; +use rustix::fd::OwnedFd; +use rustix::fs::{AtFlags, Mode, OFlags}; +use std::{io, io::Write, path::Path}; + +const DIR: OFlags = OFlags::RDONLY + .union(OFlags::DIRECTORY) + .union(OFlags::NOFOLLOW) + .union(OFlags::CLOEXEC); +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} +fn private(fd: &OwnedFd) -> io::Result<()> { + let s = rustix::fs::fstat(fd)?; + if s.st_uid != 0 || s.st_gid != 0 || s.st_mode & 0o7777 != 0o700 { + return Err(denied("unsafe identity lease directory")); + } + Ok(()) +} + +struct IdentityLease { + parent: OwnedFd, + slot: OwnedFd, + name: String, + uid: u32, + gid: u32, + removed: bool, +} +impl IdentityLease { + fn acquire(config: &ValidatedRuntimeConfig, spec: &ValidatedSpec) -> Result { + if !rustix::process::geteuid().is_root() { + return Err(denied("identity allocator requires privileged supervisor").into()); + } + let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?; + match rustix::fs::mkdirat(&state, "identities", Mode::from_raw_mode(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(e) => return Err(e.into()), + } + let parent = rustix::fs::openat(&state, "identities", DIR, Mode::empty())?; + private(&parent)?; + for index in 0..config.config().max_concurrent_runs { + let (uid, gid) = config + .task_identity(index) + .ok_or_else(|| denied("identity slot mapping"))?; + let name = format!("uid-{uid}"); + match rustix::fs::mkdirat(&parent, name.as_str(), Mode::from_raw_mode(0o700)) { + Err(rustix::io::Errno::EXIST) => continue, // Never adopt a crash/foreign marker. + Err(e) => return Err(e.into()), + Ok(()) => {} + } + let slot = match rustix::fs::openat(&parent, name.as_str(), DIR, Mode::empty()) { + Ok(fd) => fd, + Err(e) => { + return Err(RootError { + source: e.into(), + cleanup_error: rustix::fs::unlinkat( + &parent, + name.as_str(), + AtFlags::REMOVEDIR, + ) + .err() + .map(Into::into), + }); + } + }; + let mut lease = Self { + parent, + slot, + name, + uid, + gid, + removed: false, + }; + let setup = (|| { + rustix::fs::fchmod(&lease.slot, Mode::from_raw_mode(0o700))?; + private(&lease.slot)?; + let marker = rustix::fs::openat( + &lease.slot, + "owner.json", + OFlags::WRONLY + | OFlags::CREATE + | OFlags::EXCL + | OFlags::CLOEXEC + | OFlags::NOFOLLOW, + Mode::from_raw_mode(0o600), + )?; + let mut marker = std::fs::File::from(marker); + serde_json::to_writer( + &mut marker, + &serde_json::json!({"version":1,"run_id":spec.spec().run_id,"uid":uid,"gid":gid}), + )?; + marker.write_all(b"\n")?; + marker.sync_all()?; + rustix::fs::fsync(&lease.slot)?; + rustix::fs::fsync(&lease.parent)?; + Ok::<_, io::Error>(()) + })(); + if let Err(source) = setup { + return Err(RootError { + source, + cleanup_error: lease.release().err(), + }); + } + return Ok(lease); + } + Err(rustix::io::Errno::AGAIN.into()) + } + // There is intentionally no automatic lease release in IdentityLease::Drop. + // Only a wrapper that has confirmed complete teardown may remove a marker. + fn release(&mut self) -> io::Result<()> { + if self.removed { + return Ok(()); + } + let named = rustix::fs::openat(&self.parent, self.name.as_str(), DIR, Mode::empty())?; + let a = rustix::fs::fstat(&named)?; + let b = rustix::fs::fstat(&self.slot)?; + if (a.st_dev, a.st_ino) != (b.st_dev, b.st_ino) { + return Err(denied("identity marker changed")); + } + match rustix::fs::unlinkat(&self.slot, "owner.json", AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => {} + Err(e) => return Err(e.into()), + } + rustix::fs::unlinkat(&self.parent, self.name.as_str(), AtFlags::REMOVEDIR)?; + self.removed = true; + rustix::fs::fsync(&self.parent)?; + Ok(()) + } +} +fn release_after_cleanup(lease: &mut IdentityLease, result: &mut InitializationFailure) { + if result.cleanup_failure.is_none() + && let Err(e) = lease.release() + { + result.cleanup_failure = Some(failure( + ExecutionStage::Cleanup, + ErrorCode::CleanupIncomplete, + Some(&e), + )); + } +} + +pub struct IdentityReducedTarget { + target: Option, + lease: IdentityLease, +} +impl IdentityReducedTarget { + pub fn prepare( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + executable: &Path, + artifacts: &ArtifactRoot, + ) -> Result { + let mut lease = + IdentityLease::acquire(config, spec).map_err(|e| InitializationFailure { + failure: failure( + ExecutionStage::Identity, + ErrorCode::OperationFailed, + Some(&e.source), + ), + cleanup_failure: e.cleanup_error.as_ref().map(|e| { + failure( + ExecutionStage::Cleanup, + ErrorCode::CleanupIncomplete, + Some(e), + ) + }), + })?; + let mut target = match InputBoundTarget::prepare(config, spec, executable, artifacts) { + Ok(target) => target, + Err(mut e) => { + release_after_cleanup(&mut lease, &mut e); + return Err(e); + } + }; + if let Err(e) = target.target.root.initialize_identity(lease.uid, lease.gid) { + let mut e = target + .target + .root + .refuse_stage(ExecutionStage::Identity, Some(&e)); + release_after_cleanup(&mut lease, &mut e); + return Err(e); + } + Ok(Self { + target: Some(target), + lease, + }) + } + pub fn root(&self) -> &super::root::RootedTarget { + self.target.as_ref().expect("owned target").root() + } + pub fn identity(&self) -> (u32, u32) { + (self.lease.uid, self.lease.gid) + } + 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 + .target + .root + .refuse_stage(ExecutionStage::Seccomp, None); + release_after_cleanup(&mut self.lease, &mut result); + result + } +} +impl Drop for IdentityReducedTarget { + fn drop(&mut self) { + if let Some(target) = self.target.take() { + let mut result = target + .target + .root + .refuse_stage(ExecutionStage::Seccomp, None); + release_after_cleanup(&mut self.lease, &mut result); + // An error leaves the durable marker reserved for restart recovery. + } + } +} + +pub(crate) fn initialize( + channel: &mut super::protocol::ControlChannel, + uid: u32, + gid: u32, +) -> io::Result<()> { + let result = crate::isolation::identity::reduce_init(uid, gid); + 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 result; + } + channel.send(&super::protocol::NamespaceEvent::IdentityInitialized {}) +} diff --git a/judge/crates/sandbox/src/launcher/mod.rs b/judge/crates/sandbox/src/launcher/mod.rs index 84e4a6d..dcf1bee 100644 --- a/judge/crates/sandbox/src/launcher/mod.rs +++ b/judge/crates/sandbox/src/launcher/mod.rs @@ -12,6 +12,7 @@ pub mod blocked; #[cfg(target_os = "linux")] pub mod entry; #[cfg(target_os = "linux")] +pub mod identity; pub mod inputs; #[cfg(target_os = "linux")] pub mod namespaces; diff --git a/judge/crates/sandbox/src/launcher/namespaces.rs b/judge/crates/sandbox/src/launcher/namespaces.rs index 26448f3..1033110 100644 --- a/judge/crates/sandbox/src/launcher/namespaces.rs +++ b/judge/crates/sandbox/src/launcher/namespaces.rs @@ -189,7 +189,14 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io:: NamespaceCommand::Cancel {} => return Ok(()), _ => return Err(denied("input command before preparation")), } - let _ = channel.receive::()?; + match channel.receive::()? { + NamespaceCommand::PrepareIdentity { uid, gid } => { + super::identity::initialize(&mut channel, uid, gid)?; + let _ = channel.receive::()?; + } + NamespaceCommand::Cancel {} => return Ok(()), + _ => return Err(denied("identity command before inputs")), + } } NamespaceCommand::Cancel {} => return Ok(()), _ => {} @@ -211,6 +218,7 @@ pub struct NamespacedTarget { root_ready: bool, proc_dev_ready: bool, inputs_ready: bool, + identity_ready: bool, init_pid: u32, ids: NamespaceIds, } @@ -304,6 +312,7 @@ impl NamespacedTarget { root_ready: false, proc_dev_ready: false, inputs_ready: false, + identity_ready: false, }), Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))), } @@ -378,6 +387,24 @@ impl NamespacedTarget { Ok(()) } + pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> { + if !self.inputs_ready || self.identity_ready || !crate::isolation::identity::valid(uid, gid) + { + return Err(denied("credential initialization order")); + } + self.initialize_filesystem( + NamespaceCommand::PrepareIdentity { uid, gid }, + NamespaceEvent::IdentityInitialized {}, + )?; + crate::isolation::identity::verify_status( + &std::fs::read_to_string(format!("/proc/{}/status", self.init_pid))?, + uid, + gid, + )?; + self.identity_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 c49375e..bc0d55e 100644 --- a/judge/crates/sandbox/src/launcher/protocol.rs +++ b/judge/crates/sandbox/src/launcher/protocol.rs @@ -63,6 +63,10 @@ pub enum NamespaceCommand { executable: bool, }, FinishInputs {}, + PrepareIdentity { + uid: u32, + gid: u32, + }, Cancel {}, } @@ -75,6 +79,7 @@ pub enum NamespaceEvent { InputsStarted {}, InputAttached {}, InputsInitialized {}, + IdentityInitialized {}, Failed { errno: Option }, Cancelled {}, } @@ -110,6 +115,7 @@ impl WireMessage for NamespaceCommand { crate::spec::path(path, "input").is_ok() && *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES } + Self::PrepareIdentity { uid, gid } => crate::isolation::identity::valid(*uid, *gid), _ => true, } } diff --git a/judge/crates/sandbox/src/launcher/root.rs b/judge/crates/sandbox/src/launcher/root.rs index 0f3c4dd..83165e8 100644 --- a/judge/crates/sandbox/src/launcher/root.rs +++ b/judge/crates/sandbox/src/launcher/root.rs @@ -148,6 +148,22 @@ impl RootedTarget { crate::isolation::inputs::verify(self.init_pid(), &inputs.files) } + pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> { + self.target.initialize_identity(uid, gid)?; + self.lease.verify_root(self.init_pid())?; + self.lease.verify_root(self.bootstrap_pid()) + } + + pub(crate) fn refuse_stage( + mut self, + stage: ExecutionStage, + error: Option<&io::Error>, + ) -> InitializationFailure { + let mut result = self.target.prepared.refuse_at(stage, error); + attach_cleanup(&mut result, self.lease.cleanup().err()); + result + } + pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure { let mut failure = self .target @@ -157,7 +173,7 @@ impl RootedTarget { failure } - /// Restricted proc/dev, identity, capabilities and seccomp are still absent. + /// This base type has no proof of later input/identity/seccomp stages. pub fn refuse_remaining_isolation(mut self) -> InitializationFailure { let mut failure = self .target diff --git a/judge/crates/sandbox/src/runtime.rs b/judge/crates/sandbox/src/runtime.rs index 9d69070..ac1fd76 100644 --- a/judge/crates/sandbox/src/runtime.rs +++ b/judge/crates/sandbox/src/runtime.rs @@ -148,6 +148,12 @@ impl RuntimeConfig { { return Err(invalid("roots", "invalid_layout")); } + let identity_root = format!("{}/identities", self.state_root); + if [&self.tasks_root, &self.image_root].iter().any(|root| { + *root == &identity_root || inside(root, &identity_root) || inside(&identity_root, root) + }) { + return Err(invalid("roots", "identity_lease_overlap")); + } if !inside(&self.delegated_cgroup_root, "/sys/fs/cgroup") { return Err(invalid("delegated_cgroup_root", "not_delegated_subtree")); } diff --git a/judge/crates/sandbox/tests/fixtures/identity_deny.c b/judge/crates/sandbox/tests/fixtures/identity_deny.c new file mode 100644 index 0000000..bc5a456 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/identity_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_setgroups,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_SETGROUPS","1",1); + execl("/kf/bin/identity-vm-tests","identity-vm-tests","--ignored","--test-threads=1","--exact","kernel_credential_failure_refuses_and_releases_own_lease","--nocapture",(char*)0); + return 4; +} diff --git a/judge/crates/sandbox/tests/fixtures/identity_inherit_probe.c b/judge/crates/sandbox/tests/fixtures/identity_inherit_probe.c new file mode 100644 index 0000000..2e20747 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/identity_inherit_probe.c @@ -0,0 +1,67 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int check(void) { + uid_t r,e,s; gid_t gr,ge,gs; + if(getresuid(&r,&e,&s)||getresgid(&gr,&ge,&gs)) return 1; + if(r!=100000||e!=r||s!=r||gr!=r||ge!=r||gs!=r||getgroups(0,NULL)!=0) return 2; + struct __user_cap_header_struct h={_LINUX_CAPABILITY_VERSION_3,0}; + struct __user_cap_data_struct d[2]={{0}}; + if(syscall(SYS_capget,&h,d)) return 3; + for(int i=0;i<2;i++) if(d[i].effective||d[i].permitted||d[i].inheritable) return 4; + if(prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)!=1||prctl(PR_GET_SECUREBITS,0,0,0,0)!=0xeb) return 5; + for(int cap=0;cap<64;cap++) { + int b=prctl(PR_CAPBSET_READ,cap,0,0,0); + if(b==-1 && errno==EINVAL) break; + if(b!=0||prctl(PR_CAP_AMBIENT,PR_CAP_AMBIENT_IS_SET,cap,0,0)!=0) return 6; + } + if(setuid(0)!=-1||errno!=EPERM||setgid(0)!=-1||errno!=EPERM) return 7; + gid_t group=0; + if(setgroups(1,&group)!=-1||errno!=EPERM) return 8; + if(mount("tmpfs","/tmp", "tmpfs",0,NULL)!=-1||errno!=EPERM) return 9; + d[0].permitted=d[0].effective=1U<=0){close(fd);return 26;} + if(errno!=EACCES&&errno!=EPERM)return 27; + } else if(argc!=1) return 28; + puts("KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1"); + return 0; +} diff --git a/judge/crates/sandbox/tests/identity_vm.rs b/judge/crates/sandbox/tests/identity_vm.rs new file mode 100644 index 0000000..67f4dd9 --- /dev/null +++ b/judge/crates/sandbox/tests/identity_vm.rs @@ -0,0 +1,253 @@ +#![cfg(target_os = "linux")] +mod support; +use kf_sandbox::artifacts::ArtifactRoot; +use kf_sandbox::launcher::identity::IdentityReducedTarget; +use kf_sandbox::outcome::ExecutionStage; +use std::os::unix::fs::PermissionsExt; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; +use support::{Root, binary, children}; +const LEASES: &str = "/var/lib/kf-judge/identities"; +struct Publications(PathBuf); +impl Publications { + fn new() -> Self { + support::require_vm(); + let path = PathBuf::from(format!("/kf/identity-publications-{}", std::process::id())); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(path.join("program-001"), b"fixed-non-task-payload").unwrap(); + fs::write(path.join("input-001"), b"private-stdin").unwrap(); + Self(path) + } + fn root(&self) -> ArtifactRoot { + ArtifactRoot::from_directory( + rustix::fs::open( + &self.0, + rustix::fs::OFlags::PATH + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(), + ) + .unwrap() + } +} +impl Drop for Publications { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn prepare( + root: &Root, + publications: &Publications, + id: &str, +) -> Result { + IdentityReducedTarget::prepare( + &root.validated(), + &support::spec(id, 64 << 20, 8), + &binary(), + &publications.root(), + ) +} +fn leases() -> Vec { + let mut result: Vec<_> = fs::read_dir(LEASES) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + result.sort(); + result +} +fn verify_ids(target: &IdentityReducedTarget) { + let (uid, gid) = target.identity(); + let status = fs::read_to_string(format!("/proc/{}/status", target.root().init_pid())).unwrap(); + for (name, id) in [("Uid:", uid), ("Gid:", gid)] { + let line = status.lines().find(|l| l.starts_with(name)).unwrap(); + assert_eq!( + line.split_whitespace() + .skip(1) + .map(|s| s.parse::().unwrap()) + .collect::>(), + vec![id; 4] + ); + } + for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] { + assert_eq!( + u64::from_str_radix( + status + .lines() + .find(|l| l.starts_with(name)) + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(), + 16 + ) + .unwrap(), + 0 + ); + } + assert_eq!( + status + .lines() + .find(|l| l.starts_with("Groups:")) + .unwrap() + .split_whitespace() + .count(), + 1 + ); + assert!(status.contains("NoNewPrivs:\t1\n")); + assert!(target.root().snapshot_outputs().unwrap().is_empty()); +} +#[test] +#[ignore = "requires independent capped VM and fixed credential inheritance fixture"] +fn production_init_reduces_credentials_and_leases_distinct_ids_until_cleanup() { + let root = Root::new(); + let publications = Publications::new(); + let before = children(); + let one = prepare(&root, &publications, "identity-one").unwrap(); + let two = prepare(&root, &publications, "identity-two").unwrap(); + assert_eq!(one.identity(), (100000, 100000)); + assert_eq!(two.identity(), (100001, 100001)); + verify_ids(&one); + verify_ids(&two); + assert_eq!(leases(), ["uid-100000", "uid-100001"]); + let Err(busy) = prepare(&root, &publications, "identity-busy") else { + panic!("live identity reused"); + }; + assert_eq!(busy.failure.stage, ExecutionStage::Identity); + assert_eq!(busy.failure.errno, Some(11)); + assert!(busy.cleanup_failure.is_none()); + let output = Command::new("/kf/bin/identity-probe") + .args(["fixed-probe", &two.root().init_pid().to_string()]) + .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) + ); + let proof = String::from_utf8(output.stdout).unwrap(); + assert!(proof.contains("KF_IDENTITY_REDUCER_PROOF")); + print!("{proof}"); + let result = one.refuse_remaining_isolation(); + assert_eq!(result.failure.stage, ExecutionStage::Seccomp); + assert!(result.cleanup_failure.is_none()); + let reuse = prepare(&root, &publications, "identity-reuse").unwrap(); + assert_eq!(reuse.identity(), (100000, 100000)); + verify_ids(&reuse); + drop(reuse); + drop(two); + assert!(leases().is_empty()); + assert_eq!(children(), before); + for id in [ + "identity-one", + "identity-two", + "identity-busy", + "identity-reuse", + ] { + assert!(!root.path.join(format!("kf-{id}")).exists()); + assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists()); + } + println!( + "KF_IDENTITY_PROOF all_four_ids=1 groups=0 five_cap_sets=0 nnp=1 distinct_leases=1 exhaustion_refused=1 release_after_cleanup=1 safe_reuse=1 drop_cleanup=1 fixed_fixture_not_user_exec=1" + ); +} +#[test] +#[ignore = "requires independent capped VM"] +fn foreign_markers_and_unsafe_lease_roots_are_not_adopted() { + let root = Root::new(); + let publications = Publications::new(); + let before = children(); + fs::create_dir_all(LEASES).unwrap(); + fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap(); + let foreign = Path::new(LEASES).join("uid-100000"); + fs::create_dir(&foreign).unwrap(); + fs::set_permissions(&foreign, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(foreign.join("foreign"), b"must retain").unwrap(); + let target = prepare(&root, &publications, "identity-foreign").unwrap(); + assert_eq!(target.identity(), (100001, 100001)); + drop(target); + assert_eq!(fs::read(foreign.join("foreign")).unwrap(), b"must retain"); + fs::remove_file(foreign.join("foreign")).unwrap(); + fs::remove_dir(&foreign).unwrap(); + fs::set_permissions(LEASES, fs::Permissions::from_mode(0o777)).unwrap(); + let Err(error) = prepare(&root, &publications, "identity-unsafe") else { + panic!("writable lease root accepted"); + }; + assert_eq!(error.failure.stage, ExecutionStage::Identity); + assert!(error.cleanup_failure.is_none()); + assert!(leases().is_empty()); + fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap(); + fs::remove_file(publications.0.join("program-001")).unwrap(); + let Err(error) = prepare(&root, &publications, "identity-bad-input") else { + panic!("missing input accepted"); + }; + assert_eq!(error.failure.stage, ExecutionStage::Preparation); + assert!(error.cleanup_failure.is_none()); + assert!(leases().is_empty()); + assert_eq!(children(), before); + println!( + "KF_IDENTITY_LEASE_POLICY_PROOF foreign_retained=1 writable_root_refused=1 failed_preparation_slot_released=1 no_target_leftover=1" + ); +} +#[test] +#[ignore = "requires independent capped VM; cleanup failure injection"] +fn incomplete_target_cleanup_keeps_identity_reserved() { + let root = Root::new(); + let publications = Publications::new(); + let before = children(); + let target = prepare(&root, &publications, "identity-cleanup-failure").unwrap(); + let task = Path::new("/var/lib/kf-judge/tasks/kf-identity-cleanup-failure"); + fs::write(task.join("foreign"), b"keep").unwrap(); + let error = target.refuse_remaining_isolation(); + assert_eq!(error.failure.stage, ExecutionStage::Seccomp); + assert!(error.cleanup_failure.is_some()); + assert_eq!(leases(), ["uid-100000"]); + assert!(!root.path.join("kf-identity-cleanup-failure").exists()); + assert_eq!(children(), before); + let next = prepare(&root, &publications, "identity-after-failed-cleanup").unwrap(); + assert_eq!(next.identity(), (100001, 100001)); + drop(next); + assert_eq!(fs::read(task.join("foreign")).unwrap(), b"keep"); + // VM operator restores the test baseline only after proving both task + // cgroups/children gone. Production restart recovery is still unimplemented. + fs::remove_file(task.join("foreign")).unwrap(); + fs::remove_dir(task).unwrap(); + fs::remove_file(Path::new(LEASES).join("uid-100000/owner.json")).unwrap(); + fs::remove_dir(Path::new(LEASES).join("uid-100000")).unwrap(); + assert!(leases().is_empty()); + println!( + "KF_IDENTITY_CLEANUP_PROOF cleanup_error_separate=1 failed_slot_reserved=1 alternate_slot_only=1 foreign_not_deleted=1 test_baseline_operator_restore=1" + ); +} +#[test] +#[ignore = "requires independent capped VM with kernel setgroups failure injector"] +fn kernel_credential_failure_refuses_and_releases_own_lease() { + let root = Root::new(); + assert_eq!( + std::env::var("KF_SANDBOX_DENY_SETGROUPS").as_deref(), + Ok("1") + ); + let publications = Publications::new(); + let before = children(); + let Err(error) = prepare(&root, &publications, "identity-kernel-denied") else { + panic!("credential error ignored"); + }; + assert_eq!(error.failure.stage, ExecutionStage::Identity); + assert_eq!(error.failure.errno, Some(1)); + assert!(error.cleanup_failure.is_none()); + assert!(leases().is_empty()); + assert_eq!(children(), before); + assert!(!root.path.join("kf-identity-kernel-denied").exists()); + assert!(!Path::new("/var/lib/kf-judge/tasks/kf-identity-kernel-denied").exists()); + println!( + "KF_IDENTITY_FAILURE_PROOF real_kernel_eperm=1 identity_ack_refused=1 slot_released_after_teardown=1 child_reaped=1 task_cleanup=1" + ); +} diff --git a/judge/crates/sandbox/tests/runtime_policy.rs b/judge/crates/sandbox/tests/runtime_policy.rs index d090a5b..021e3c7 100644 --- a/judge/crates/sandbox/tests/runtime_policy.rs +++ b/judge/crates/sandbox/tests/runtime_policy.rs @@ -46,6 +46,8 @@ fn roots_reject_traversal_aliases_system_locations_and_overlapping_storage() { for path in [ "/var/lib/kf-judge/tasks", "/var/lib/kf-judge/tasks/image", + "/var/lib/kf-judge/identities", + "/var/lib/kf-judge/identities/image", "/var/lib/kf-judge", "/srv/unrelated", ] {