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/bpf.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The BPF builder, checked with the interpreter against
//! `Profile::evaluate`. Heimdall is a defensive sandbox; nothing here is
//! installed in the kernel (see tests/kernel.rs for that).

use heimdall_seccomp::bpf::{build, build_rules, BuildError, Mode, TestBox, MAX_INSNS, RET_K};
use heimdall_seccomp::interp::run;
use heimdall_seccomp::notif::SeccompData;
use heimdall_seccomp::{Action, Arch, ArgCmp, Libc, Profile, Rule, Width};
use proptest::prelude::*;

fn data(arch: Arch, name: &str, args: [u64; 6]) -> SeccompData {
    SeccompData {
        nr: arch.syscall_nr(name).unwrap() as i32,
        arch: arch.audit_arch(),
        instruction_pointer: 0,
        args,
    }
}

fn act(prog: &heimdall_seccomp::Program, d: &SeccompData) -> Action {
    Action::from_ret(run(prog.insns(), d).unwrap()).unwrap()
}

fn sample(arch: Arch) -> Profile {
    Profile {
        workload: "sample".into(),
        arch,
        libc: Libc::Glibc,
        rules: vec![
            Rule::bare("read", Action::Allow),
            Rule {
                syscall: "openat".into(),
                action: Action::Allow,
                when: vec![vec![ArgCmp::only_bits32(
                    2,
                    (libc::O_CLOEXEC | libc::O_DIRECTORY) as u32,
                )]],
            },
            Rule::bare("openat", Action::UserNotif),
            Rule {
                syscall: "clone".into(),
                action: Action::Allow,
                when: vec![vec![ArgCmp::only_bits64(0, 0x3d0f00)]],
            },
            Rule::bare("clone3", Action::Errno(libc::ENOSYS as u16)),
        ],
    }
}

#[test]
fn enforce_kills_a_call_outside_the_profile() {
    let prog = build(&sample(Arch::X86_64), &Mode::Enforce).unwrap();
    assert_eq!(
        act(&prog, &data(Arch::X86_64, "write", [0; 6])),
        Action::KillProcess
    );
    assert_eq!(
        act(&prog, &data(Arch::X86_64, "read", [0; 6])),
        Action::Allow
    );
}

#[test]
fn audit_mode_needs_a_test_box_token_and_then_logs_unknown_calls() {
    std::env::remove_var(heimdall_seccomp::bpf::TEST_BOX_ENV);
    assert!(
        TestBox::from_env().is_err(),
        "audit mode without the test-box mark"
    );
    std::env::set_var(heimdall_seccomp::bpf::TEST_BOX_ENV, "1");
    let tb = TestBox::from_env().unwrap();
    std::env::remove_var(heimdall_seccomp::bpf::TEST_BOX_ENV);
    let prog = build(&sample(Arch::X86_64), &Mode::Audit(tb)).unwrap();
    assert_eq!(
        act(&prog, &data(Arch::X86_64, "write", [0; 6])),
        Action::Log
    );
}

#[test]
fn a_foreign_arch_is_killed_in_every_mode() {
    for arch in [Arch::X86_64, Arch::Aarch64] {
        let prog = build_rules(arch, &sample(arch).rules, Action::Allow).unwrap();
        let mut d = data(arch, "read", [0; 6]);
        d.arch = 0x4000_0003; // i386
        assert_eq!(act(&prog, &d), Action::KillProcess, "{arch}");
        d.arch = if arch == Arch::X86_64 {
            Arch::Aarch64.audit_arch()
        } else {
            Arch::X86_64.audit_arch()
        };
        assert_eq!(act(&prog, &d), Action::KillProcess, "{arch}");
    }
}

#[test]
fn x32_calls_are_killed_even_with_an_allow_default() {
    let prog = build_rules(Arch::X86_64, &sample(Arch::X86_64).rules, Action::Allow).unwrap();
    let mut d = data(Arch::X86_64, "read", [0; 6]);
    d.nr |= 0x4000_0000;
    assert_eq!(act(&prog, &d), Action::KillProcess);
}

