test(sandbox): verify complete user filesystem and descriptor boundaries

This commit is contained in:
2026-10-08 10:12:49 +08:00
parent b5db44ba16
commit ddf1e0ca68
4 changed files with 213 additions and 0 deletions
+4
View File
@@ -7,6 +7,7 @@ use crate::{
spec::{ExecutionSpec, SpecPolicy},
};
use std::{fs, io, io::Read, os::unix::fs::PermissionsExt, path::Path};
pub(crate) mod isolation;
mod normal;
mod resources;
pub(crate) fn verify_guest(require_env: bool) -> io::Result<()> {
@@ -121,6 +122,9 @@ pub fn run_case(case: &str) -> io::Result<serde_json::Value> {
if case.starts_with("resource-") {
return resources::run_case(case);
}
if case.starts_with("isolation-") {
return isolation::run_case(case);
}
let rollback = case.strip_prefix("cancel-").filter(|s| {
matches!(
*s,
@@ -0,0 +1,86 @@
//! Fixed complete-boundary probes inside the independent, bounded VM only.
use super::{Fixture, describe};
use crate::{
launcher::run::{self, MonitorMode},
spec::{ExecutionSpec, InputArtifact, SpecPolicy},
};
use std::{fs, io, os::unix::fs::symlink};
pub(super) fn run_case(case: &str) -> io::Result<serde_json::Value> {
if !matches!(case, "isolation-files" | "isolation-inherited-fd") {
return Err(io::Error::other("unknown isolation case"));
}
let fixture = Fixture::new("/kf/bin/isolation-probe")?;
let config = fixture.config()?;
let image = &config.config().image_root;
fs::write(format!("{image}/image-sentinel"), b"readonly-image")?;
fs::write("/kf/outside-sentinel", b"outside-private-root")?;
symlink("../../kf/outside-sentinel", format!("{image}/escape-image"))?;
fs::write(
format!("{}/data-001", fixture.publications),
b"protected-input",
)?;
fs::write(
format!("{}/stdin-001", fixture.publications),
b"private-stdin",
)?;
let mut request: ExecutionSpec =
serde_json::from_str(include_str!("../../tests/fixtures/spec.valid.json"))?;
request.run_id = case.into();
request.argv.push("files".into());
request.inputs.push(InputArtifact {
artifact_id: "data-001".into(),
path: "source/input".into(),
});
request.stdin_artifact = Some("stdin-001".into());
request.limits.cpu_time_us = 2_000_000;
request.limits.wall_time_us = 8_000_000;
let spec = request
.validate(&SpecPolicy::default())
.map_err(io::Error::other)?;
let root = fixture.root()?;
// The fresh privileged exec role must refuse, rather than inherit a caller
// directory capability even when it was accidentally left without CLOEXEC.
let inherited = if case == "isolation-inherited-fd" {
let fd = rustix::fs::open(
"/kf",
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY,
rustix::fs::Mode::empty(),
)?;
rustix::io::fcntl_setfd(&fd, rustix::io::FdFlags::empty())?;
Some(fd)
} else {
None
};
let result = run::execute(
&config,
&spec,
&std::env::current_exe()?,
&root,
MonitorMode::Normal,
);
drop(inherited);
fs::remove_file(format!("{image}/escape-image"))?;
if fs::read("/kf/outside-sentinel")? != b"outside-private-root"
|| fs::read(format!("{image}/image-sentinel"))? != b"readonly-image"
|| fs::read(format!("{}/data-001", fixture.publications))? != b"protected-input"
{
return Err(io::Error::other("outside or input sentinel changed"));
}
if case == "isolation-inherited-fd" {
let failure = result
.err()
.ok_or_else(|| io::Error::other("inherited directory not refused"))?;
if failure.cleanup_failure.is_some() {
return Err(io::Error::other("inherited descriptor cleanup failed"));
}
return Ok(
serde_json::json!({"case":case,"refused_before_exec":true,"failure":failure.failure,"cleanup_failure":failure.cleanup_failure,"outside_sentinels_unchanged":true}),
);
}
let completed =
result.map_err(|e| io::Error::other(format!("isolation preparation: {:?}", e.failure)))?;
let mut value = describe(case, &spec, &completed, false)?;
value["outside_sentinels_unchanged"] = true.into();
Ok(value)
}
+70
View File
@@ -0,0 +1,70 @@
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <unistd.h>
static void check(int yes, const char *why) {
if (!yes) { fprintf(stderr,"ISOLATION_FAIL %s errno=%d\n",why,errno); exit(1); }
}
static void absent(const char *path) {
int fd=open(path,O_RDONLY|O_CLOEXEC);
check(fd<0&&(errno==ENOENT||errno==EACCES||errno==EPERM),path);
}
static void files(void) {
check(getpid()==2 && getuid()>=100000 && geteuid()==getuid(),"namespace uid");
check(prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)==1 && prctl(PR_GET_SECCOMP)==2,"NNP/filter");
char cwd[128];check(getcwd(cwd,sizeof cwd)&&!strcmp(cwd,"/work"),"initial cwd");
check(!getenv("KF_SANDBOX_INDEPENDENT_VM")&&!getenv("HOME")&&!getenv("LD_PRELOAD"),"cleared environment");
const char *hidden[]={"/kf/outside-sentinel","/var/lib/kf-judge/tasks","/sys","/.old-root","/escape-image","/proc/sys","/proc/sysrq-trigger","/proc/kcore","/proc/keys","/proc/meminfo","/proc/cpuinfo","/proc/1/root","/proc/1/fd/3","/proc/1/environ","/proc/self/root/../../kf/outside-sentinel","/proc/self/cwd/../../kf/outside-sentinel","/work/stdin-001","/work/input-001"};
for(size_t i=0;i<sizeof hidden/sizeof *hidden;i++)absent(hidden[i]);
for(int fd=3;fd<64;fd++)check(fcntl(fd,F_GETFD)<0&&errno==EBADF,"only FD012 inherited");
DIR *d=opendir("/proc");check(d!=NULL,"proc listing");struct dirent *e;
while((e=readdir(d)))if(e->d_name[0]>='0'&&e->d_name[0]<='9')check(atoi(e->d_name)==2,"proc only user pid");
closedir(d);
d=opendir("/proc/self/fd");check(d!=NULL,"self FD listing");int scanner=dirfd(d);
while((e=readdir(d)))if(e->d_name[0]>='0'&&e->d_name[0]<='9')check(atoi(e->d_name)<=2||atoi(e->d_name)==scanner,"no directory/control capability");
closedir(d);
struct statvfs v;check(!statvfs("/proc",&v)&&(v.f_flag&(ST_RDONLY|ST_NOSUID|ST_NODEV|ST_NOEXEC))==(ST_RDONLY|ST_NOSUID|ST_NODEV|ST_NOEXEC),"proc mount flags");
const char *dev[]={"null","zero","random","urandom"};unsigned int minors[]={3,5,8,9};
d=opendir("/dev");check(d!=NULL,"dev listing");int n=0;
while((e=readdir(d)))if(e->d_name[0]!='.'){
int found=0;for(int i=0;i<4;i++)if(!strcmp(e->d_name,dev[i]))found=1;
check(found,"device whitelist");n++;
}
closedir(d);check(n==4,"only four devices");
for(int i=0;i<4;i++){
char path[64];snprintf(path,sizeof path,"/dev/%s",dev[i]);struct stat st;
check(!stat(path,&st)&&S_ISCHR(st.st_mode)&&major(st.st_rdev)==1&&minor(st.st_rdev)==minors[i],"device type");
int fd=open(path,O_RDWR|O_NONBLOCK|O_CLOEXEC);check(fd>=0,"device I/O open");
if(i==0)check(write(fd,"x",1)==1,"null write");
if(i==1){char zero[16];check(read(fd,zero,sizeof zero)==(ssize_t)sizeof zero,"zero read");for(int j=0;j<16;j++)check(!zero[j],"zero bytes");}
if(i==3){char random[16];check(read(fd,random,sizeof random)==(ssize_t)sizeof random,"urandom read");}
close(fd);
}
check(!statvfs("/dev",&v)&&(v.f_flag&(ST_RDONLY|ST_NOSUID|ST_NOEXEC))==(ST_RDONLY|ST_NOSUID|ST_NOEXEC),"dev readonly flags");
int fd=open("/dev/new",O_CREAT|O_WRONLY,0600);check(fd<0&&errno==EROFS,"dev entries immutable");
absent("/dev/mem");absent("/dev/sda");absent("/dev/tty");
char data[32];fd=open("/work/source/input",O_RDONLY|O_CLOEXEC);check(fd>=0&&read(fd,data,sizeof data)==15&&!memcmp(data,"protected-input",15),"bound input contents");close(fd);
check(!statvfs("/work/source/input",&v)&&(v.f_flag&(ST_RDONLY|ST_NOEXEC|ST_NOSUID|ST_NODEV))==(ST_RDONLY|ST_NOEXEC|ST_NOSUID|ST_NODEV),"data readonly noexec");
fd=open("/work/source/input",O_WRONLY|O_TRUNC|O_CLOEXEC);check(fd<0&&(errno==EROFS||errno==EACCES),"input write refused");
check(rename("/work/source/input","/work/source/stolen")<0,"input rename refused");
check(rename("/work/source","/work/stolen-dir")<0,"input ancestor rename refused");
char *args[]={"/work/source/input",NULL};char *env[]={NULL};
check(execve(args[0],args,env)<0&&errno==EACCES,"data execution refused");
fd=open("/image-sentinel",O_WRONLY|O_TRUNC|O_CLOEXEC);check(fd<0&&errno==EROFS,"image readonly");
fd=open("/work/source/result",O_CREAT|O_WRONLY|O_EXCL,0600);check(fd>=0&&write(fd,"own-output",10)==10,"sibling output permitted");close(fd);
ssize_t count=read(0,data,sizeof data);check(count==13&&!memcmp(data,"private-stdin",13)&&read(0,data,sizeof data)==0,"sealed hidden stdin EOF");
fd=open("/",O_RDONLY|O_DIRECTORY|O_CLOEXEC);check(fd>=0&&!fchdir(fd)&&!chdir("../../../../"),"root traversal");close(fd);
check(getcwd(cwd,sizeof cwd)&&!strcmp(cwd,"/"),"root cannot climb above");absent("/kf/outside-sentinel");
errno=0;check(syscall(SYS_chroot,"/")==-1&&errno==EPERM,"chroot reconstruction refused");
printf("FILES_OK proc dev input stdin fd root image\n");
}
int main(int argc,char **argv){check(argc>=2,"fixed mode");if(!strcmp(argv[1],"files")){files();return 0;}return 100;}
@@ -0,0 +1,53 @@
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
#[allow(dead_code)]
mod support;
use std::{fs, process::Command};
fn run(case: &str) -> serde_json::Value {
support::require_vm();
let output = Command::new(support::binary())
.args(["vm-case", case])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{case}: {} {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(v["outside_sentinels_unchanged"], true);
for dir in ["tasks", "identities", "captures"] {
assert_eq!(
fs::read_dir(format!("/var/lib/kf-judge/{dir}"))
.unwrap()
.count(),
0
);
}
println!("KF_ISOLATION_FILES_PROOF {v}");
v
}
#[test]
#[ignore = "independent capped VM: actual user proc/dev/input/FD/root escape boundary"]
fn full_user_filesystem_and_descriptor_boundary() {
let v = run("isolation-files");
assert_eq!(
v["outcome"],
serde_json::json!({"kind":"exited","code":0}),
"{v}"
);
assert_eq!(v["report"]["cleanup"], "complete");
assert_eq!(v["report"]["errors"], serde_json::json!([]));
let expected = "FILES_OK proc dev input stdin fd root image\n";
let hex: String = expected.bytes().map(|b| format!("{b:02x}")).collect();
assert_eq!(v["stdout_prefix_hex"], hex);
}
#[test]
#[ignore = "independent capped VM: caller directory without CLOEXEC must refuse before exec"]
fn unexpected_inherited_directory_is_refused_and_cleaned() {
let v = run("isolation-inherited-fd");
assert_eq!(v["refused_before_exec"], true);
assert!(v["cleanup_failure"].is_null());
}