feat(sandbox): reap namespace descendants and cancel via init lifecycle
This commit is contained in:
@@ -396,3 +396,23 @@ socketpair、ptrace 和 dumpability 可用,再安装真实生产策略验证
|
||||
TMPDIR 指向私有 `/tmp`,native 不带 PATH。请求仍不能指定 PATH、TMPDIR、HOME
|
||||
或 loader 覆盖;最终 exec 必须先清空环境、进入私有根,再应用这份环境。VM 语言
|
||||
探针使用相同固定编译 PATH,仅将 TMPDIR 显式指向其有界测试工作区。
|
||||
|
||||
### Namespace 进程树回收原语
|
||||
|
||||
私有 `isolation/process_tree.rs` 为单线程、已降权/过滤的 namespace PID 1 定义回收
|
||||
角色。PID 1 保留运行,主程序在独立子进程 exec;主进程 pidfd + waitid 与 wait-any
|
||||
配合,处理主进程退出和孤儿后代收养的竞态,只有 ECHILD 才确认树为空。取消、控制
|
||||
通道失联或监控异常都使 PID 1 退出,由内核终止该 namespace 的所有剩余进程;外层
|
||||
监督器仍必须检查 cgroup 为空并回收 bootstrap,不能把主进程退出当作整个任务结束。
|
||||
|
||||
exec 参数与固定环境在 fork 前分配,fork 前重复验证单线程/PID 1/凭据/过滤器与
|
||||
SIGCHLD wait 所有权。受审计的 unsafe 仅在沙箱内部:子进程用 async-signal-safe
|
||||
系统操作绑定 FD 0/1/2、关闭其他 FD、设置 cwd/SIGPIPE,随后 execve 或 _exit。
|
||||
FD 3 仅为 CLOEXEC 的 exec errno 管道;错误管道 EOF 只表示没有收到 exec 错误,
|
||||
单凭 EOF 不能证明程序已经 exec(例如放行前被终止)。普通信号/pidfd_send_signal/
|
||||
pidfd_getfd 仍拒绝,仅增加可见进程的只读 pidfd_open 以支持等待。
|
||||
|
||||
`tree_vm.rs`/固定 `tree_probe` 在受限独立 VM 实际验证正常主进程、孤儿收养/回收、
|
||||
主进程退出仍有后代时取消、控制端断开及真实 exec ENOENT。探针使用与生产相同的
|
||||
私有回收/凭据/seccomp 原语,在新 PID namespace 的 PID 1 运行;它不是完整任务
|
||||
执行 API。CLI run 继续拒绝,stdio/限额/计量与完整监督全部验收前不暴露执行放行。
|
||||
|
||||
@@ -10,10 +10,11 @@ kf-contracts.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio"] }
|
||||
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event"] }
|
||||
|
||||
# Fixed BPF compilation/installation through safe APIs; libc supplies syscall
|
||||
# numbers only. Both dependencies stay inside the OS sandbox boundary.
|
||||
# numbers and the audited fork/exec primitive. Both dependencies stay inside
|
||||
# the OS sandbox boundary.
|
||||
seccompiler = { version = "=0.5.0", default-features = false }
|
||||
libc = "=0.2.190"
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Fixed guarded VM role using the exact private production reaper/reducer.
|
||||
//! No production CLI user-exec gate or arbitrary fixture argv is added.
|
||||
mod launcher {
|
||||
pub mod entry {
|
||||
pub use kf_sandbox::launcher::entry::ensure_single_threaded;
|
||||
}
|
||||
}
|
||||
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_read_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
control
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
// Original proc still shows outer PIDs. The fixed fresh PID 1 must replace
|
||||
// it before single-threaded/proc-based preconditions and credential checks.
|
||||
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 (input, in_write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap();
|
||||
let (out_read, output) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap();
|
||||
let (err_read, error) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap();
|
||||
drop(in_write); // fixed empty stdin / EOF
|
||||
let mut request = support::spec("tree-fixture", 64 << 20, 8).spec().clone();
|
||||
request.argv = vec!["/work/main".into(), mode.into()];
|
||||
if mode == "exec-fail" {
|
||||
request.argv[0] = "/work/missing".into();
|
||||
request.inputs[0].path = "missing".into();
|
||||
}
|
||||
let plan = process_tree::ExecPlan::new(
|
||||
&request
|
||||
.validate(&kf_sandbox::spec::SpecPolicy::default())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
identity::reduce_current(100000, 100000).unwrap();
|
||||
seccomp::install(spec::ExecutionProfile::Native).unwrap();
|
||||
let mut reaper = process_tree::Reaper::spawn(
|
||||
plan,
|
||||
process_tree::ChildStdio {
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
control.write_all(b"READY\n").unwrap();
|
||||
let mut main_reported = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
let observed = reaper.poll().unwrap();
|
||||
if let Some(status) = observed.main.as_ref().filter(|_| !main_reported) {
|
||||
assert_eq!(
|
||||
*status,
|
||||
outcome::Termination::Exited {
|
||||
code: if mode == "exec-fail" { 126 } else { 7 }
|
||||
}
|
||||
);
|
||||
if mode == "exec-fail" {
|
||||
assert_eq!(
|
||||
observed.exec,
|
||||
process_tree::ExecObservation::Failed { errno: 2 }
|
||||
);
|
||||
}
|
||||
control.write_all(b"MAIN\n").unwrap();
|
||||
main_reported = true;
|
||||
}
|
||||
if observed.empty {
|
||||
assert!(main_reported);
|
||||
if mode == "orphan-short" {
|
||||
assert_eq!(observed.reaped_descendants, 1);
|
||||
}
|
||||
let mut out = std::fs::File::from(out_read);
|
||||
let mut err = std::fs::File::from(err_read);
|
||||
let mut output = Vec::new();
|
||||
let mut error = Vec::new();
|
||||
out.read_to_end(&mut output).unwrap();
|
||||
err.read_to_end(&mut error).unwrap();
|
||||
if mode != "exec-fail" {
|
||||
assert!(String::from_utf8_lossy(&output).contains("KF_TREE_USER_PROOF"));
|
||||
}
|
||||
if mode == "orphan-short" {
|
||||
assert!(String::from_utf8_lossy(&error).contains("KF_ORPHAN_ADOPTED_PROOF"));
|
||||
}
|
||||
control.write_all(b"DRAINED\n").unwrap();
|
||||
reaper.shutdown(process_tree::NamespaceEnd::Drained);
|
||||
}
|
||||
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: 2_000_000,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
if !poll[0].revents().is_empty() {
|
||||
let mut byte = [0];
|
||||
// The fixed init uses the admitted read syscall; std UnixStream::read
|
||||
// uses recvfrom, which is deliberately outside this policy.
|
||||
match rustix::io::read(&control, &mut byte) {
|
||||
Ok(0) => reaper.shutdown(process_tree::NamespaceEnd::ControlLost),
|
||||
Ok(1) if byte[0] == b'C' => {
|
||||
control.write_all(b"CANCELLED\n").unwrap();
|
||||
reaper.shutdown(process_tree::NamespaceEnd::Cancelled);
|
||||
}
|
||||
_ => reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed),
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
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!(
|
||||
[
|
||||
"normal",
|
||||
"orphan-short",
|
||||
"orphan-cancel",
|
||||
"orphan-disconnect",
|
||||
"exec-fail"
|
||||
]
|
||||
.contains(&args[2].as_str())
|
||||
);
|
||||
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(4)))
|
||||
.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), "MAIN");
|
||||
if args[2] == "orphan-cancel" || args[2] == "orphan-disconnect" {
|
||||
assert!(
|
||||
process.try_wait().unwrap().is_none(),
|
||||
"main exit abandoned descendants"
|
||||
);
|
||||
if args[2] == "orphan-cancel" {
|
||||
parent.write_all(b"C").unwrap();
|
||||
assert_eq!(line(&mut parent), "CANCELLED");
|
||||
}
|
||||
drop(parent);
|
||||
} else {
|
||||
assert_eq!(line(&mut parent), "DRAINED");
|
||||
drop(parent);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
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] == "orphan-disconnect" {
|
||||
assert_eq!(status.code(), Some(125));
|
||||
} 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_TREE_PROOF mode={} pid1_reaper=1 main_pidfd=1 main_status_verified=1 init_pidfd_ready=1 namespace_cleanup=1",
|
||||
args[2]
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,6 @@ pub mod namespaces;
|
||||
pub(crate) mod proc_dev;
|
||||
pub(crate) mod root;
|
||||
pub(crate) mod seccomp;
|
||||
// No public exec gate until resource supervision and lifecycle acceptance.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod process_tree;
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Single-threaded namespace PID 1. Private until all execution gates are ready.
|
||||
//! The init never execs the user program: it owns a pidfd and reaps orphaned
|
||||
//! descendants. Exiting init is the namespace-wide cancellation mechanism.
|
||||
use crate::{outcome::Termination, spec::ValidatedSpec};
|
||||
use rustix::{
|
||||
fd::{AsFd, AsRawFd, OwnedFd},
|
||||
process::{Pid, PidfdFlags, WaitId, WaitIdOptions, WaitOptions, WaitStatus},
|
||||
};
|
||||
use std::{ffi::CString, io};
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate/validate strings before fork. No request shell or host environment.
|
||||
pub(crate) struct ExecPlan {
|
||||
argv: Vec<CString>,
|
||||
env: Vec<CString>,
|
||||
cwd: CString,
|
||||
}
|
||||
impl ExecPlan {
|
||||
pub(crate) fn new(spec: &ValidatedSpec) -> io::Result<Self> {
|
||||
let string = |s: String| CString::new(s).map_err(|_| denied("exec string"));
|
||||
Ok(Self {
|
||||
argv: spec
|
||||
.spec()
|
||||
.argv
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(string)
|
||||
.collect::<io::Result<_>>()?,
|
||||
env: spec
|
||||
.execution_environment()
|
||||
.into_iter()
|
||||
.map(|(k, v)| string(format!("{k}={v}")))
|
||||
.collect::<io::Result<_>>()?,
|
||||
cwd: string(if spec.spec().cwd == "." {
|
||||
"/work".into()
|
||||
} else {
|
||||
format!("/work/{}", spec.spec().cwd)
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExecObservation {
|
||||
Pending,
|
||||
NoErrorObserved,
|
||||
Failed { errno: u16 },
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TreeObservation {
|
||||
pub main: Option<Termination>,
|
||||
pub exec: ExecObservation,
|
||||
pub reaped_descendants: u32,
|
||||
pub empty: bool,
|
||||
}
|
||||
pub(crate) enum NamespaceEnd {
|
||||
Drained,
|
||||
Cancelled,
|
||||
ControlLost,
|
||||
MonitorFailed,
|
||||
}
|
||||
|
||||
pub(crate) struct Reaper {
|
||||
pid: Pid,
|
||||
pidfd: OwnedFd,
|
||||
exec_error: OwnedFd,
|
||||
errno_bytes: [u8; 4],
|
||||
errno_len: usize,
|
||||
observation: TreeObservation,
|
||||
}
|
||||
impl Reaper {
|
||||
pub(crate) fn spawn(plan: ExecPlan, stdio: ChildStdio) -> io::Result<Self> {
|
||||
crate::launcher::entry::ensure_single_threaded()?;
|
||||
if rustix::process::getpid() != Pid::INIT {
|
||||
return Err(denied("reaper requires namespace PID 1"));
|
||||
}
|
||||
let uid = rustix::process::getuid().as_raw();
|
||||
let gid = rustix::process::getgid().as_raw();
|
||||
let status = std::fs::read_to_string("/proc/self/status")?;
|
||||
if !crate::isolation::identity::valid(uid, gid) {
|
||||
return Err(denied("reaper identity"));
|
||||
}
|
||||
crate::isolation::identity::verify_status(&status, uid, gid)?;
|
||||
crate::isolation::seccomp::filter_count(&status)?;
|
||||
// PID ownership requires no inherited SIGCHLD auto-reap disposition.
|
||||
check_sigchld()?;
|
||||
stdio.validate()?;
|
||||
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)?;
|
||||
drop(write);
|
||||
drop(stdio); // Init must not keep users' pipe ends open.
|
||||
let pidfd = match rustix::process::pidfd_open(pid, PidfdFlags::empty()) {
|
||||
Ok(fd) => fd,
|
||||
Err(_) => std::process::exit(125), // PID 1 exit kills the spawned tree.
|
||||
};
|
||||
Ok(Self {
|
||||
pid,
|
||||
pidfd,
|
||||
exec_error: read,
|
||||
errno_bytes: [0; 4],
|
||||
errno_len: 0,
|
||||
observation: TreeObservation {
|
||||
main: None,
|
||||
exec: ExecObservation::Pending,
|
||||
reaped_descendants: 0,
|
||||
empty: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
pub(crate) fn poll(&mut self) -> io::Result<TreeObservation> {
|
||||
self.poll_exec()?;
|
||||
if self.observation.main.is_none() {
|
||||
match rustix::process::waitid(
|
||||
WaitId::PidFd(self.pidfd.as_fd()),
|
||||
WaitIdOptions::EXITED | WaitIdOptions::NOHANG,
|
||||
) {
|
||||
Ok(Some(s)) => {
|
||||
self.observation.main = Some(if let Some(code) = s.exit_status() {
|
||||
Termination::Exited { code: code as u8 }
|
||||
} else if let Some(signal) = s.terminating_signal() {
|
||||
Termination::Signaled {
|
||||
signal: signal as u8,
|
||||
core_dumped: s.dumped(),
|
||||
}
|
||||
} else {
|
||||
return Err(denied("unexpected main waitid state"));
|
||||
});
|
||||
}
|
||||
Ok(None) | Err(rustix::io::Errno::INTR) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
// At most the validated pids ceiling per tick; don't starve control/I/O.
|
||||
for _ in 0..128 {
|
||||
match rustix::process::wait(WaitOptions::NOHANG) {
|
||||
Ok(Some((pid, status))) if pid == self.pid => {
|
||||
// Main exited between pidfd polling and wait-any. Record
|
||||
// its actual status, never mistake it for a descendant.
|
||||
if self.observation.main.is_some() {
|
||||
return Err(denied("main reaped twice"));
|
||||
}
|
||||
self.observation.main = Some(termination(status)?);
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
self.observation.reaped_descendants = self
|
||||
.observation
|
||||
.reaped_descendants
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| denied("reap counter overflow"))?
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(rustix::io::Errno::INTR) => continue,
|
||||
Err(rustix::io::Errno::CHILD) => {
|
||||
if self.observation.main.is_none() {
|
||||
return Err(denied("main wait ownership lost"));
|
||||
}
|
||||
self.observation.empty = true;
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if self.observation.main.is_some() {
|
||||
self.poll_exec()?;
|
||||
}
|
||||
Ok(self.observation.clone())
|
||||
}
|
||||
fn poll_exec(&mut self) -> io::Result<()> {
|
||||
if self.observation.exec != ExecObservation::Pending {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
match rustix::io::read(&self.exec_error, &mut self.errno_bytes[self.errno_len..]) {
|
||||
Ok(0) if self.errno_len == 0 => {
|
||||
self.observation.exec = ExecObservation::NoErrorObserved;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(0) => return Err(denied("partial exec error")),
|
||||
Ok(n) => {
|
||||
self.errno_len += n;
|
||||
if self.errno_len == 4 {
|
||||
let errno = i32::from_ne_bytes(self.errno_bytes);
|
||||
let errno = u16::try_from(errno)
|
||||
.ok()
|
||||
.filter(|n| *n > 0)
|
||||
.ok_or_else(|| denied("exec errno"))?;
|
||||
self.observation.exec = ExecObservation::Failed { errno };
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(rustix::io::Errno::AGAIN) => return Ok(()),
|
||||
Err(rustix::io::Errno::INTR) => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn shutdown(self, reason: NamespaceEnd) -> ! {
|
||||
let code = match reason {
|
||||
NamespaceEnd::Drained | NamespaceEnd::Cancelled => 0,
|
||||
NamespaceEnd::ControlLost | NamespaceEnd::MonitorFailed => 125,
|
||||
};
|
||||
// Constructor proved PID 1. Kernel SIGKILLs and waits for the entire
|
||||
// namespace; the outside supervisor must still verify cgroup empty.
|
||||
std::process::exit(code)
|
||||
}
|
||||
}
|
||||
impl Drop for Reaper {
|
||||
fn drop(&mut self) {
|
||||
// Monitor panic/error cannot abandon a live descendant namespace.
|
||||
std::process::exit(125);
|
||||
}
|
||||
}
|
||||
fn termination(s: WaitStatus) -> io::Result<Termination> {
|
||||
if let Some(code) = s.exit_status() {
|
||||
Ok(Termination::Exited { code: code as u8 })
|
||||
} else if let Some(signal) = s.terminating_signal() {
|
||||
Ok(Termination::Signaled {
|
||||
signal: signal as u8,
|
||||
core_dumped: s.as_raw() & 0x80 != 0,
|
||||
})
|
||||
} else {
|
||||
Err(denied("unexpected descendant wait state"))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn check_sigchld() -> io::Result<()> {
|
||||
// SAFETY: sigaction query initializes the supplied C struct, no handler or
|
||||
// global disposition is changed. Only the checked single-threaded init.
|
||||
unsafe {
|
||||
let mut s: libc::sigaction = std::mem::zeroed();
|
||||
if libc::sigaction(libc::SIGCHLD, std::ptr::null(), &mut s) != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if s.sa_sigaction != libc::SIG_DFL || s.sa_flags & libc::SA_NOCLDWAIT != 0 {
|
||||
return Err(denied("SIGCHLD must preserve wait ownership"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn fork_exec(plan: &ExecPlan, stdio: &ChildStdio, error: &OwnedFd) -> io::Result<Pid> {
|
||||
let mut argv: Vec<_> = plan.argv.iter().map(|s| s.as_ptr()).collect();
|
||||
argv.push(std::ptr::null());
|
||||
let mut env: Vec<_> = plan.env.iter().map(|s| s.as_ptr()).collect();
|
||||
env.push(std::ptr::null());
|
||||
let sources = [
|
||||
stdio.input.as_raw_fd(),
|
||||
stdio.output.as_raw_fd(),
|
||||
stdio.error.as_raw_fd(),
|
||||
];
|
||||
let err = error.as_raw_fd();
|
||||
let program = plan.argv[0].as_ptr();
|
||||
let cwd = plan.cwd.as_ptr();
|
||||
if err < 4 {
|
||||
return Err(denied("exec-error descriptor alias"));
|
||||
}
|
||||
// SAFETY: fresh, checked single-threaded PID 1, trusted image only, no user
|
||||
// code/threads/atfork hooks loaded. All allocations/C strings/raw FD values
|
||||
// prepared above and borrowed alive in both processes. Child uses only
|
||||
// async-signal-safe libc syscalls and exec/_exit, never Rust allocation,
|
||||
// panic, unwinding or descriptor destructors. fork copies, not shares, the
|
||||
// FD table; only its copy is rewritten/closed. All source FDs are >=4.
|
||||
unsafe {
|
||||
let raw = libc::fork();
|
||||
if raw < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if raw == 0 {
|
||||
if libc::dup3(err, 3, libc::O_CLOEXEC) < 0 {
|
||||
let errno = *libc::__errno_location();
|
||||
let _ = libc::write(err, (&errno as *const i32).cast(), 4);
|
||||
libc::_exit(126);
|
||||
}
|
||||
let setup = sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(target, source)| libc::dup2(*source, target as i32) >= 0)
|
||||
&& libc::syscall(libc::SYS_close_range, 4_u32, u32::MAX, 0_u32) == 0
|
||||
&& libc::chdir(cwd) == 0
|
||||
&& libc::signal(libc::SIGPIPE, libc::SIG_DFL) != libc::SIG_ERR;
|
||||
if setup {
|
||||
libc::execve(program, argv.as_ptr(), env.as_ptr());
|
||||
}
|
||||
let errno = *libc::__errno_location();
|
||||
let _ = libc::write(3, (&errno as *const i32).cast(), 4);
|
||||
libc::_exit(126);
|
||||
}
|
||||
Ok(Pid::from_raw(raw).expect("positive fork PID"))
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,9 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
||||
libc::SYS_fork,
|
||||
libc::SYS_wait4,
|
||||
libc::SYS_waitid,
|
||||
// Holding/waiting on visible children is read-only; signalling via
|
||||
// pidfds remains forbidden, as does pidfd_getfd.
|
||||
libc::SYS_pidfd_open,
|
||||
libc::SYS_execve,
|
||||
libc::SYS_exit,
|
||||
libc::SYS_exit_group,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
static void require(int yes){if(!yes){fprintf(stderr,"tree user proof failure errno=%d\n",errno);_exit(2);}}
|
||||
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 *e;
|
||||
while((e=readdir(dir)))if(e->d_name[0]!='.'){int fd=atoi(e->d_name);require(fd<=2||fd==scanner);}
|
||||
closedir(dir);char data;require(read(0,&data,1)==0);
|
||||
puts("KF_TREE_USER_PROOF pid_gt1=1 parent_init=1 reduced=1 filtered=1 fd012_only=1 eof=1");fflush(stdout);
|
||||
if(!strcmp(argv[1],"normal"))return 7;
|
||||
pid_t child=fork();require(child>=0);
|
||||
if(child)return 7;
|
||||
if(!strcmp(argv[1],"orphan-short")){
|
||||
for(unsigned i=0;i<100&&getppid()!=1;i++)usleep(1000);
|
||||
require(getppid()==1);fputs("KF_ORPHAN_ADOPTED_PROOF\n",stderr);return 3;
|
||||
}
|
||||
for(;;)sleep(10);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#![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 namespace_init_reaps_main_and_orphans_and_cancels_surviving_tree() {
|
||||
support::require_vm();
|
||||
for mode in [
|
||||
"normal",
|
||||
"orphan-short",
|
||||
"orphan-cancel",
|
||||
"orphan-disconnect",
|
||||
"exec-fail",
|
||||
] {
|
||||
let output = Command::new("/kf/bin/tree-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_TREE_PROOF mode={mode}")));
|
||||
print!("{proof}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user