#[test]
fn aarch64_has_no_x32_check_and_uses_its_own_numbers() {
    let prog = build(&sample(Arch::Aarch64), &Mode::Enforce).unwrap();
    assert_eq!(
        act(&prog, &data(Arch::Aarch64, "read", [0; 6])),
        Action::Allow
    );
    // x86_64's read is 0 = aarch64's io_setup: not allowed.
    let mut d = data(Arch::Aarch64, "read", [0; 6]);
    d.nr = 0;
    assert_eq!(act(&prog, &d), Action::KillProcess);
}

#[test]
fn rules_of_one_call_keep_their_order_in_the_program() {
    let prog = build(&sample(Arch::X86_64), &Mode::Enforce).unwrap();
    let ro = data(
        Arch::X86_64,
        "openat",
        [0, 0, libc::O_CLOEXEC as u64, 0, 0, 0],
    );
    let wr = data(
        Arch::X86_64,
        "openat",
        [0, 0, (libc::O_RDWR | libc::O_CREAT) as u64, 0, 0, 0],
    );
    assert_eq!(act(&prog, &ro), Action::Allow);
    assert_eq!(act(&prog, &wr), Action::UserNotif);
}

#[test]
fn a_64_bit_flag_field_checks_both_halves() {
    let prog = build(&sample(Arch::X86_64), &Mode::Enforce).unwrap();
    let ok = data(Arch::X86_64, "clone", [0x3d0f00, 0, 0, 0, 0, 0]);
    let high = data(Arch::X86_64, "clone", [0x3d0f00 | (1 << 33), 0, 0, 0, 0, 0]);
    let low = data(Arch::X86_64, "clone", [0x3d0f00 | 0x11, 0, 0, 0, 0, 0]);
    assert_eq!(act(&prog, &ok), Action::Allow);
    assert_eq!(act(&prog, &high), Action::KillProcess);
    assert_eq!(act(&prog, &low), Action::KillProcess);
}

#[test]
fn errno_rules_carry_their_errno() {
    let prog = build(&sample(Arch::X86_64), &Mode::Enforce).unwrap();
    let r = run(prog.insns(), &data(Arch::X86_64, "clone3", [0; 6])).unwrap();
    assert_eq!(r, 0x0005_0000 | libc::ENOSYS as u32);
}

#[test]
fn every_program_ends_in_a_ret() {
    let prog = build(&sample(Arch::X86_64), &Mode::Enforce).unwrap();
    assert_eq!(prog.insns().last().unwrap().code, RET_K);
    assert_eq!(prog.arch(), Arch::X86_64);
}

#[test]
fn a_program_over_the_kernel_limit_is_refused() {
    let alts: Vec<Vec<ArgCmp>> = (0..1400).map(|i| vec![ArgCmp::eq32(0, i)]).collect();
    let rules = vec![Rule {
        syscall: "ioctl".into(),
        action: Action::Allow,
        when: alts,
    }];
    match build_rules(Arch::X86_64, &rules, Action::KillProcess) {
        Err(BuildError::TooLong(n)) => assert!(n > MAX_INSNS),
        other => panic!("{other:?}"),
    }
}

#[test]
fn an_invalid_profile_does_not_build() {
    let rules = vec![Rule::bare("open", Action::Allow)];
    assert!(matches!(
        build_rules(Arch::Aarch64, &rules, Action::KillProcess),
        Err(BuildError::Profile(_))
    ));
}

