Files
KF-OnlineJudge/judge/crates/sandbox/tests/artifacts_vm.rs
T

431 lines
16 KiB
Rust

#![cfg(target_os = "linux")]
//! Path substitution, special-file and mutation tests run only in the capped VM.
mod support;
use kf_sandbox::artifacts::{ArtifactRoot, SnapshotFile};
use kf_sandbox::launcher::proc_dev::RestrictedRootTarget;
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
use rustix::fd::{AsFd, AsRawFd};
use rustix::fs::{FileType, Mode, OFlags};
use std::fs;
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use support::{Root, binary, children, require_vm};
fn request(id: &str, outputs: &[&str]) -> ExecutionSpec {
let mut s = support::spec(id, 64 << 20, 8).spec().clone();
s.outputs = outputs.iter().map(|s| (*s).into()).collect();
s
}
fn validated(s: ExecutionSpec) -> ValidatedSpec {
s.validate(&SpecPolicy::default()).unwrap()
}
fn read(file: &SnapshotFile) -> Vec<u8> {
let mut bytes = Vec::new();
file.reader().unwrap().read_to_end(&mut bytes).unwrap();
bytes
}
fn descriptors() -> usize {
fs::read_dir("/proc/self/fd").unwrap().count()
}
struct Publications(PathBuf);
impl Publications {
fn new(id: &str) -> Self {
require_vm();
let path = PathBuf::from(format!("/kf/artifacts-{id}"));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
fn capability(&self) -> ArtifactRoot {
ArtifactRoot::from_directory(
rustix::fs::open(
&self.0,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap(),
)
.unwrap()
}
}
impl Drop for Publications {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn input_and_stdin_snapshots_are_sealed_and_do_not_follow_replacement() {
let publications = Publications::new("immutable");
fs::write(publications.0.join("program-001"), b"\0binary\xff").unwrap();
fs::write(publications.0.join("input-001"), []).unwrap();
let capability = publications.capability();
let before = descriptors();
let prepared = capability
.snapshot_inputs(&validated(request("input-seals", &[])))
.unwrap();
assert_eq!(prepared.files.len(), 1);
assert_eq!(prepared.files[0].path, "main");
assert!(prepared.files[0].executable);
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
assert_eq!(prepared.stdin.as_ref().unwrap().bytes(), 0);
assert!(read(prepared.stdin.as_ref().unwrap()).is_empty());
fs::rename(
publications.0.join("program-001"),
publications.0.join("old"),
)
.unwrap();
fs::write(publications.0.join("program-001"), b"replacement").unwrap();
fs::write(publications.0.join("old"), b"overwritten backing source").unwrap();
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
let seals = rustix::fs::fcntl_get_seals(&prepared.files[0].file).unwrap();
for required in [
rustix::fs::SealFlags::WRITE,
rustix::fs::SealFlags::GROW,
rustix::fs::SealFlags::SHRINK,
rustix::fs::SealFlags::SEAL,
] {
assert!(seals.contains(required));
}
assert!(
rustix::io::fcntl_getfd(&prepared.files[0].file)
.unwrap()
.contains(rustix::io::FdFlags::CLOEXEC)
);
// Even a privileged test that reopens the retained inode writable cannot
// modify/shrink/grow it. This is a real seal, beyond descriptor permissions.
let fd = rustix::fs::open(
format!(
"/proc/self/fd/{}",
prepared.files[0].file.as_fd().as_raw_fd()
),
OFlags::RDWR | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap();
assert_eq!(
rustix::io::pwrite(&fd, b"tamper", 0),
Err(rustix::io::Errno::PERM)
);
assert_eq!(rustix::fs::ftruncate(&fd, 0), Err(rustix::io::Errno::PERM));
assert_eq!(
rustix::fs::ftruncate(&fd, 100),
Err(rustix::io::Errno::PERM)
);
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
drop(fd);
drop(prepared);
assert_eq!(descriptors(), before);
println!(
"KF_ARTIFACT_INPUT_PROOF binary=1 empty_stdin_separate=1 source_replacement_safe=1 kernel_seals=1 readonly_cloexec=1 no_fd_leaks=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn input_publications_reject_links_special_files_modes_and_byte_limits() {
let publications = Publications::new("refusals");
let capability = publications.capability();
let mut s = request("input-refusals", &[]);
s.stdin_artifact = None;
s.limits.file_bytes = 4;
let spec = validated(s);
let candidate = publications.0.join("program-001");
let before = descriptors();
for variant in 0..10 {
match variant {
0 => symlink("/image-outside-sentinel", &candidate).unwrap(),
1 => {
fs::write(publications.0.join("other"), b"ok").unwrap();
fs::hard_link(publications.0.join("other"), &candidate).unwrap();
}
2 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::Fifo,
Mode::from_raw_mode(0o600),
0,
)
.unwrap(),
3 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::CharacterDevice,
Mode::from_raw_mode(0o600),
rustix::fs::makedev(1, 3),
)
.unwrap(),
4 => fs::create_dir(&candidate).unwrap(),
5 => {
fs::write(&candidate, b"ok").unwrap();
fs::set_permissions(&candidate, fs::Permissions::from_mode(0o666)).unwrap();
}
6 => {
fs::write(&candidate, b"ok").unwrap();
rustix::fs::chown(
&candidate,
Some(rustix::process::Uid::from_raw(100000)),
None,
)
.unwrap();
}
7 => fs::write(&candidate, b"large").unwrap(),
8 => {
let _listener = std::os::unix::net::UnixListener::bind(&candidate).unwrap();
}
9 => {
fs::write(&candidate, b"ok").unwrap();
fs::set_permissions(&candidate, fs::Permissions::from_mode(0o4755)).unwrap();
}
_ => unreachable!(),
}
assert!(
capability.snapshot_inputs(&spec).is_err(),
"variant {variant}"
);
if variant == 4 {
fs::remove_dir(&candidate).unwrap();
} else {
fs::remove_file(&candidate).unwrap();
}
assert_eq!(descriptors(), before);
}
fs::set_permissions(&publications.0, fs::Permissions::from_mode(0o777)).unwrap();
assert!(capability.snapshot_inputs(&spec).is_err());
let directory = rustix::fs::open(
&publications.0,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap();
assert!(ArtifactRoot::from_directory(directory).is_err());
fs::set_permissions(&publications.0, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(&candidate, [0_u8; 2048]).unwrap();
let mut s = request("input-total", &[]);
s.stdin_artifact = None;
s.limits.file_bytes = 2048;
s.limits.workdir_bytes = 8192;
for n in 0..4 {
s.inputs.push(kf_sandbox::spec::InputArtifact {
artifact_id: "program-001".into(),
path: format!("copy{n}"),
});
}
assert!(capability.snapshot_inputs(&validated(s)).is_err());
assert_eq!(descriptors(), before);
println!(
"KF_ARTIFACT_REFUSAL_PROOF symlink=1 hardlink=1 fifo=1 device=1 socket=1 setuid=1 directory=1 mode=1 owner=1 oversize=1 total_limit=1 partial_fd_cleanup=1 untrusted_root=1 changed_root_authority=1 no_fd_leaks=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn declared_outputs_use_the_frozen_work_root_and_enforce_file_and_batch_limits() {
let root = Root::new();
let before = children();
let mut s = request("artifact-export", &["nested/binary", "empty"]);
s.limits.file_bytes = 32 << 10;
s.limits.workdir_bytes = 64 << 10;
let target =
RestrictedRootTarget::prepare(&root.validated(), &validated(s), &binary()).unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
fs::create_dir(work.join("nested")).unwrap();
fs::write(work.join("nested/binary"), b"\0output\xff").unwrap();
fs::write(work.join("empty"), []).unwrap();
fs::write(work.join("undeclared"), b"not exported").unwrap();
let fds = descriptors();
let outputs = target.root().snapshot_outputs().unwrap();
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0].path, "nested/binary");
assert_eq!(read(&outputs[0].file), b"\0output\xff");
assert_eq!(outputs[1].file.bytes(), 0);
fs::write(work.join("nested/binary"), b"subsequent change").unwrap();
assert_eq!(read(&outputs[0].file), b"\0output\xff");
drop(outputs);
assert_eq!(descriptors(), fds);
fs::File::create(work.join("nested/binary"))
.unwrap()
.set_len(32769)
.unwrap();
assert!(target.root().snapshot_outputs().is_err());
assert_eq!(descriptors(), fds);
fs::File::create(work.join("nested/binary"))
.unwrap()
.set_len(32768)
.unwrap();
fs::File::create(work.join("empty"))
.unwrap()
.set_len(32768)
.unwrap();
let outputs = target.root().snapshot_outputs().unwrap();
assert_eq!(outputs.iter().map(|o| o.file.bytes()).sum::<u64>(), 65536);
drop(outputs);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
assert_eq!(children(), before);
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-artifact-export").exists());
let mut s = request("artifact-total", &["one", "two", "three"]);
s.limits.file_bytes = 32768;
s.limits.workdir_bytes = 65536;
let target =
RestrictedRootTarget::prepare(&root.validated(), &validated(s), &binary()).unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
for name in ["one", "two", "three"] {
fs::File::create(work.join(name))
.unwrap()
.set_len(32768)
.unwrap();
}
let fds = descriptors();
assert!(
target.root().snapshot_outputs().is_err(),
"aggregate sparse-file logical bytes"
);
assert_eq!(descriptors(), fds);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
assert_eq!(children(), before);
println!(
"KF_ARTIFACT_EXPORT_PROOF binary=1 empty=1 declared_only=1 private_work=1 immutable_snapshot=1 file_limit=1 total_limit=1 sparse_bytes_counted=1 partial_fd_cleanup=1 task_cleanup=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn output_resolution_rejects_links_special_files_submounts_and_path_swaps() {
let root = Root::new();
let target = RestrictedRootTarget::prepare(
&root.validated(),
&validated(request("artifact-paths", &["nested/value"])),
&binary(),
)
.unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
fs::create_dir(work.join("nested")).unwrap();
let candidate = work.join("nested/value");
let before = descriptors();
for variant in 0..6 {
match variant {
0 => symlink("/image-outside-sentinel", &candidate).unwrap(),
1 => {
fs::write(work.join("other"), b"hardlinked").unwrap();
fs::hard_link(work.join("other"), &candidate).unwrap();
}
2 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::Fifo,
Mode::from_raw_mode(0o600),
0,
)
.unwrap(),
3 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::CharacterDevice,
Mode::from_raw_mode(0o600),
rustix::fs::makedev(1, 3),
)
.unwrap(),
4 => fs::create_dir(&candidate).unwrap(),
5 => symlink("/proc/self/fd/0", &candidate).unwrap(),
_ => unreachable!(),
}
assert!(
target.root().snapshot_outputs().is_err(),
"variant {variant}"
);
if variant == 4 {
fs::remove_dir(&candidate).unwrap();
} else {
fs::remove_file(&candidate).unwrap();
}
assert_eq!(descriptors(), before);
}
fs::remove_dir(work.join("nested")).unwrap();
symlink("/kf", work.join("nested")).unwrap();
assert!(target.root().snapshot_outputs().is_err());
fs::remove_file(work.join("nested")).unwrap();
fs::create_dir(work.join("nested")).unwrap();
fs::write(&candidate, b"inside-only").unwrap();
// A private mount placed by the trusted VM test is rejected even when its
// file has the same inode type. User mount permissions remain unimplemented.
let probe = std::process::Command::new("/kf/bin/artifact-mount-probe")
.arg(target.root().init_pid().to_string())
.output()
.unwrap();
assert!(probe.status.success(), "{:?}", probe.stderr);
assert!(target.root().snapshot_outputs().is_err());
let probe = std::process::Command::new("/kf/bin/artifact-mount-probe")
.arg(target.root().init_pid().to_string())
.arg("undo")
.output()
.unwrap();
assert!(probe.status.success());
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let directory = work.clone();
let attacker = std::thread::spawn(move || {
while !flag.load(Ordering::Relaxed) {
fs::rename(directory.join("nested"), directory.join("held")).unwrap();
symlink("/kf", directory.join("nested")).unwrap();
fs::remove_file(directory.join("nested")).unwrap();
fs::rename(directory.join("held"), directory.join("nested")).unwrap();
}
});
let mut accepted = 0;
let mut rejected = 0;
for _ in 0..200 {
match target.root().snapshot_outputs() {
Ok(outputs) => {
assert_eq!(read(&outputs[0].file), b"inside-only");
accepted += 1;
}
Err(_) => rejected += 1,
}
}
stop.store(true, Ordering::Relaxed);
attacker.join().unwrap();
assert_eq!(accepted + rejected, 200);
assert_eq!(descriptors(), before);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
for invalid in [
"../outside",
"/outside",
"nested/../outside",
"nested//value",
"nested/./value",
] {
assert!(
request("artifact-lexical", &[invalid])
.validate(&SpecPolicy::default())
.is_err()
);
}
let too_many: Vec<String> = (0..17).map(|n| format!("file{n}")).collect();
let mut s = request("artifact-count", &[]);
s.outputs = too_many;
assert!(s.validate(&SpecPolicy::default()).is_err());
println!(
"KF_ARTIFACT_PATH_PROOF symlink=1 hardlink=1 fifo=1 device=1 directory=1 magiclink=1 ancestor_symlink=1 submount=1 traversal=1 count=1 rename_race_attempts=200 accepted={accepted} rejected={rejected} outside_content_absent=1 no_fd_leaks=1"
);
}