feat(sandbox): restrict proc and devices in private roots

This commit is contained in:
2026-10-07 13:21:37 +08:00
parent 0f68eb5fb0
commit 2b5e79dd39
13 changed files with 588 additions and 12 deletions
+27
View File
@@ -232,6 +232,33 @@ work 为 nosuid/nodev,保留后续编译和执行能力;tmp 另加 noexec。
参见 [pivot_root](https://man7.org/linux/man-pages/man2/pivot_root.2.html) 和
[mount](https://man7.org/linux/man-pages/man2/mount.2.html)。
## 受限 proc/dev 与初始化 FD 防护
`isolation/proc_dev.rs` 固定 OS 策略,`launcher/proc_dev.rs` 的 `RestrictedRootTarget`
在已完成的 `RootedTarget` 上推进独立握手;两个类型均没有用户 exec 方法。
fresh exec 的 bootstrap/init 在创建内部 FD 前扫描自己的 FD,只允许 0/1/2,
精确排除扫描器自己持有的 FD。任何额外继承文件或目录都在 namespace 操作前拒绝,
避免原始 close 与 Rust FD 所有权冲突。初始化后监督器再次确认 init 和 bootstrap
的根、cwd 及无目录 FD,拒绝 cwd 留在私有 root/work 以外的情况。
仅 namespace PID 1 在切根后重新挂载 proc,使用 ro/nosuid/nodev/noexec、
`hidepid=2,subset=pid`。监督器核对实际文件系统、挂载选项、PID 1/PPid 0,
以及全局 sys/sysrq-trigger/kcore/keys 等入口缺失;选项或挂载能力不支持则拒绝。
dev 使用 64 KiB、16 inode 的私有 tmpfs,只创建 root 所有、0666 的四个字符设备:
null (1:3)、zero (1:5)、random (1:8)、urandom (1:9),随后把目录挂载设为只读。
dev 为 nosuid/noexec,允许这四个设备 I/O 所以不设置 nodev;不提供磁盘、mem、tty 或 pts。
固定 dev 的容量是初始化开销,work/tmp 的申请预算和内存计费政策另行管理。
`proc_dev_vm.rs` 在规定的独立 VM 中验证新 PID 视图、固定设备 I/O、非 root 固定探针对
helper/外部 PID、旧根和跨任务文件的访问拒绝,并验证意外继承 FD 的启动拒绝。
VM 专用故障注入器让内核对 mknodat 返回 EPERM,验证部分 proc/dev 初始化的真实清理。
探针的 UID 切换及故障过滤器仅属于 VM 验收,不能当作生产身份或 seccomp 实现。
测试输入尚未安全绑定,工件校验/提取、生产身份/caps/seccomp 和完整用户 stdio/监督
仍待实现,因此完整 proc/dev 与输入保护 TODO 暂不标记完成,run 继续拒绝。
参见 [proc 的 hidepid/subset 选项](https://man7.org/linux/man-pages/man5/procfs.5.html)
及 [null/zero 设备](https://man7.org/linux/man-pages/man4/null.4.html)。
## 可信运行配置与启动前检查
```bash
@@ -1,4 +1,5 @@
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
pub mod namespaces;
pub(crate) mod proc_dev;
pub(crate) mod root;
@@ -0,0 +1,194 @@
//! Fixed post-pivot proc/dev policy. No host mount/device path is request data.
use rustix::fs::{AtFlags, FileType, Mode, OFlags, StatVfsMountFlags};
use rustix::mount::MountFlags;
use std::io;
const DEV_BYTES: u64 = 64 * 1024;
const DEVICES: [(&str, u32); 4] = [("null", 3), ("zero", 5), ("random", 8), ("urandom", 9)];
fn denied(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
/// The root role already proved single-threaded before pivot; a fresh proc
/// mount is needed before repeating that check. Only namespace PID 1 calls us.
pub(crate) fn install() -> io::Result<()> {
if rustix::process::getpid().as_raw_pid() != 1 {
return Err(denied("proc setup requires namespace init"));
}
rustix::mount::mount(
"proc",
"/proc",
"proc",
MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC,
Some(c"hidepid=2,subset=pid"),
)?;
crate::launcher::entry::ensure_single_threaded()?;
rustix::mount::mount(
"tmpfs",
"/dev",
"tmpfs",
MountFlags::NOSUID | MountFlags::NOEXEC,
Some(c"size=65536,nr_inodes=16,mode=0755"),
)?;
let dev = rustix::fs::open(
"/dev",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
// Single-threaded fresh init owns this process-wide umask change. Restore
// it even when mknod fails; the caller terminates the namespace on error.
let previous = rustix::process::umask(Mode::empty());
let created = (|| {
for (name, minor) in DEVICES {
rustix::fs::mknodat(
&dev,
name,
FileType::CharacterDevice,
Mode::from_raw_mode(0o666),
rustix::fs::makedev(1, minor),
)?;
}
Ok::<_, io::Error>(())
})();
rustix::process::umask(previous);
created?;
drop(dev);
// Read-only directory entries still permit I/O on the fixed character
// devices. NODEV must remain absent here; all other mounts retain NODEV.
rustix::mount::mount_remount(
"/dev",
MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NOEXEC,
c"",
)?;
Ok(())
}
pub(crate) fn verify(pid: u32) -> io::Result<()> {
let base = format!("/proc/{pid}/root");
let proc = rustix::fs::open(
format!("{base}/proc"),
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let required =
StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NOEXEC;
if rustix::fs::fstatfs(&proc)?.f_type != rustix::fs::PROC_SUPER_MAGIC
|| !rustix::fs::fstatvfs(&proc)?
.f_flag
.contains(required | StatVfsMountFlags::NODEV)
{
return Err(denied("restricted proc mount mismatch"));
}
let status = std::fs::read_to_string(format!("{base}/proc/1/status"))?;
if !status.lines().any(|line| line == "Pid:\t1")
|| !status.lines().any(|line| line == "PPid:\t0")
{
return Err(denied("proc is not rooted in namespace PID 1"));
}
let proc_mountinfo = std::fs::read_to_string(format!("{base}/proc/1/mountinfo"))?;
let options = proc_mountinfo
.lines()
.find_map(|line| {
let (left, right) = line.split_once(" - ")?;
(left.split_whitespace().nth(4) == Some("/proc"))
.then(|| right.split_whitespace().nth(2))
.flatten()
})
.ok_or_else(|| denied("missing proc mount options"))?;
verify_proc_options(options)?;
for path in [
"sys",
"sysrq-trigger",
"kcore",
"keys",
"meminfo",
"cpuinfo",
] {
match rustix::fs::statat(&proc, path, AtFlags::SYMLINK_NOFOLLOW) {
Err(rustix::io::Errno::NOENT) => {}
_ => return Err(denied("global proc entry exposed")),
}
}
let dev = rustix::fs::open(
format!("{base}/dev"),
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let stat = rustix::fs::fstatvfs(&dev)?;
if rustix::fs::fstatfs(&dev)?.f_type as u64 != 0x0102_1994
|| stat.f_blocks.checked_mul(stat.f_frsize) != Some(DEV_BYTES)
|| !stat.f_flag.contains(required)
|| stat.f_flag.contains(StatVfsMountFlags::NODEV)
{
return Err(denied("restricted dev mount mismatch"));
}
let mut entries = std::fs::read_dir(format!("{base}/dev"))?
.map(|entry| entry.map(|e| e.file_name()))
.collect::<io::Result<Vec<_>>>()?;
entries.sort();
let mut expected: Vec<_> = DEVICES
.iter()
.map(|(name, _)| std::ffi::OsString::from(name))
.collect();
expected.sort();
if entries != expected {
return Err(denied("unexpected dev entry"));
}
for (name, minor) in DEVICES {
let stat = rustix::fs::statat(&dev, name, AtFlags::SYMLINK_NOFOLLOW)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::CharacterDevice
|| stat.st_rdev != rustix::fs::makedev(1, minor)
|| stat.st_uid != 0
|| stat.st_gid != 0
|| stat.st_mode & 0o7777 != 0o666
{
return Err(denied("device type/identity/mode mismatch"));
}
}
Ok(())
}
fn verify_proc_options(options: &str) -> io::Result<()> {
let hide: Vec<_> = options
.split(',')
.filter(|s| s.starts_with("hidepid="))
.collect();
let subset: Vec<_> = options
.split(',')
.filter(|s| s.starts_with("subset="))
.collect();
// Kernel show_options renders HIDEPID_INVISIBLE as a named value, even
// when mount received the numeric value. Accept only this exact policy.
if !matches!(hide.as_slice(), ["hidepid=2"] | ["hidepid=invisible"])
|| !matches!(subset.as_slice(), ["subset=pid"])
{
return Err(denied("proc restriction options mismatch"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::verify_proc_options;
#[test]
fn kernel_numeric_and_named_invisible_policy_are_equivalent() {
for options in ["rw,hidepid=2,subset=pid", "rw,hidepid=invisible,subset=pid"] {
assert!(verify_proc_options(options).is_ok());
}
}
#[test]
fn weaker_missing_or_ambiguous_proc_options_are_rejected() {
for options in [
"rw,subset=pid",
"rw,hidepid=invisible",
"rw,hidepid=off,subset=pid",
"rw,hidepid=noaccess,subset=pid",
"rw,hidepid=ptraceable,subset=pid",
"rw,hidepid=2,hidepid=off,subset=pid",
"rw,hidepid=2,subset=pid,subset=all",
] {
assert!(verify_proc_options(options).is_err(), "{options}");
}
}
}
+10 -4
View File
@@ -208,10 +208,9 @@ impl RootLease {
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
if !rustix::fs::fstatvfs(&root)?
.f_flag
.contains(StatVfsMountFlags::RDONLY)
{
if !rustix::fs::fstatvfs(&root)?.f_flag.contains(
StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NODEV,
) {
return Err(invalid("image root is not read-only"));
}
for (name, bytes, tmp) in [
@@ -240,6 +239,13 @@ impl RootLease {
return Err(invalid("initialization directory FD leaked"));
}
}
let cwd = std::fs::metadata(format!("/proc/{pid}/cwd"))?;
let work = std::fs::metadata(format!("/proc/{pid}/root/work"))?;
if (cwd.dev(), cwd.ino()) != (actual.dev(), actual.ino())
&& (cwd.dev(), cwd.ino()) != (work.dev(), work.ino())
{
return Err(invalid("initialization cwd is outside private root/work"));
}
Ok(())
}
pub(crate) fn cleanup(&mut self) -> io::Result<()> {
@@ -77,3 +77,38 @@ pub fn ensure_single_threaded() -> io::Result<()> {
}
Ok(())
}
/// Fresh exec roles reject unowned inherited descriptors before creating any
/// channel duplicates. Closing unknown raw FDs would risk Rust ownership; fail
/// closed instead. The single-threaded scan owns and excludes exactly its FD.
pub(crate) fn ensure_inherited_descriptors() -> io::Result<()> {
use rustix::fd::AsRawFd;
use rustix::fs::{Dir, Mode, OFlags};
ensure_single_threaded()?;
let fd = rustix::fs::open(
"/proc/self/fd",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
let mut directory = Dir::new(fd)?;
let scanner = directory.fd()?.as_raw_fd();
let mut count = 0;
while let Some(entry) = directory.read() {
let entry = entry?;
let name = entry.file_name().to_bytes();
if name == b"." || name == b".." {
continue;
}
count += 1;
let fd = std::str::from_utf8(name)
.ok()
.and_then(|s| s.parse::<i32>().ok());
if count > 128 || !fd.is_some_and(|fd| (0..=2).contains(&fd) || fd == scanner) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"unexpected inherited descriptor",
));
}
}
Ok(())
}
+2
View File
@@ -14,6 +14,8 @@ pub mod entry;
#[cfg(target_os = "linux")]
pub mod namespaces;
#[cfg(target_os = "linux")]
pub mod proc_dev;
#[cfg(target_os = "linux")]
pub mod protocol;
#[cfg(target_os = "linux")]
pub mod root;
@@ -39,6 +39,7 @@ fn inherited_channel(parent_pid: u32) -> io::Result<ControlChannel> {
/// unshare; changing PID namespaces changes SO_PEERCRED's PID translation.
pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()> {
super::entry::ensure_single_threaded()?;
super::entry::ensure_inherited_descriptors()?;
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
if expected_parent <= 1
|| rustix::process::getppid().map(|pid| pid.as_raw_pid() as u32) != Some(expected_parent)
@@ -53,7 +54,7 @@ pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()>
return Ok(());
}
NamespaceCommand::Initialize {} => {}
NamespaceCommand::PrepareRoot { .. } => {
NamespaceCommand::PrepareRoot { .. } | NamespaceCommand::PrepareProcDev {} => {
return Err(denied("root command before namespaces"));
}
}
@@ -130,6 +131,7 @@ fn status_value(status: &str, name: &str) -> io::Result<u32> {
/// in the new PID namespace. Later root setup must replace this inherited proc.
pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::Result<()> {
super::entry::ensure_single_threaded()?;
super::entry::ensure_inherited_descriptors()?;
if rustix::process::getpid().as_raw_pid() != 1 || rustix::process::getppid().is_some() {
return Err(denied("not the namespace init"));
}
@@ -171,7 +173,20 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::
return Err(error);
}
channel.send(&NamespaceEvent::RootInitialized {})?;
let _ = channel.receive::<NamespaceCommand>()?;
match channel.receive::<NamespaceCommand>()? {
NamespaceCommand::PrepareProcDev {} => {
if let Err(error) = crate::isolation::proc_dev::install() {
channel.send(&NamespaceEvent::Failed {
errno: error.raw_os_error().and_then(|n| u16::try_from(n).ok()),
})?;
return Err(error);
}
channel.send(&NamespaceEvent::ProcDevInitialized {})?;
let _ = channel.receive::<NamespaceCommand>()?;
}
NamespaceCommand::Cancel {} => return Ok(()),
_ => {}
}
}
NamespaceCommand::Cancel {} => return Ok(()),
_ => {}
@@ -186,6 +201,8 @@ pub struct NamespacedTarget {
control: ControlChannel,
init_pidfd: OwnedFd,
allow_root: bool,
root_ready: bool,
proc_dev_ready: bool,
init_pid: u32,
ids: NamespaceIds,
}
@@ -276,6 +293,8 @@ impl NamespacedTarget {
control,
init_pidfd,
allow_root,
root_ready: false,
proc_dev_ready: false,
}),
Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))),
}
@@ -285,18 +304,43 @@ impl NamespacedTarget {
&mut self,
budget: crate::isolation::root::RootBudget,
) -> std::io::Result<()> {
if !self.allow_root {
if !self.allow_root || self.root_ready {
return Err(denied("root descriptors not inherited"));
}
self.control.send(&NamespaceCommand::PrepareRoot {
work_bytes: budget.work_bytes,
tmp_bytes: budget.tmp_bytes,
})?;
self.initialize_filesystem(
NamespaceCommand::PrepareRoot {
work_bytes: budget.work_bytes,
tmp_bytes: budget.tmp_bytes,
},
NamespaceEvent::RootInitialized {},
)?;
self.root_ready = true;
Ok(())
}
pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> {
if !self.root_ready || self.proc_dev_ready {
return Err(denied("proc/dev initialization order"));
}
self.initialize_filesystem(
NamespaceCommand::PrepareProcDev {},
NamespaceEvent::ProcDevInitialized {},
)?;
self.proc_dev_ready = true;
Ok(())
}
fn initialize_filesystem(
&mut self,
command: NamespaceCommand,
expected: NamespaceEvent,
) -> io::Result<()> {
self.control.send(&command)?;
self.prepared
.cgroup
.begin_root_initialization(&[self.prepared.pid(), self.init_pid], &self.init_pidfd)?;
match self.control.receive::<NamespaceEvent>()? {
NamespaceEvent::RootInitialized {} => {}
event if event == expected => {}
NamespaceEvent::Failed { errno: Some(errno) } => {
return Err(io::Error::from_raw_os_error(i32::from(errno)));
}
@@ -0,0 +1,31 @@
//! A separately gated restricted-filesystem stage, with no user execution API.
use super::blocked::InitializationFailure;
use super::root::RootedTarget;
use crate::runtime::ValidatedRuntimeConfig;
use crate::spec::ValidatedSpec;
use std::path::Path;
pub struct RestrictedRootTarget {
root: RootedTarget,
}
impl RestrictedRootTarget {
pub fn prepare(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
executable: &Path,
) -> Result<Self, InitializationFailure> {
let mut root = RootedTarget::prepare(config, spec, executable)?;
if let Err(error) = root.initialize_proc_dev() {
return Err(root.refuse_root_error(&error));
}
Ok(Self { root })
}
pub fn root(&self) -> &RootedTarget {
&self.root
}
/// Input binding, identity/capabilities/seccomp and supervision are absent.
pub fn refuse_remaining_isolation(self) -> InitializationFailure {
self.root.refuse_remaining_isolation()
}
}
@@ -41,6 +41,7 @@ pub enum ChildMessage {
pub enum NamespaceCommand {
Initialize {},
PrepareRoot { work_bytes: u64, tmp_bytes: u64 },
PrepareProcDev {},
Cancel {},
}
@@ -49,6 +50,7 @@ pub enum NamespaceCommand {
pub enum NamespaceEvent {
Initialized { init_pid: u32 },
RootInitialized {},
ProcDevInitialized {},
Failed { errno: Option<u16> },
Cancelled {},
}
+17
View File
@@ -72,6 +72,7 @@ impl RootedTarget {
if let Err(error) = (|| {
target.initialize_root(lease.budget)?;
lease.verify_root(target.init_pid())?;
lease.verify_root(target.bootstrap_pid())?;
Ok::<_, io::Error>(())
})() {
let mut failure = target
@@ -95,6 +96,22 @@ impl RootedTarget {
self.lease.budget
}
pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> {
self.target.initialize_proc_dev()?;
self.lease.verify_root(self.init_pid())?;
self.lease.verify_root(self.bootstrap_pid())?;
crate::isolation::proc_dev::verify(self.init_pid())
}
pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure {
let mut failure = self
.target
.prepared
.refuse_at(ExecutionStage::RootFilesystem, Some(error));
attach_cleanup(&mut failure, self.lease.cleanup().err());
failure
}
/// Restricted proc/dev, identity, capabilities and seccomp are still absent.
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
let mut failure = self
+29
View File
@@ -0,0 +1,29 @@
/* VM-only kernel failure injector. It execs one fixed trusted test case. */
#define _GNU_SOURCE
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <errno.h>
#include <stddef.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
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_mknodat,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_MKNOD","1",1);
execl("/kf/bin/proc-dev-vm-tests","proc-dev-vm-tests","--ignored","--test-threads=1","--exact","kernel_mknod_failure_rolls_back_proc_dev_and_root","--nocapture",(char*)0);
return 4;
}
+65
View File
@@ -0,0 +1,65 @@
/* Fixed bounded filesystem probe, compiled/run only in the capped test VM. */
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
static int absent(const char *p) {
struct stat st;
return stat(p,&st)<0 && (errno==ENOENT || errno==EACCES);
}
static int probe(int mnt,int pidns,int root,const char *peer) {
alarm(8);
if(fchdir(root)||chroot(".")||chdir("/"))return 10;
close(mnt);close(pidns);close(root);
int created=open("/dev/new-file",O_CREAT|O_WRONLY|O_CLOEXEC,0600);
if(created>=0||errno!=EROFS)return 24;
/* Test-only identity, not a production identity implementation. */
if(setgroups(0,NULL)||setresgid(100000,100000,100000)||setresuid(100000,100000,100000))return 11;
if(getpid()!=2 || !absent("/proc/1/root") || !absent("/proc/1/fd"))return 12;
DIR *d=opendir("/proc");if(!d)return 13;struct dirent *e;
while((e=readdir(d)))if(e->d_name[0]>='0'&&e->d_name[0]<='9'&&strcmp(e->d_name,"2"))return 14;
closedir(d);
const char *global[]={"/proc/sys","/proc/sysrq-trigger","/proc/kcore","/proc/keys","/proc/cpuinfo","/proc/meminfo"};
for(unsigned i=0;i<sizeof global/sizeof *global;i++)if(!absent(global[i]))return 15;
char path[128];snprintf(path,sizeof path,"/proc/%s",peer);if(!absent(path))return 16;
int fd=open("/proc/self/status",O_RDONLY|O_CLOEXEC);char buf[1024];
if(fd<0 || read(fd,buf,sizeof buf)<=0)return 17;
close(fd);
fd=open("/dev/null",O_WRONLY|O_CLOEXEC);if(fd<0||write(fd,"discard",7)!=7)return 18;close(fd);
unsigned char zero[16]={1};fd=open("/dev/zero",O_RDONLY|O_CLOEXEC);
if(fd<0||read(fd,zero,sizeof zero)!=(ssize_t)sizeof zero)return 19;
close(fd);for(unsigned i=0;i<sizeof zero;i++)if(zero[i])return 20;
fd=open("/dev/urandom",O_RDONLY|O_CLOEXEC);if(fd<0||read(fd,zero,sizeof zero)!=(ssize_t)sizeof zero)return 21;close(fd);
fd=open("/dev/random",O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0)return 22;close(fd);
if(!absent("/dev/mem")||!absent("/dev/sda")||!absent("/dev/tty")||!absent("/sys")||!absent("/kf")||!absent("/var/lib/kf-judge"))return 23;
fd=open("/image-sentinel",O_WRONLY|O_TRUNC|O_CLOEXEC);if(fd>=0||errno!=EROFS)return 25;
if(!absent("/proc/self/root/../../kf")||!absent("/proc/self/cwd/../../sys")||!absent("/escape-image/outside-sentinel"))return 26;
d=opendir("/proc/self/fd");if(!d)return 27;int scanner=dirfd(d);
while((e=readdir(d))) {
if(e->d_name[0]=='.'||atoi(e->d_name)==scanner)continue;
snprintf(path,sizeof path,"/proc/self/fd/%d",atoi(e->d_name));
struct stat st;if(stat(path,&st)||S_ISDIR(st.st_mode))return 28;
}
closedir(d);return 0;
}
int main(int argc,char **argv) {
alarm(15);if(argc!=3)return 2;
for(int i=1;i<3;i++)for(char *p=argv[i];*p;p++)if(*p<'0'||*p>'9')return 2;
char path[128];snprintf(path,sizeof path,"/proc/%s/ns/mnt",argv[1]);int mnt=open(path,O_RDONLY|O_CLOEXEC);
snprintf(path,sizeof path,"/proc/%s/ns/pid",argv[1]);int pidns=open(path,O_RDONLY|O_CLOEXEC);
snprintf(path,sizeof path,"/proc/%s/root",argv[1]);int root=open(path,O_RDONLY|O_DIRECTORY|O_CLOEXEC);
if(mnt<0||pidns<0||root<0||setns(mnt,CLONE_NEWNS)||setns(pidns,CLONE_NEWPID))return 3;
pid_t child=fork();if(child==0)_exit(probe(mnt,pidns,root,argv[2]));
close(mnt);close(pidns);close(root);int status=0;
if(child<0||waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status)){fprintf(stderr,"proc/dev probe status=%d\n",status);return 4;}
puts("KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1");return 0;
}
+123
View File
@@ -0,0 +1,123 @@
#![cfg(target_os = "linux")]
//! Real restricted proc/dev verification, only in an independently capped VM.
mod support;
use kf_sandbox::launcher::proc_dev::RestrictedRootTarget;
use kf_sandbox::outcome::ExecutionStage;
use std::fs;
use std::path::Path;
use std::process::Command;
use support::{Root, binary, children, spec};
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn restricted_proc_devices_and_root_boundaries_are_real() {
let root = Root::new();
let before = fs::read_to_string("/proc/self/mountinfo").unwrap();
let one =
RestrictedRootTarget::prepare(&root.validated(), &spec("proc-one", 64 << 20, 8), &binary())
.unwrap();
let two =
RestrictedRootTarget::prepare(&root.validated(), &spec("proc-two", 64 << 20, 8), &binary())
.unwrap();
let a = format!("/proc/{}/root", one.root().init_pid());
let b = format!("/proc/{}/root", two.root().init_pid());
fs::write(format!("{b}/work/peer-only"), "isolated peer").unwrap();
assert!(!Path::new(&format!("{a}/work/peer-only")).exists());
let output = Command::new("/kf/bin/proc-dev-probe")
.arg(one.root().init_pid().to_string())
.arg(two.root().init_pid().to_string())
.env_clear()
.output()
.unwrap();
assert!(
output.status.success(),
"fixed proc/dev probe: {:?} {:?}",
output.stdout,
output.stderr
);
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1"
);
for target in [&one, &two] {
let info =
fs::read_to_string(format!("/proc/{}/mountinfo", target.root().init_pid())).unwrap();
assert_eq!(
info.lines().count(),
5,
"only root/work/tmp/proc/dev: {info}"
);
assert!(!info.contains(" shared:") && !info.contains(" master:"));
for pid in [target.root().init_pid(), target.root().bootstrap_pid()] {
for fd in fs::read_dir(format!("/proc/{pid}/fd")).unwrap() {
assert!(!fs::metadata(fd.unwrap().path()).unwrap().is_dir());
}
}
}
assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), before);
for (target, id) in [(one, "proc-one"), (two, "proc-two")] {
let group = root.path.join(target.root().cgroup().name());
let error = target.refuse_remaining_isolation();
assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem);
assert!(error.cleanup_failure.is_none());
assert!(!group.exists());
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
}
println!(
"KF_PROC_DEV_PROOF fresh_pid_proc=1 readonly=1 fixed_devices=1 init_and_bootstrap_no_directory_fds=1 peer_isolated=1 host_mounts_unchanged=1 refusal=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn unexpected_inherited_file_and_directory_fds_refuse_initialization() {
let root = Root::new();
let before = children();
for (path, id) in [
("/image-outside-sentinel", "proc-file-fd"),
("/var/lib/kf-judge/tasks", "proc-dir-fd"),
] {
let leak = fs::File::open(path).unwrap();
rustix::io::fcntl_setfd(&leak, rustix::io::FdFlags::empty()).unwrap();
let Err(error) =
RestrictedRootTarget::prepare(&root.validated(), &spec(id, 64 << 20, 8), &binary())
else {
panic!("unexpected inherited FD must fail closed");
};
assert_eq!(error.failure.stage, ExecutionStage::Preparation);
assert!(error.cleanup_failure.is_none());
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
assert!(!root.path.join(format!("kf-{id}")).exists());
drop(leak);
}
assert_eq!(children(), before);
println!(
"KF_PROC_DEV_FD_PROOF unknown_file_and_directory_rejected=1 no_namespace_started=1 task_removed=1 child_reaped=1"
);
}
#[test]
#[ignore = "requires independent VM and fixed mknodat-denying test launcher"]
fn kernel_mknod_failure_rolls_back_proc_dev_and_root() {
let root = Root::new();
assert_eq!(std::env::var("KF_SANDBOX_DENY_MKNOD").as_deref(), Ok("1"));
let before = children();
let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap();
let Err(error) = RestrictedRootTarget::prepare(
&root.validated(),
&spec("proc-mknod-fail", 64 << 20, 8),
&binary(),
) else {
panic!("kernel mknodat denial must fail root stage");
};
assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem);
assert_eq!(error.failure.errno, Some(1));
assert!(error.cleanup_failure.is_none());
assert_eq!(children(), before);
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-proc-mknod-fail").exists());
assert!(!root.path.join("kf-proc-mknod-fail").exists());
assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), mounts);
println!(
"KF_PROC_DEV_FAILURE_PROOF errno=1 partial_proc_and_dev_cleanup=1 task_root_removed=1 bootstrap_reaped=1 host_mounts_unchanged=1"
);
}