diff --git a/judge/README.md b/judge/README.md index 7cec149..1967fbf 100644 --- a/judge/README.md +++ b/judge/README.md @@ -416,3 +416,15 @@ pidfd_getfd 仍拒绝,仅增加可见进程的只读 pidfd_open 以支持等 主进程退出仍有后代时取消、控制端断开及真实 exec ENOENT。探针使用与生产相同的 私有回收/凭据/seccomp 原语,在新 PID namespace 的 PID 1 运行;它不是完整任务 执行 API。CLI run 继续拒绝,stdio/限额/计量与完整监督全部验收前不暴露执行放行。 + +`io::PipePump` now feeds immutable snapshot stdin and drains independent stdout +and stderr pipes in bounded nonblocking ticks. Only blocking child ends reach +FDs 0/1/2; anonymous captures stay in a trusted 0700 directory capability, have +per-stream/file/aggregate byte ceilings and no published writable descriptor. +An over-limit observation retains at most the allowed prefix and requires +whole-tree cancellation; its byte count is a lower bound. Early stdin closure +uses thread-local SIGPIPE masking, preserving global disposition and prior +pending signals. Init is explicitly non-dumpable before filtering so same-UID +users cannot reopen its control/capture handles through proc. Fixed guarded VM +probes exercise the exact transport/reaper; full CLI exec, accounting and +post-run artifact publication still await their remaining gates. diff --git a/judge/crates/sandbox/examples/io_probe.rs b/judge/crates/sandbox/examples/io_probe.rs new file mode 100644 index 0000000..cc900b1 --- /dev/null +++ b/judge/crates/sandbox/examples/io_probe.rs @@ -0,0 +1,424 @@ +//! Fixed guarded VM role using the exact private production reaper/reducer. +//! Exact production pipe pump and private reaper, guarded fixed VM-only fixture. +mod launcher { + pub mod entry { + pub use kf_sandbox::launcher::entry::ensure_single_threaded; + } +} +mod io { + pub use kf_sandbox::io::ChildPipes; +} +mod outcome { + pub use kf_sandbox::outcome::Termination; +} +mod spec { + pub use kf_sandbox::spec::{ExecutionProfile, ValidatedSpec}; +} +#[allow(dead_code)] +#[path = "../src/isolation/identity.rs"] +pub(crate) mod identity; +#[allow(dead_code)] +#[path = "../src/isolation/namespaces.rs"] +mod namespaces; +#[allow(dead_code)] +#[path = "../src/isolation/process_tree.rs"] +mod process_tree; +#[allow(dead_code)] +#[path = "../src/isolation/seccomp.rs"] +pub(crate) mod seccomp; +mod isolation { + pub(crate) use crate::{identity, seccomp}; +} +#[allow(dead_code)] +#[path = "../tests/support/mod.rs"] +mod support; +use rustix::fd::{AsRawFd, OwnedFd}; +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +fn init(mode: &str) { + assert_eq!(rustix::process::getpid(), rustix::process::Pid::INIT); + rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::KILL)).unwrap(); + let mut control = + UnixStream::from(rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3).unwrap()); + assert_eq!(control.as_raw_fd(), 3); + control + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + rustix::mount::mount( + "proc", + "/proc", + "proc", + rustix::mount::MountFlags::NOSUID + | rustix::mount::MountFlags::NODEV + | rustix::mount::MountFlags::NOEXEC, + Some(c"hidepid=2,subset=pid"), + ) + .unwrap(); + let path = format!("/kf/io-capture-{}", mode); + std::fs::create_dir(&path).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + if mode == "storage-fail" { + rustix::mount::mount( + "tmpfs", + &path, + "tmpfs", + rustix::mount::MountFlags::NOSUID + | rustix::mount::MountFlags::NODEV + | rustix::mount::MountFlags::NOEXEC, + Some(c"size=8192,mode=0700"), + ) + .unwrap(); + } + let directory = rustix::fs::open( + &path, + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let artifacts = kf_sandbox::artifacts::ArtifactRoot::from_directory( + rustix::fs::open( + "/kf/io-publications", + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(), + ) + .unwrap(); + let mut request = support::spec("io-fixture", 64 << 20, 8).spec().clone(); + request.argv = vec!["/work/main".into(), mode.into()]; + if mode == "empty" { + request.stdin_artifact = None; + } + if mode == "stdout-limit" { + request.limits.stdout_bytes = 4096; + } + if mode == "stderr-limit" { + request.limits.stderr_bytes = 4096; + } + let spec = request + .validate(&kf_sandbox::spec::SpecPolicy::default()) + .unwrap(); + let data: Vec<_> = (0..262144).map(|i| (i % 251) as u8).collect(); + std::fs::write("/kf/io-publications/input-001", &data).unwrap(); + let input = artifacts.snapshot_inputs(&spec).unwrap().stdin; + // Snapshot cursor/content do not follow publication changes. + std::fs::write("/kf/io-publications/input-001", b"changed-after-sealing").unwrap(); + let (mut pump, child) = kf_sandbox::io::PipePump::prepare(input, &spec, directory).unwrap(); + child.validate_for_exec().unwrap(); + for fd in [&child.input, &child.output, &child.error] { + assert!( + !rustix::fs::fcntl_getfl(fd) + .unwrap() + .contains(rustix::fs::OFlags::NONBLOCK) + ); + } + assert_eq!( + std::fs::read_dir(&path).unwrap().count(), + 0, + "anonymous capture names leaked" + ); + // The captures hold no directory inode; the private empty directory can be + // removed before dropping identity and every anonymous inode dies on exit. + if mode != "storage-fail" { + std::fs::remove_dir(&path).unwrap(); + } + drop(artifacts); + let plan = process_tree::ExecPlan::new(&spec).unwrap(); + default_sigpipe(); + identity::reduce_current(100000, 100000).unwrap(); + seccomp::install(spec::ExecutionProfile::Native).unwrap(); + let mut reaper = process_tree::Reaper::spawn(plan, child).unwrap(); + control.write_all(b"READY\n").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let output = match pump.poll() { + Ok(s) => s, + Err(e) => { + assert_eq!(mode, "storage-fail", "unexpected pump error: {e}"); + assert_eq!(e.raw_os_error(), Some(libc::ENOSPC)); + let status = pump.status(); + assert!(!status.output_limit_exceeded()); + assert!(status.stdout.retained + status.stderr.retained <= 8192); + assert!( + status.stdout.observed + status.stderr.observed + > status.stdout.retained + status.stderr.retained + ); + pump.close_input(); + control.write_all(b"PROVED\n").unwrap(); + reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed); + } + }; + let observed = reaper.poll().unwrap(); + if output.output_limit_exceeded() { + let captured = if mode == "stdout-limit" { + pump.stdout() + } else { + assert_eq!(mode, "stderr-limit"); + pump.stderr() + }; + let status = captured.status(); + assert!(status.exceeded && !status.eof); + assert_eq!(status.retained, 4096); + assert_eq!(status.observed, 4097); + let mut prefix = [0; 4096]; + assert_eq!(captured.read_at(&mut prefix, 0).unwrap(), 4096); + assert!(prefix.iter().all(|b| *b == 0x5a)); + // At least one descendant remains; exiting PID1 must kill it even + // when main already died of SIGPIPE after its output end closed. + assert!(!observed.empty); + pump.close_input(); + control.write_all(b"PROVED\n").unwrap(); + reaper.shutdown(process_tree::NamespaceEnd::Cancelled); + } + if observed.empty && output.outputs_eof() { + assert_eq!( + observed.main, + Some(outcome::Termination::Exited { code: 7 }) + ); + assert_eq!( + observed.exec, + process_tree::ExecObservation::NoErrorObserved + ); + assert!(!output.output_limit_exceeded() && output.stdin_closed); + if mode == "duplex" { + assert_eq!(output.stdin_sent, 262144); + assert!(!output.stdin_closed_early); + assert_capture(pump.stdout(), 262144, 0); + assert_capture(pump.stderr(), 262144, 0xa5); + } else if mode == "early-close" { + assert!(output.stdin_closed_early && output.stdin_sent < 262144); + let mut bytes = [0; 8]; + assert_eq!(pump.stdout().read_at(&mut bytes, 0).unwrap(), 8); + assert_eq!(&bytes, b"CLOSED\0\xff"); + assert_eq!(output.stderr.observed, 0); + } else { + assert_eq!(mode, "empty"); + assert_eq!(output.stdin_sent, 0); + assert_eq!(output.stdout.observed, 0); + assert_eq!(output.stderr.observed, 0); + } + control.write_all(b"PROVED\n").unwrap(); + reaper.shutdown(process_tree::NamespaceEnd::Drained); + } + assert!(Instant::now() < deadline, "bounded I/O proof stalled"); + let mut poll = [rustix::event::PollFd::new( + &control, + rustix::event::PollFlags::IN, + )]; + rustix::event::poll( + &mut poll, + Some(&rustix::event::Timespec { + tv_sec: 0, + tv_nsec: 100_000, + }), + ) + .unwrap(); + if !poll[0].revents().is_empty() { + reaper.shutdown(process_tree::NamespaceEnd::ControlLost); + } + } +} +fn assert_capture(capture: &kf_sandbox::io::Capture, length: u64, xor: u8) { + assert_eq!(capture.status().retained, length); + assert_eq!(capture.status().observed, length); + assert!(capture.status().eof); + let mut chunk = [0; 4096]; + let mut offset = 0; + while offset < length { + let n = capture.read_at(&mut chunk, offset).unwrap(); + assert!(n > 0); + for (i, b) in chunk[..n].iter().enumerate() { + assert_eq!(*b, (((offset + i as u64) % 251) as u8) ^ xor); + } + offset += n as u64; + } + assert_eq!(capture.read_at(&mut chunk, length).unwrap(), 0); +} +#[allow(unsafe_code)] +fn default_sigpipe() { + // SAFETY: fixed, single-threaded, guarded VM init before user fork. This + // makes early-close exercise actual SIGPIPE containment, not Rust's IGN. + unsafe { + assert_ne!(libc::signal(libc::SIGPIPE, libc::SIG_DFL), libc::SIG_ERR); + } +} +fn signal_mask_proof() { + use std::os::unix::fs::PermissionsExt; + launcher::entry::ensure_single_threaded().unwrap(); + assert_ne!(rustix::process::getpid(), rustix::process::Pid::INIT); + let path = "/kf/io-signal-capture"; + std::fs::create_dir(path).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + let directory = rustix::fs::open( + path, + rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let artifacts = kf_sandbox::artifacts::ArtifactRoot::from_directory( + rustix::fs::open( + "/kf/io-publications", + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(), + ) + .unwrap(); + let spec = support::spec("io-signal", 64 << 20, 8); + let input = artifacts.snapshot_inputs(&spec).unwrap().stdin; + let bad_directory = rustix::io::fcntl_dupfd_cloexec(&directory, 4).unwrap(); + let mut bad_spec = spec.spec().clone(); + bad_spec.limits.file_bytes = 4096; + assert!( + kf_sandbox::io::PipePump::prepare( + None, + &bad_spec + .validate(&kf_sandbox::spec::SpecPolicy::default()) + .unwrap(), + bad_directory + ) + .is_err() + ); + let (mut pump, mut child) = kf_sandbox::io::PipePump::prepare(input, &spec, directory).unwrap(); + child.validate_for_exec().unwrap(); + let alias = rustix::io::fcntl_dupfd_cloexec(&child.output, 4).unwrap(); + let original = std::mem::replace(&mut child.error, alias); + assert!(child.validate_for_exec().is_err()); + child.error = original; + rustix::fs::fcntl_setfl(&child.output, rustix::fs::OFlags::NONBLOCK).unwrap(); + assert!(child.validate_for_exec().is_err()); + rustix::fs::fcntl_setfl(&child.output, rustix::fs::OFlags::empty()).unwrap(); + std::mem::swap(&mut child.input, &mut child.output); + assert!(child.validate_for_exec().is_err()); + std::mem::swap(&mut child.input, &mut child.output); + child.validate_for_exec().unwrap(); + drop(child); + default_sigpipe(); + let before = std::fs::read_to_string("/proc/self/status").unwrap(); + let observed = pump.poll().unwrap(); + assert!(observed.stdin_closed_early && observed.outputs_eof()); + let after = std::fs::read_to_string("/proc/self/status").unwrap(); + for field in ["SigBlk:", "SigPnd:", "ShdPnd:"] { + assert_eq!( + before.lines().find(|s| s.starts_with(field)), + after.lines().find(|s| s.starts_with(field)) + ); + } + drop(pump); + assert_eq!(std::fs::read_dir(path).unwrap().count(), 0); + std::fs::remove_dir(path).unwrap(); + println!( + "KF_IO_SIGPIPE_PROOF default_disposition=1 ordinary_pid=1 early_close_survived=1 mask_restored=1 no_pending_leak=1 anonymous_cleanup=1 invalid_pipe_alias_direction_flags_and_budget_denied=1" + ); +} +fn line(control: &mut UnixStream) -> String { + let mut bytes = Vec::new(); + loop { + let mut b = [0]; + control.read_exact(&mut b).unwrap(); + if b[0] == b'\n' { + break; + } + bytes.push(b[0]); + assert!(bytes.len() < 32); + } + String::from_utf8(bytes).unwrap() +} +fn main() { + support::require_vm(); + let args: Vec<_> = std::env::args().collect(); + assert_eq!(args.len(), 3); + assert!( + [ + "duplex", + "empty", + "early-close", + "stdout-limit", + "stderr-limit", + "storage-fail" + ] + .contains(&args[2].as_str()) + ); + if args[1] == "signal-mask" { + signal_mask_proof(); + return; + } + if args[1] == "init" { + init(&args[2]); + return; + } + assert_eq!(args[1], "fixed"); + launcher::entry::ensure_single_threaded().unwrap(); + let (mut parent, child) = UnixStream::pair().unwrap(); + parent + .set_read_timeout(Some(Duration::from_secs(12))) + .unwrap(); + parent + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let own = std::env::current_exe().unwrap(); + namespaces::create().unwrap(); + let mut process = Command::new(own) + .args(["init", &args[2]]) + .env_clear() + .env("KF_SANDBOX_INDEPENDENT_VM", "1") + .stdin(Stdio::from(OwnedFd::from(child))) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let pidfd = rustix::process::pidfd_open( + rustix::process::Pid::from_raw(process.id() as i32).unwrap(), + rustix::process::PidfdFlags::empty(), + ) + .unwrap(); + assert_eq!(line(&mut parent), "READY"); + assert_eq!(line(&mut parent), "PROVED"); + drop(parent); + let deadline = Instant::now() + Duration::from_secs(12); + let status = loop { + if let Some(s) = process.try_wait().unwrap() { + break s; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(2)); + }; + if args[2] == "storage-fail" { + assert_eq!(status.code(), Some(125)); + // The fixed outer wrapper shares this private mount namespace with + // init; its own lifetime still pins the temporary capture mount. + rustix::mount::unmount( + "/kf/io-capture-storage-fail", + rustix::mount::UnmountFlags::empty(), + ) + .unwrap(); + std::fs::remove_dir("/kf/io-capture-storage-fail").unwrap(); + } else { + assert!(status.success(), "{status}"); + } + let mut poll = [rustix::event::PollFd::new( + &pidfd, + rustix::event::PollFlags::IN, + )]; + assert_eq!( + rustix::event::poll( + &mut poll, + Some(&rustix::event::Timespec { + tv_sec: 0, + tv_nsec: 0 + }) + ) + .unwrap(), + 1 + ); + println!( + "KF_IO_PROOF mode={} child_fd012=1 snapshot_input=1 bounded_binary_capture=1 init_pidfd_ready=1 namespace_cleanup=1", + args[2] + ); +} diff --git a/judge/crates/sandbox/examples/tree_probe.rs b/judge/crates/sandbox/examples/tree_probe.rs index 5be562c..144bcdc 100644 --- a/judge/crates/sandbox/examples/tree_probe.rs +++ b/judge/crates/sandbox/examples/tree_probe.rs @@ -5,6 +5,9 @@ mod launcher { pub use kf_sandbox::launcher::entry::ensure_single_threaded; } } +mod io { + pub use kf_sandbox::io::ChildPipes; +} mod outcome { pub use kf_sandbox::outcome::Termination; } diff --git a/judge/crates/sandbox/src/io.rs b/judge/crates/sandbox/src/io.rs new file mode 100644 index 0000000..0dfbaa3 --- /dev/null +++ b/judge/crates/sandbox/src/io.rs @@ -0,0 +1,363 @@ +//! Bounded, nonblocking parent transport. Only pipe ends reach user FDs012. +//! Input is an immutable authorized snapshot; capture storage is anonymous in +//! an operator-owned private directory. No request pathname or unbounded Vec. +//! The supervisor must cancel the whole tree on limit/read/storage failure. +use crate::{artifacts::SnapshotFile, spec::ValidatedSpec}; +use rustix::fd::{AsRawFd, OwnedFd}; +use rustix::fs::{FileType, Mode, OFlags}; +use std::io; + +const CHUNK: usize = 4096; +fn denied(s: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, s) +} + +/// Blocking, owned CLOEXEC ends; the exec primitive duplicates these to 012 +/// and closes every other descriptor, including control and capture handles. +pub struct ChildPipes { + pub input: OwnedFd, + pub output: OwnedFd, + pub error: OwnedFd, +} +impl ChildPipes { + pub fn validate_for_exec(&self) -> io::Result<()> { + let mut identities = [(0, 0); 3]; + for (i, (fd, access)) in [ + (&self.input, OFlags::RDONLY), + (&self.output, OFlags::WRONLY), + (&self.error, OFlags::WRONLY), + ] + .into_iter() + .enumerate() + { + let stat = rustix::fs::fstat(fd)?; + let flags = rustix::fs::fcntl_getfl(fd)?; + if fd.as_raw_fd() < 4 + || FileType::from_raw_mode(stat.st_mode) != FileType::Fifo + || flags.intersection(OFlags::ACCMODE) != access + || flags.contains(OFlags::NONBLOCK) + || !rustix::io::fcntl_getfd(fd)?.contains(rustix::io::FdFlags::CLOEXEC) + { + return Err(denied("owned blocking CLOEXEC child pipe end")); + } + let identity = (stat.st_dev, stat.st_ino); + if identities[..i].contains(&identity) { + return Err(denied("independent stdio pipes")); + } + identities[i] = identity; + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CaptureStatus { + /// A lower bound if the tree is cancelled or collection fails. + pub observed: u64, + pub retained: u64, + pub exceeded: bool, + pub eof: bool, +} +pub struct Capture { + fd: OwnedFd, + pipe: Option, + limit: u64, + status: CaptureStatus, +} +impl Capture { + pub fn status(&self) -> CaptureStatus { + self.status + } + /// Bounded caller buffer, no writable capture capability escapes. + pub fn read_at(&self, buffer: &mut [u8], offset: u64) -> io::Result { + let length = buffer + .len() + .min(self.status.retained.saturating_sub(offset) as usize); + if length == 0 { + return Ok(0); + } + Ok(rustix::io::pread(&self.fd, &mut buffer[..length], offset)?) + } + fn drain(&mut self) -> io::Result<()> { + let Some(pipe) = self.pipe.as_ref() else { + return Ok(()); + }; + let mut buffer = [0; CHUNK]; + // Observe one byte beyond the ceiling, retain only the allowed prefix. + let length = CHUNK.min((self.limit - self.status.retained + 1) as usize); + let n = match rustix::io::read(pipe, &mut buffer[..length]) { + Ok(0) => { + self.pipe = None; + self.status.eof = true; + return Ok(()); + } + Ok(n) => n, + Err(rustix::io::Errno::AGAIN | rustix::io::Errno::INTR) => return Ok(()), + Err(e) => return Err(e.into()), + }; + self.status.observed = self + .status + .observed + .checked_add(n as u64) + .ok_or_else(|| denied("output counter overflow"))?; + let keep = n.min((self.limit - self.status.retained) as usize); + let mut done = 0; + while done < keep { + match rustix::io::pwrite(&self.fd, &buffer[done..keep], self.status.retained) { + Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "capture storage")), + Ok(n) => { + done += n; + self.status.retained += n as u64; + } + Err(rustix::io::Errno::INTR) => continue, + Err(e) => return Err(e.into()), + } + } + if keep < n { + self.status.exceeded = true; + self.pipe = None; // No further allocation/drain on this stream. + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PumpStatus { + pub stdin_sent: u64, + pub stdin_closed: bool, + pub stdin_closed_early: bool, + pub stdout: CaptureStatus, + pub stderr: CaptureStatus, +} +impl PumpStatus { + pub fn output_limit_exceeded(self) -> bool { + self.stdout.exceeded || self.stderr.exceeded + } + pub fn outputs_eof(self) -> bool { + self.stdout.eof && self.stderr.eof + } +} +pub struct PipePump { + input: Option, + writer: Option, + pending: [u8; CHUNK], + position: usize, + length: usize, + sent: u64, + closed_early: bool, + output: Capture, + error: Capture, +} +impl PipePump { + /// This capability comes from the operator, never from the request. It + /// must be private to the current trusted monitor identity. Temporary + /// captures have zero links and are automatically deleted on drop. + pub fn prepare( + input: Option, + spec: &ValidatedSpec, + directory: OwnedFd, + ) -> io::Result<(Self, ChildPipes)> { + let stat = rustix::fs::fstat(&directory)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::Directory + || stat.st_uid != rustix::process::geteuid().as_raw() + || stat.st_mode & 0o7777 != 0o700 + || !rustix::io::fcntl_getfd(&directory)?.contains(rustix::io::FdFlags::CLOEXEC) + { + return Err(denied("capture directory capability")); + } + let limits = &spec.spec().limits; + let total = limits + .stdout_bytes + .checked_add(limits.stderr_bytes) + .ok_or_else(|| denied("capture budget overflow"))?; + if limits.stdout_bytes > limits.file_bytes + || limits.stderr_bytes > limits.file_bytes + || total + > limits + .workdir_bytes + .min(crate::artifacts::MAX_SNAPSHOT_BYTES) + { + return Err(denied("capture storage ceiling")); + } + let (input_end, input_write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?; + let (output_read, output_end) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?; + let (error_read, error_end) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?; + for fd in [&input_write, &output_read, &error_read] { + rustix::fs::fcntl_setfl(fd, OFlags::NONBLOCK)?; + } + let capture = |pipe, limit| -> io::Result { + let fd = rustix::fs::openat( + &directory, + ".", + OFlags::TMPFILE | OFlags::RDWR | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + )?; + let stat = rustix::fs::fstat(&fd)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile + || stat.st_nlink != 0 + || stat.st_size != 0 + || stat.st_uid != rustix::process::geteuid().as_raw() + { + return Err(denied("anonymous capture storage")); + } + Ok(Capture { + fd, + pipe: Some(pipe), + limit, + status: CaptureStatus::default(), + }) + }; + let writer = if input.as_ref().is_some_and(|f| f.bytes() > 0) { + Some(input_write) + } else { + drop(input_write); + None + }; + Ok(( + Self { + input, + writer, + pending: [0; CHUNK], + position: 0, + length: 0, + sent: 0, + closed_early: false, + output: capture(output_read, limits.stdout_bytes)?, + error: capture(error_read, limits.stderr_bytes)?, + }, + ChildPipes { + input: input_end, + output: output_end, + error: error_end, + }, + )) + } + /// Fair bounded tick: one stdin write and one read from each output. The + /// outer supervisor supplies poll/deadlines/cancellation; no busy wait here. + pub fn poll(&mut self) -> io::Result { + self.feed()?; + self.output.drain()?; + self.error.drain()?; + Ok(self.status()) + } + pub fn status(&self) -> PumpStatus { + PumpStatus { + stdin_sent: self.sent, + stdin_closed: self.writer.is_none(), + stdin_closed_early: self.closed_early, + stdout: self.output.status(), + stderr: self.error.status(), + } + } + pub fn stdout(&self) -> &Capture { + &self.output + } + pub fn stderr(&self) -> &Capture { + &self.error + } + /// Cancelling closes the feed immediately; outputs can still be drained to + /// EOF after the namespace/cgroup terminates. Snapshot stays immutable. + pub fn close_input(&mut self) { + self.writer = None; + } + fn feed(&mut self) -> io::Result<()> { + let Some(writer) = self.writer.as_ref() else { + return Ok(()); + }; + let input = self + .input + .as_ref() + .ok_or_else(|| denied("stdin ownership"))?; + if self.position == self.length { + let remaining = input + .bytes() + .checked_sub(self.sent) + .ok_or_else(|| denied("stdin accounting"))?; + if remaining == 0 { + self.writer = None; + return Ok(()); + } + let count = CHUNK.min(remaining as usize); + self.length = match rustix::io::pread(input, &mut self.pending[..count], self.sent) { + Ok(0) => return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "sealed stdin")), + Ok(n) => n, + Err(rustix::io::Errno::INTR) => return Ok(()), + Err(e) => return Err(e.into()), + }; + self.position = 0; + } + match write_without_sigpipe(writer, &self.pending[self.position..self.length]) { + Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "stdin pipe")), + Ok(n) => { + self.position += n; + self.sent += n as u64; + if self.sent == input.bytes() { + self.writer = None; + } + } + Err(rustix::io::Errno::PIPE) => { + self.writer = None; + self.closed_early = true; + } + Err(rustix::io::Errno::AGAIN | rustix::io::Errno::INTR) => {} + Err(e) => return Err(e.into()), + } + Ok(()) + } +} + +#[allow(unsafe_code)] +fn write_without_sigpipe(fd: &OwnedFd, bytes: &[u8]) -> rustix::io::Result { + // SAFETY: valid owned pipe and borrowed bytes, initialized libc sigsets. + // Block only this thread's SIGPIPE during write, consume a newly generated + // EPIPE signal if none was pending, and restore the exact prior mask on + // every path. Never change process-global disposition or steal a signal + // which was already pending. No allocation/unwind while mask is changed. + unsafe { + let mut set: libc::sigset_t = std::mem::zeroed(); + let mut old: libc::sigset_t = std::mem::zeroed(); + let mut pending: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + libc::sigaddset(&mut set, libc::SIGPIPE); + let rc = libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old); + if rc != 0 { + return Err(rustix::io::Errno::from_raw_os_error(rc)); + } + let result = if libc::sigpending(&mut pending) != 0 { + Err(rustix::io::Errno::from_raw_os_error( + *libc::__errno_location(), + )) + } else { + let prior = libc::sigismember(&pending, libc::SIGPIPE) == 1; + let n = libc::write(fd.as_raw_fd(), bytes.as_ptr().cast(), bytes.len()); + let written = if n < 0 { + Err(rustix::io::Errno::from_raw_os_error( + *libc::__errno_location(), + )) + } else { + Ok(n as usize) + }; + if written == Err(rustix::io::Errno::PIPE) && !prior { + let zero = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + loop { + let signal = libc::sigtimedwait(&set, std::ptr::null_mut(), &zero); + if signal >= 0 + || rustix::io::Errno::from_raw_os_error(*libc::__errno_location()) + != rustix::io::Errno::INTR + { + break; + } + } + } + written + }; + let rc = libc::pthread_sigmask(libc::SIG_SETMASK, &old, std::ptr::null_mut()); + if rc != 0 { + return Err(rustix::io::Errno::from_raw_os_error(rc)); + } + result + } +} diff --git a/judge/crates/sandbox/src/isolation/identity.rs b/judge/crates/sandbox/src/isolation/identity.rs index 5ad8bea..620530b 100644 --- a/judge/crates/sandbox/src/isolation/identity.rs +++ b/judge/crates/sandbox/src/isolation/identity.rs @@ -54,6 +54,10 @@ pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> { let uid = Uid::from_raw(uid); rustix::thread::set_thread_res_gid(gid, gid, gid)?; rustix::thread::set_thread_res_uid(uid, uid, uid)?; + // The trusted init retains control/capture capabilities. A same-UID user + // child must not reopen them through /proc/1/fd or /proc/1/root. Don't rely + // on fs.suid_dumpable's system default after the credential transition. + rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable)?; let empty = CapabilitySets { effective: CapabilitySet::empty(), permitted: CapabilitySet::empty(), @@ -70,6 +74,7 @@ pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> { || rustix::process::geteuid() != uid || rustix::process::getgid() != gid || rustix::process::getegid() != gid + || rustix::process::dumpable_behavior()? != rustix::process::DumpableBehavior::NotDumpable || rustix::thread::get_keep_capabilities()? || rustix::thread::capabilities_secure_bits()? != SECURE || !rustix::thread::no_new_privs()? diff --git a/judge/crates/sandbox/src/isolation/process_tree.rs b/judge/crates/sandbox/src/isolation/process_tree.rs index bf4fb1f..8c2cc42 100644 --- a/judge/crates/sandbox/src/isolation/process_tree.rs +++ b/judge/crates/sandbox/src/isolation/process_tree.rs @@ -12,30 +12,7 @@ fn denied(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::PermissionDenied, message) } -pub(crate) struct ChildStdio { - pub input: OwnedFd, - pub output: OwnedFd, - pub error: OwnedFd, -} -impl ChildStdio { - pub(crate) fn validate(&self) -> io::Result<()> { - for (fd, access) in [ - (&self.input, rustix::fs::OFlags::RDONLY), - (&self.output, rustix::fs::OFlags::WRONLY), - (&self.error, rustix::fs::OFlags::WRONLY), - ] { - let s = rustix::fs::fstat(fd)?; - if fd.as_raw_fd() < 4 - || s.st_mode & libc::S_IFMT != libc::S_IFIFO - || rustix::fs::fcntl_getfl(fd)?.intersection(rustix::fs::OFlags::ACCMODE) != access - || !rustix::io::fcntl_getfd(fd)?.contains(rustix::io::FdFlags::CLOEXEC) - { - return Err(denied("stdio requires owned CLOEXEC pipe ends")); - } - } - Ok(()) - } -} +pub(crate) use crate::io::ChildPipes as ChildStdio; /// Allocate/validate strings before fork. No request shell or host environment. pub(crate) struct ExecPlan { @@ -110,9 +87,12 @@ impl Reaper { } crate::isolation::identity::verify_status(&status, uid, gid)?; crate::isolation::seccomp::filter_count(&status)?; + if rustix::process::dumpable_behavior()? != rustix::process::DumpableBehavior::NotDumpable { + return Err(denied("trusted init descriptor privacy")); + } // PID ownership requires no inherited SIGCHLD auto-reap disposition. check_sigchld()?; - stdio.validate()?; + stdio.validate_for_exec()?; let (read, write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?; rustix::fs::fcntl_setfl(&read, rustix::fs::OFlags::NONBLOCK)?; let pid = fork_exec(&plan, &stdio, &write)?; diff --git a/judge/crates/sandbox/src/isolation/seccomp.rs b/judge/crates/sandbox/src/isolation/seccomp.rs index 6b44333..6f71dc6 100644 --- a/judge/crates/sandbox/src/isolation/seccomp.rs +++ b/judge/crates/sandbox/src/isolation/seccomp.rs @@ -87,6 +87,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result { libc::SYS_rseq, libc::SYS_rt_sigaction, libc::SYS_rt_sigprocmask, + libc::SYS_rt_sigpending, libc::SYS_rt_sigreturn, libc::SYS_sigaltstack, libc::SYS_rt_sigsuspend, @@ -201,6 +202,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result { rules( [ libc::PR_GET_NO_NEW_PRIVS, + libc::PR_GET_DUMPABLE, libc::PR_GET_SECCOMP, libc::PR_GET_SECUREBITS, libc::PR_GET_KEEPCAPS, diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index ab53b0e..8960587 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -15,5 +15,8 @@ pub mod artifacts; #[cfg(target_os = "linux")] pub mod cgroup; +#[cfg(target_os = "linux")] +pub mod io; + #[cfg(target_os = "linux")] mod isolation; diff --git a/judge/crates/sandbox/tests/fixtures/io_main.c b/judge/crates/sandbox/tests/fixtures/io_main.c new file mode 100644 index 0000000..3ace45f --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/io_main.c @@ -0,0 +1,36 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +static void require(int yes){if(!yes)_exit(2);} +static void write_all(int fd,const unsigned char *bytes,size_t n){while(n){ssize_t k=write(fd,bytes,n);if(k<0&&errno==EINTR)continue;require(k>0);bytes+=k;n-=(size_t)k;}} +static void pattern(int fd,unsigned char xor){unsigned char bytes[4096];for(unsigned offset=0;offset<262144;offset+=sizeof bytes){for(unsigned i=0;i1&&getppid()==1&&getuid()==100000&&prctl(PR_GET_SECCOMP)==2); + DIR *dir=opendir("/proc/self/fd");require(dir!=NULL);int scanner=dirfd(dir);struct dirent *entry; + while((entry=readdir(dir))){if(entry->d_name[0]!='.'){int fd=atoi(entry->d_name);require(fd<=2||fd==scanner);}} + closedir(dir); + require(open("/proc/1/fd/3",O_RDONLY|O_CLOEXEC)<0&&(errno==EACCES||errno==ENOENT)); + require(open("/proc/1/root",O_RDONLY|O_DIRECTORY|O_CLOEXEC)<0&&(errno==EACCES||errno==ENOENT)); + if(!strcmp(argv[1],"empty")){char byte;require(read(0,&byte,1)==0);return 7;} + if(!strcmp(argv[1],"early-close")){require(close(0)==0);const unsigned char out[]={'C','L','O','S','E','D',0,255};write_all(1,out,sizeof out);return 7;} + if(!strcmp(argv[1],"stdout-limit")||!strcmp(argv[1],"stderr-limit")){ + pid_t child=fork();require(child>=0);if(!child){for(;;)sleep(10);} + unsigned char bytes[4096];memset(bytes,0x5a,sizeof bytes);int fd=!strcmp(argv[1],"stdout-limit")?1:2; + // Fixed bounded flood in the independent VM. The descendant holding + // both pipe writers must also disappear when the monitor cancels. + for(unsigned i=0;i<32;i++){write_all(fd,bytes,sizeof bytes);} + for(;;){sleep(10);} + } + require(!strcmp(argv[1],"duplex")||!strcmp(argv[1],"storage-fail"));pid_t child=fork();require(child>=0); + if(!child){pattern(2,0xa5);return 0;} + pattern(1,0);unsigned char input[4096];unsigned total=0; + for(;;){ssize_t n=read(0,input,sizeof input);if(n<0&&errno==EINTR)continue;require(n>=0);if(!n)break;for(ssize_t i=0;i