Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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);
    }
}