Apply a seccomp-BPF syscall allowlist by default
Derived from Podman's default profile, stripped of capability-conditional rules (we never grant capabilities), argument filters, and the explicit EPERM block. Dangerous syscalls (mount, unshare, ptrace, bpf, perf_event_open, io_uring_*, keyctl, kexec_*, ...) fall through to the default ENOSYS action, which also keeps glibc's clone3 -> clone fallback working. x86_64 and aarch64 are supported; other archs error out. Toggle with --seccomp / --no-seccomp or seccomp = <bool> in config.
This commit is contained in:
+194
@@ -0,0 +1,194 @@
|
||||
//! seccomp BPF allowlist for sandboxed processes.
|
||||
//!
|
||||
//! Derived from Podman's containers/common default profile:
|
||||
//! <https://github.com/containers/common/blob/main/pkg/seccomp/seccomp.json>
|
||||
//!
|
||||
//! 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 argument filters. `personality` stays out of the allowlist; `socket`
|
||||
//! is allowed unconditionally rather than gated on AF_VSOCK.
|
||||
//! - No explicit-EPERM list — anything outside the allowlist returns ENOSYS
|
||||
//! via the default action.
|
||||
//! - x86_64 and aarch64 only.
|
||||
//!
|
||||
//! 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 — they would just return ENOSYS anyway under the default action.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
|
||||
use std::str::FromStr;
|
||||
|
||||
use seccompiler::{
|
||||
BackendError, BpfProgram, SeccompAction, SeccompFilter, SeccompRule, TargetArch, sock_filter,
|
||||
};
|
||||
use syscalls::Sysno;
|
||||
|
||||
use crate::SandboxError;
|
||||
|
||||
/// 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<RawFd, SandboxError> {
|
||||
let bytes = build_program_bytes()?;
|
||||
|
||||
// Safety: memfd_create is a normal Linux syscall. We pass a valid C string
|
||||
// and flags=0, so the fd is created without MFD_CLOEXEC and survives exec
|
||||
// into bwrap. The name is only a debugging label (shows up in /proc/<pid>/maps).
|
||||
let raw_fd = unsafe { libc::memfd_create(c"agent-sandbox-seccomp".as_ptr(), 0) };
|
||||
if raw_fd < 0 {
|
||||
return Err(SandboxError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
// Safety: raw_fd is owned by us and currently uniquely held.
|
||||
let mut file = unsafe { std::fs::File::from_raw_fd(raw_fd) };
|
||||
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<Vec<u8>, SandboxError> {
|
||||
let target_arch = current_target_arch()?;
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
for name in ALLOWED_SYSCALLS {
|
||||
if let Ok(sysno) = Sysno::from_str(name) {
|
||||
rules.insert(i64::from(sysno.id()), vec![]);
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
fn current_target_arch() -> Result<TargetArch, SandboxError> {
|
||||
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<u8> {
|
||||
// Flatten the in-memory BpfProgram into the raw byte stream the kernel's
|
||||
// seccomp(2) interface expects. A BpfProgram is &[sock_filter], where
|
||||
// sock_filter is the classic-BPF instruction format from <linux/filter.h>:
|
||||
//
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_on_supported_arch() {
|
||||
let bytes = build_program_bytes().expect("seccomp program should build");
|
||||
assert!(!bytes.is_empty(), "serialized BPF program is empty");
|
||||
assert_eq!(bytes.len() % 8, 0, "BPF byte stream must be 8-byte aligned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_contains_essential_syscalls() {
|
||||
for needed in &[
|
||||
"read",
|
||||
"write",
|
||||
"openat",
|
||||
"close",
|
||||
"execve",
|
||||
"exit_group",
|
||||
"mmap",
|
||||
"brk",
|
||||
"clone",
|
||||
] {
|
||||
assert!(
|
||||
ALLOWED_SYSCALLS.contains(needed),
|
||||
"allowlist missing essential syscall: {needed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_excludes_dangerous_syscalls() {
|
||||
for denied in &[
|
||||
"bpf",
|
||||
"perf_event_open",
|
||||
"userfaultfd",
|
||||
"kexec_load",
|
||||
"kexec_file_load",
|
||||
"init_module",
|
||||
"finit_module",
|
||||
"delete_module",
|
||||
"mount",
|
||||
"umount",
|
||||
"umount2",
|
||||
"unshare",
|
||||
"setns",
|
||||
"pivot_root",
|
||||
"ptrace",
|
||||
"process_vm_readv",
|
||||
"process_vm_writev",
|
||||
"keyctl",
|
||||
"personality",
|
||||
"clone3",
|
||||
"io_uring_setup",
|
||||
"io_uring_register",
|
||||
"io_uring_enter",
|
||||
"fanotify_init",
|
||||
"fanotify_mark",
|
||||
"open_by_handle_at",
|
||||
"name_to_handle_at",
|
||||
"fsopen",
|
||||
"fsconfig",
|
||||
"fsmount",
|
||||
"fspick",
|
||||
"open_tree",
|
||||
"move_mount",
|
||||
"mount_setattr",
|
||||
"reboot",
|
||||
"swapon",
|
||||
"swapoff",
|
||||
] {
|
||||
assert!(
|
||||
!ALLOWED_SYSCALLS.contains(denied),
|
||||
"allowlist must not contain dangerous syscall: {denied}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user