feat(sandbox): transport bounded stdio and close inherited user descriptors

This commit is contained in:
2026-10-07 22:49:25 +08:00
parent 89da9ffd9f
commit cc6ee1bfa7
10 changed files with 906 additions and 25 deletions
+12
View File
@@ -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.
+424
View File
@@ -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]
);
}
@@ -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;
}
+363
View File
@@ -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<OwnedFd>,
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<usize> {
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<SnapshotFile>,
writer: Option<OwnedFd>,
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<SnapshotFile>,
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<Capture> {
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<PumpStatus> {
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<usize> {
// 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
}
}
@@ -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()?
@@ -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)?;
@@ -87,6 +87,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
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<BpfProgram> {
rules(
[
libc::PR_GET_NO_NEW_PRIVS,
libc::PR_GET_DUMPABLE,
libc::PR_GET_SECCOMP,
libc::PR_GET_SECUREBITS,
libc::PR_GET_KEEPCAPS,
+3
View File
@@ -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;
+36
View File
@@ -0,0 +1,36 @@
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/wait.h>
#include <unistd.h>
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;i<sizeof bytes;i++)bytes[i]=(unsigned char)((offset+i)%251)^xor;write_all(fd,bytes,sizeof bytes);}}
int main(int argc,char **argv){
require(argc==2&&getpid()>1&&getppid()==1&&getuid()==100000&&prctl(PR_GET_SECCOMP)==2);
DIR *dir=opendir("/proc/self/fd");require(dir!=NULL);int scanner=dirfd(dir);struct dirent *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<n;i++)require(input[i]==(unsigned char)((total+(unsigned)i)%251));total+=(unsigned)n;require(total<=262144);}
require(total==262144);int status;require(waitpid(child,&status,0)==child&&WIFEXITED(status)&&WEXITSTATUS(status)==0);return 7;
}
+53
View File
@@ -0,0 +1,53 @@
#![cfg(target_os = "linux")]
#[allow(dead_code)]
mod support;
use std::process::Command;
#[test]
#[ignore = "requires independent capped VM with fixed namespace role fixture"]
fn independent_stdio_drains_binary_backpressure_eof_early_close_and_limits() {
support::require_vm();
for mode in [
"duplex",
"empty",
"early-close",
"stdout-limit",
"stderr-limit",
"storage-fail",
] {
let output = Command::new("/kf/bin/io-probe")
.args(["fixed", mode])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{mode}: {:?} {:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let proof = String::from_utf8(output.stdout).unwrap();
assert!(proof.contains(&format!("KF_IO_PROOF mode={mode}")));
print!("{proof}");
}
}
#[test]
#[ignore = "requires independent capped VM; SIGPIPE default may terminate a broken monitor"]
fn early_close_contains_sigpipe_without_global_disposition_or_mask_leak() {
support::require_vm();
let result = std::process::Command::new("/kf/bin/io-probe")
.args(["signal-mask", "early-close"])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.output()
.unwrap();
assert!(
result.status.success(),
"{:?} {:?}",
result.stdout,
result.stderr
);
assert!(String::from_utf8_lossy(&result.stdout).contains("KF_IO_SIGPIPE_PROOF"));
print!("{}", String::from_utf8_lossy(&result.stdout));
}