#[test]
fn every_syscall_of_both_arches_can_be_allowed_in_one_program() {
    for arch in [Arch::X86_64, Arch::Aarch64] {
        let rules: Vec<Rule> = arch
            .syscalls()
            .iter()
            .map(|(n, _)| Rule::bare(n, Action::Allow))
            .collect();
        let prog = build_rules(arch, &rules, Action::KillProcess).unwrap();
        assert!(prog.len() <= MAX_INSNS);
        for (n, _) in arch.syscalls() {
            assert_eq!(
                act(&prog, &data(arch, n, [0; 6])),
                Action::Allow,
                "{arch} {n}"
            );
        }
    }
}

// ---- property: the program and Profile::evaluate agree everywhere ----

const NAMES: &[&str] = &[
    "read", "write", "openat", "mmap", "mprotect", "ioctl", "socket", "clone", "fcntl", "prctl",
];

fn cmp_strategy() -> impl Strategy<Value = ArgCmp> {
    (0u8..6, any::<bool>(), any::<u64>(), any::<u64>()).prop_map(|(arg, w64, mask, v)| {
        if w64 {
            ArgCmp {
                arg,
                width: Width::W64,
                mask,
                value: v & mask,
            }
        } else {
            let mask = mask & 0xffff_ffff;
            ArgCmp {
                arg,
                width: Width::W32,
                mask,
                value: v & mask,
            }
        }
    })
}

fn action_strategy() -> impl Strategy<Value = Action> {
    prop_oneof![
        Just(Action::Allow),
        Just(Action::Log),
        (1u16..200).prop_map(Action::Errno),
        Just(Action::UserNotif),
        Just(Action::Trap),
    ]
}

fn rule_strategy() -> impl Strategy<Value = Rule> {
    (
        prop::sample::select(NAMES),
        action_strategy(),
        prop::collection::vec(prop::collection::vec(cmp_strategy(), 1..4), 0..4),
    )
        .prop_map(|(n, action, when)| Rule {
            syscall: n.to_string(),
            action,
            when,
        })
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(512))]

    #[test]
    fn the_program_and_evaluate_agree_on_every_call(
        aarch in any::<bool>(),
        rules in prop::collection::vec(rule_strategy(), 0..12),
        name in prop::sample::select(NAMES),
        pick in prop::collection::vec(any::<prop::sample::Index>(), 6),
        raw in prop::array::uniform6(any::<u64>()),
        from_rules in prop::array::uniform6(any::<bool>()),
        enforce in any::<bool>(),
    ) {
        let arch = if aarch { Arch::Aarch64 } else { Arch::X86_64 };
        let default = if enforce { Action::KillProcess } else { Action::UserNotif };
        let prof = Profile { workload: "p".into(), arch, libc: Libc::Glibc, rules };
        let prog = build_rules(arch, &prof.rules, default).unwrap();
        // Argument values: often one a rule compares with (so rules match).
        let values: Vec<u64> = prof.rules.iter().flat_map(|r| r.when.iter().flatten().map(|c| c.value)).collect();
        let mut args = raw;
        for i in 0..6 {
            if from_rules[i] && !values.is_empty() {
                let v = values[pick[i].index(values.len())];
                // Sometimes with a dirty upper half (W32 must ignore it).
                args[i] = if raw[i] % 2 == 0 { v } else { v | (raw[i] & 0xffff_ffff_0000_0000) };
            }
        }
        let d = data(arch, name, args);
        let want = prof.evaluate(d.nr as u32, &args, default);
        prop_assert_eq!(act(&prog, &d), want);
    }

    #[test]
    fn unknown_numbers_get_the_default(nr in 0u32..0x4000_0000, aarch in any::<bool>()) {
        let arch = if aarch { Arch::Aarch64 } else { Arch::X86_64 };
        let prog = build(&sample(arch), &Mode::Enforce).unwrap();
        let d = SeccompData { nr: nr as i32, arch: arch.audit_arch(), instruction_pointer: 0, args: [0; 6] };
        prop_assert_eq!(act(&prog, &d), sample(arch).evaluate(nr, &[0; 6], Action::KillProcess));
    }
}