crates/heimdall-seccomp/tests/kernel.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Kernel tests: real filters, installed on a child that is this very test
//! binary run as a tiny worker (`kernel worker <what>`). Heimdall is a
//! defensive sandbox; each worker attacks only its own filter.
//!
//! They need seccomp with USER_NOTIF (>= 5.9 for ADDFD, 5.14 for ADDFD_SEND)
//! and run only with `HEIMDALL_KERNEL_TESTS=1` (scripts/seccomp-tests.sh
//! runs them in a privileged rootless podman container). `harness = false`:
//! the worker must not start libtest's threads.
use std::io::Read;
use std::os::fd::AsFd;
use std::path::PathBuf;
use std::time::Duration;
use heimdall_seccomp::bpf::{Mode, TestBox, TEST_BOX_ENV};
use heimdall_seccomp::launch::{Exit, Launch, Launched, Stdio, EXEC_FD, HANDOFF_FD};
use heimdall_seccomp::notif::{AddFd, Listener, Readiness, Response};
use heimdall_seccomp::{Action, Arch, ArgCmp, Libc, Profile, Rule};
// ---------------------------------------------------------------- worker
fn errno() -> i32 {
std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
}
fn fail_code(e: i32) -> i32 {
100 + e.min(100)
}
fn worker(what: &str, arg: Option<&str>) -> i32 {
// SAFETY: plain syscalls on local data.
unsafe {
match what {
"getpid" => {
libc::syscall(libc::SYS_getpid);
0
}
"sysinfo" => {
let mut si: libc::sysinfo = std::mem::zeroed();
if libc::sysinfo(&mut si) == 0 {
0
} else {
fail_code(errno())
}
}
"x32-getpid" => {
libc::syscall(0x4000_0000 | libc::SYS_getpid);
0
}
"open-read" | "open-write" => {
let p = std::ffi::CString::new(arg.unwrap()).unwrap();
let flags = if what == "open-read" {
libc::O_RDONLY | libc::O_CLOEXEC
} else {
libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC
};
let fd = libc::open(p.as_ptr(), flags, 0o600);
if fd >= 0 {
0
} else {
fail_code(errno())
}
}
"getppid-is" => {
let want: i64 = arg.unwrap().parse().unwrap();
let r = libc::syscall(libc::SYS_getppid);
if r == want {
0
} else {
eprintln!("getppid gave {r}, wanted {want}");
1
}
}
"getppid-errno" => {
let want: i32 = arg.unwrap().parse().unwrap();
let r = libc::syscall(libc::SYS_getppid);
if r == -1 && errno() == want {
0
} else {
eprintln!("getppid gave {r} errno {}", errno());
1
}
}
"socket-read" => {
let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0);
if fd < 0 {
return fail_code(errno());
}
let mut buf = [0u8; 64];
let n = libc::read(fd, buf.as_mut_ptr().cast(), buf.len());
if n > 0 && &buf[..n as usize] == arg.unwrap().as_bytes() {
0
} else {
eprintln!("read {n} bytes: {:?}", &buf[..n.max(0) as usize]);
1
}
}
"exec-true" => {
let p = c"/bin/true";
let argv = [p.as_ptr(), std::ptr::null()];
libc::execv(p.as_ptr(), argv.as_ptr());
fail_code(errno())
}
"prelude-fds-closed" => {
let a = libc::fcntl(EXEC_FD, libc::F_GETFD);
let b = libc::fcntl(HANDOFF_FD, libc::F_GETFD);
if a == -1 && b == -1 {
0
} else {
eprintln!("EXEC_FD {a}, HANDOFF_FD {b}");
1
}
}
_ => 99,
}
}
}
// ---------------------------------------------------------------- helpers
fn me() -> PathBuf {
std::env::current_exe().unwrap()
}
fn arch() -> Arch {
Arch::native().expect("a supported arch")
}
/// Every call of the arch allowed, except `deny` (no rule) and `extra`
/// rules put first.
fn all_except(deny: &[&str], extra: Vec<Rule>) -> Profile {
let mut rules = extra;
for (n, _) in arch().syscalls() {
if !deny.contains(n) && !rules.iter().any(|r| r.syscall == *n) {
rules.push(Rule::bare(n, Action::Allow));
}
}
Profile {
workload: "kernel-test-worker".into(),
arch: arch(),
libc: Libc::Glibc,
rules,
}
}
fn worker_launch(what: &str, arg: Option<&str>) -> Launch {
let mut l = Launch::new(me())
.arg("worker")
.arg(what)
.stdin(Stdio::Null)
.stdout(Stdio::Null);
if let Some(a) = arg {
l = l.arg(a);
}
l
}
fn run(what: &str, arg: Option<&str>, p: &Profile, mode: &Mode) -> Exit {
let mut c = worker_launch(what, arg).profile(p, mode).spawn().unwrap();
c.wait().unwrap()
}
fn test_box() -> TestBox {
std::env::set_var(TEST_BOX_ENV, "1");
let t = TestBox::from_env().unwrap();
std::env::remove_var(TEST_BOX_ENV);
t
}
fn notif_child(what: &str, arg: Option<&str>, p: &Profile) -> (Launched, Listener) {
let mut c = worker_launch(what, arg)
.profile(p, &Mode::Enforce)
.listener(true)
.spawn()
.unwrap();
let l = c.take_listener().expect("a listener");
(c, l)
}
fn getppid_notified() -> Profile {
all_except(&[], vec![Rule::bare("getppid", Action::UserNotif)])
}
/// The kernel's audit record for a seccomp kill of `pid`, when /dev/kmsg is
/// readable and the kernel audits (best effort; printed, not asserted).
fn kernel_audit_record(pid: i32) -> Option<String> {
let mut f = std::fs::OpenOptions::new()
.read(true)
.custom_flags_nonblock()
.open("/dev/kmsg")
.ok()?;
let mut all = Vec::new();
let mut buf = vec![0u8; 8192];
loop {
match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => all.extend_from_slice(&buf[..n]),
Err(e) if e.raw_os_error() == Some(libc::EPIPE) => continue,
Err(_) => break,
}
}
let text = String::from_utf8_lossy(&all);
let needle = format!(" pid={pid} ");
text.lines()
.rev()
.find(|l| l.contains("type=1326") && l.contains(&needle))
.map(str::to_string)
}
trait NonBlock {
fn custom_flags_nonblock(&mut self) -> &mut Self;
}
impl NonBlock for std::fs::OpenOptions {
fn custom_flags_nonblock(&mut self) -> &mut Self {
use std::os::unix::fs::OpenOptionsExt;
self.custom_flags(libc::O_NONBLOCK)
}
}
// ---------------------------------------------------------------- tests
fn enforce_kills_a_call_outside_the_profile_with_sigsys_and_the_kill_is_reported() {
let p = all_except(&["sysinfo"], vec![]);
let mut c = worker_launch("sysinfo", None)
.profile(&p, &Mode::Enforce)
.spawn()
.unwrap();
let pid = c.pid();
let e = c.wait().unwrap();
assert!(e.killed_by_seccomp(), "{e}");
assert_eq!(e.to_string(), "killed by seccomp (SIGSYS)");
match kernel_audit_record(pid) {
Some(r) => {
let nr = arch().syscall_nr("sysinfo").unwrap();
assert!(r.contains(&format!("syscall={nr} ")), "{r}");
eprintln!(" kernel audit record: {r}");
}
None => {
eprintln!(" no kernel audit record visible here (audit off or /dev/kmsg closed)")
}
}
}
fn enforce_runs_the_calls_inside_the_profile() {
let p = all_except(&["sysinfo"], vec![]);
assert_eq!(run("getpid", None, &p, &Mode::Enforce), Exit::Code(0));
}
fn audit_mode_logs_and_lets_an_unknown_call_run() {
let p = all_except(&["sysinfo"], vec![]);
assert_eq!(
run("sysinfo", None, &p, &Mode::Audit(test_box())),
Exit::Code(0)
);
}
fn an_x32_call_is_killed_even_in_audit_mode() {
if arch() != Arch::X86_64 {
return;
}
let p = all_except(&[], vec![]);
let e = run("x32-getpid", None, &p, &Mode::Audit(test_box()));
assert!(e.killed_by_seccomp(), "{e}");
}
fn an_errno_rule_fails_the_call_without_running_it() {
let p = all_except(
&[],
vec![Rule::bare("sysinfo", Action::Errno(libc::EPERM as u16))],
);
assert_eq!(
run("sysinfo", None, &p, &Mode::Enforce),
Exit::Code(fail_code(libc::EPERM))
);
}
fn an_openat_flag_rule_lets_a_read_open_and_kills_a_write_open() {
let dir = std::env::temp_dir().join(format!("heim-seccomp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("f");
std::fs::write(&f, b"x").unwrap();
let ro = (libc::O_CLOEXEC
| libc::O_LARGEFILE
| libc::O_NOCTTY
| libc::O_NONBLOCK
| libc::O_DIRECTORY) as u32;
let p = all_except(
&["open", "creat", "openat2"],
vec![Rule {
syscall: "openat".into(),
action: Action::Allow,
when: vec![vec![ArgCmp::only_bits32(2, ro)]],
}],
);
let fs = f.to_str().unwrap();
assert_eq!(
run("open-read", Some(fs), &p, &Mode::Enforce),
Exit::Code(0)
);
let e = run("open-write", Some(fs), &p, &Mode::Enforce);
assert!(e.killed_by_seccomp(), "{e}");
assert_eq!(std::fs::read(&f).unwrap(), b"x", "the write open never ran");
let _ = std::fs::remove_dir_all(&dir);
}
fn exec_from_the_worker_is_killed_even_though_the_launcher_execs() {
let p = all_except(&["execve", "execveat"], vec![]);
let e = run("exec-true", None, &p, &Mode::Enforce);
assert!(e.killed_by_seccomp(), "{e}");
}
fn the_prelude_fds_are_closed_in_the_worker() {
let p = all_except(&[], vec![]);
assert_eq!(
run("prelude-fds-closed", None, &p, &Mode::Enforce),
Exit::Code(0)
);
let (mut c, l) = notif_child("prelude-fds-closed", None, &all_except(&[], vec![]));
drop(l);
assert_eq!(c.wait().unwrap(), Exit::Code(0));
}
fn kernel_notif_sizes_match_this_crate() {
Listener::check_kernel().unwrap();
}
fn user_notif_value_answers_the_call_without_running_it() {
let (mut c, l) = notif_child("getppid-is", Some("4242"), &getppid_notified());
let n = l.recv().unwrap();
assert_eq!(n.data.nr as i64, libc::SYS_getppid);
assert_eq!(n.data.arch, arch().audit_arch());
assert_eq!(n.pid as i32, c.pid());
assert!(l.id_valid(n.id).unwrap());
l.respond(n.id, Response::Value(4242)).unwrap();
assert_eq!(c.wait().unwrap(), Exit::Code(0));
}
fn user_notif_errno_fails_the_call() {
let (mut c, l) = notif_child(
"getppid-errno",
Some(&libc::EACCES.to_string()),
&getppid_notified(),
);
let n = l.recv().unwrap();
l.respond(n.id, Response::Errno(libc::EACCES)).unwrap();
assert_eq!(c.wait().unwrap(), Exit::Code(0));
}
fn user_notif_continue_runs_the_real_call() {
let me = std::process::id().to_string();
let (mut c, l) = notif_child("getppid-is", Some(&me), &getppid_notified());
let n = l.recv().unwrap();
l.respond(n.id, Response::Continue).unwrap();
assert_eq!(c.wait().unwrap(), Exit::Code(0));
}
fn add_fd_send_injects_a_file_the_supervisor_opened() {
let dir = std::env::temp_dir().join(format!("heim-addfd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("brokered");
std::fs::write(&f, b"brokered").unwrap();
let p = all_except(&[], vec![Rule::bare("socket", Action::UserNotif)]);
let (mut c, l) = notif_child("socket-read", Some("brokered"), &p);
let n = l.recv().unwrap();
assert_eq!(n.data.nr as i64, libc::SYS_socket);
assert_eq!(n.data.args[0], libc::AF_INET as u64);
let file = std::fs::File::open(&f).unwrap();
let fd = l
.add_fd(
n.id,
file.as_fd(),
AddFd {
cloexec: true,
..AddFd::send()
},
)
.unwrap();
assert!(fd >= 0);
assert_eq!(c.wait().unwrap(), Exit::Code(0));
let _ = std::fs::remove_dir_all(&dir);
}
fn id_valid_is_false_once_the_target_died() {
let (mut c, l) = notif_child("getppid-is", Some("1"), &getppid_notified());
let n = l.recv().unwrap();
c.signal(libc::SIGKILL).unwrap();
let e = c.wait().unwrap();
assert_eq!(
e,
Exit::Signal {
signal: libc::SIGKILL,
core: false
}
);
assert!(!l.id_valid(n.id).unwrap());
let r = l.respond(n.id, Response::Continue).unwrap_err();
assert_eq!(r.raw_os_error(), Some(libc::ENOENT));
}
fn the_listener_hangs_up_once_the_target_exited() {
let (mut c, l) = notif_child("getpid", None, &getppid_notified());
assert_eq!(c.wait().unwrap(), Exit::Code(0));
assert_eq!(
l.wait(Some(Duration::from_secs(5))).unwrap(),
Readiness::Hangup
);
}
fn a_missing_program_is_an_error_from_spawn() {
let p = all_except(&[], vec![]);
let e = Launch::new("/nonexistent/heimdall-worker")
.profile(&p, &Mode::Enforce)
.spawn()
.unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
}
// ---------------------------------------------------------------- runner
type Test = (&'static str, fn());
const TESTS: &[Test] = &[
(
"enforce_kills_a_call_outside_the_profile_with_sigsys_and_the_kill_is_reported",
enforce_kills_a_call_outside_the_profile_with_sigsys_and_the_kill_is_reported,
),
(
"enforce_runs_the_calls_inside_the_profile",
enforce_runs_the_calls_inside_the_profile,
),
(
"audit_mode_logs_and_lets_an_unknown_call_run",
audit_mode_logs_and_lets_an_unknown_call_run,
),
(
"an_x32_call_is_killed_even_in_audit_mode",
an_x32_call_is_killed_even_in_audit_mode,
),
(
"an_errno_rule_fails_the_call_without_running_it",
an_errno_rule_fails_the_call_without_running_it,
),
(
"an_openat_flag_rule_lets_a_read_open_and_kills_a_write_open",
an_openat_flag_rule_lets_a_read_open_and_kills_a_write_open,
),
(
"exec_from_the_worker_is_killed_even_though_the_launcher_execs",
exec_from_the_worker_is_killed_even_though_the_launcher_execs,
),
(
"the_prelude_fds_are_closed_in_the_worker",
the_prelude_fds_are_closed_in_the_worker,
),
(
"kernel_notif_sizes_match_this_crate",
kernel_notif_sizes_match_this_crate,
),
(
"user_notif_value_answers_the_call_without_running_it",
user_notif_value_answers_the_call_without_running_it,
),
(
"user_notif_errno_fails_the_call",
user_notif_errno_fails_the_call,
),
(
"user_notif_continue_runs_the_real_call",
user_notif_continue_runs_the_real_call,
),
(
"add_fd_send_injects_a_file_the_supervisor_opened",
add_fd_send_injects_a_file_the_supervisor_opened,
),
(
"id_valid_is_false_once_the_target_died",
id_valid_is_false_once_the_target_died,
),
(
"the_listener_hangs_up_once_the_target_exited",
the_listener_hangs_up_once_the_target_exited,
),
(
"a_missing_program_is_an_error_from_spawn",
a_missing_program_is_an_error_from_spawn,
),
];
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("worker") {
std::process::exit(worker(&args[2], args.get(3).map(String::as_str)));
}
if std::env::var_os("HEIMDALL_KERNEL_TESTS").is_none_or(|v| v != "1") {
println!("kernel tests skipped: set HEIMDALL_KERNEL_TESTS=1 (scripts/seccomp-tests.sh)");
return;
}
let filter: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with('-')).collect();
let mut failed = Vec::new();
let mut ran = 0;
for (name, f) in TESTS {
if !filter.is_empty() && !filter.iter().any(|x| name.contains(x.as_str())) {
continue;
}
ran += 1;
match std::panic::catch_unwind(f) {
Ok(()) => println!("test {name} ... ok"),
Err(_) => {
println!("test {name} ... FAILED");
failed.push(*name);
}
}
}
println!(
"\nkernel tests: {} passed; {} failed",
ran - failed.len(),
failed.len()
);
if !failed.is_empty() {
std::process::exit(1);
}
}