//! seccomp BPF allowlist for sandboxed processes. //! //! Derived from Podman's containers/common default profile: //! //! //! Simplifications vs upstream (intentional): //! - No capability-conditional rules (we never grant capabilities, so all //! of Podman's `caps` blocks collapse to "deny" — we just omit them). //! - No explicit-EPERM list — anything outside the allowlist on the host //! ABI returns ENOSYS via the default action; cross-ABI calls are killed //! by seccompiler's arch prologue before the rule lookup runs. //! - x86_64 and aarch64 only. //! //! Argument filters (modelled after Flatpak and Docker/Podman seccomp policies): //! - `ioctl`: blocks TIOCSTI and TIOCLINUX to prevent terminal input //! injection (CVE-2017-5226). Uses 32-bit arg comparison to avoid the //! Flatpak bypass (CVE-2019-10063). //! - `clone`: blocks CLONE_NEWUSER to prevent user-namespace privilege //! escalation. `clone3` remains fully denied (not in the allowlist) //! because BPF cannot inspect its struct argument. //! //! Default action is ENOSYS (errno 38), not EPERM. This matches Podman's //! stance and is critical for the glibc clone3 -> clone fallback path. //! //! Syscall name -> number resolution uses the `syscalls` crate. Names that //! don't exist on the host architecture (e.g. legacy 32-bit aliases like //! `_llseek`, or aarch64-only `set_tls` when building on x86_64) are silently //! skipped. This is safe: such names are unreachable via the host ABI, and //! any cross-ABI attempt to call them (e.g. int 0x80 from a 64-bit process) //! is killed by seccompiler's arch-validation prologue (SECCOMP_RET_KILL_PROCESS) //! before the syscall table is even consulted. use std::collections::BTreeMap; use std::io::{Seek, SeekFrom, Write}; use std::os::fd::{IntoRawFd, RawFd}; use std::str::FromStr; use seccompiler::{ BackendError, BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, TargetArch, sock_filter, }; use syscalls::Sysno; use crate::SandboxError; use crate::memfd; /// Syscall allowlist. Includes Podman's unconditional allow set (minus syscalls /// we deny on top, see module docs) plus arch-specific syscalls for the targets /// we support. Names absent from the host arch are skipped at filter-build time. const ALLOWED_SYSCALLS: &[&str] = &include!("seccomp_allowlist.in"); /// Build a seccomp BPF program, write it to an anonymous in-memory file, and /// return the raw fd. The fd is intentionally leaked from Rust's ownership: it /// is created without `MFD_CLOEXEC` so it survives `exec` into bwrap, which /// inherits and closes it after reading the filter. pub fn write_program_to_memfd() -> Result { let bytes = build_program_bytes()?; let mut file = memfd::create_inheritable(c"agent-sandbox-seccomp")?; file.write_all(&bytes)?; file.seek(SeekFrom::Start(0))?; // into_raw_fd consumes the File without closing the underlying fd. Ok(file.into_raw_fd()) } fn build_program_bytes() -> Result, SandboxError> { let target_arch = current_target_arch()?; let mut rules: BTreeMap> = BTreeMap::new(); for name in ALLOWED_SYSCALLS { if let Ok(sysno) = Sysno::from_str(name) { let nr = i64::from(sysno.id()); let filtered = match *name { "ioctl" => ioctl_rules(), "clone" => clone_rules(), _ => Ok(vec![]), } .map_err(|e| SandboxError::Seccomp(e.to_string()))?; rules.insert(nr, filtered); } } let filter = SeccompFilter::new( rules, SeccompAction::Errno(libc::ENOSYS as u32), SeccompAction::Allow, target_arch, ) .map_err(|e| SandboxError::Seccomp(e.to_string()))?; let program: BpfProgram = filter .try_into() .map_err(|e: BackendError| SandboxError::Seccomp(e.to_string()))?; Ok(serialize(&program)) } /// Allow ioctl except for TIOCSTI and TIOCLINUX terminal injection attacks. /// Dword (32-bit) comparison prevents the CVE-2019-10063 bypass where the /// kernel ignores the high 32 bits of the ioctl command argument. fn ioctl_rules() -> Result, BackendError> { Ok(vec![SeccompRule::new(vec![ SeccompCondition::new(1, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::TIOCSTI)?, SeccompCondition::new( 1, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::TIOCLINUX, )?, ])?]) } /// Allow clone only when CLONE_NEWUSER is not set. Creating a user namespace /// gives the sandboxed process fake root, which is the standard entry point for /// kernel privilege-escalation exploits. `clone3` is fully denied (not in the /// allowlist) because BPF cannot inspect its struct clone_args — glibc falls /// back to clone on ENOSYS. fn clone_rules() -> Result, BackendError> { Ok(vec![SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Qword, SeccompCmpOp::MaskedEq(libc::CLONE_NEWUSER as u64), 0, )?])?]) } fn current_target_arch() -> Result { match std::env::consts::ARCH { "x86_64" => Ok(TargetArch::x86_64), "aarch64" => Ok(TargetArch::aarch64), other => Err(SandboxError::SeccompUnsupportedArch(other.to_string())), } } fn serialize(program: &[sock_filter]) -> Vec { // Flatten the in-memory BpfProgram into the byte stream bwrap expects to // read from the fd passed via --seccomp. bwrap then constructs a // `struct sock_fprog` over that buffer and hands it to the kernel via // seccomp(2). A BpfProgram is &[sock_filter], where sock_filter is the // classic-BPF instruction format from : // // struct sock_filter { // __u16 code; // opcode // __u8 jt; // jump-if-true offset // __u8 jf; // jump-if-false offset // __u32 k; // generic immediate operand // }; // // Exactly 8 bytes, no padding. Native endian because producer and consumer // are the same machine — there is no cross-host serialization. let mut out = Vec::with_capacity(program.len() * 8); for insn in program { out.extend_from_slice(&insn.code.to_ne_bytes()); out.push(insn.jt); out.push(insn.jf); out.extend_from_slice(&insn.k.to_ne_bytes()); } out } #[cfg(test)] #[path = "../tests/unit/seccomp.rs"] mod tests;