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