Files
agent-sandbox/src/seccomp.rs
T

160 lines
6.5 KiB
Rust

//! 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 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<RawFd, SandboxError> {
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<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) {
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<Vec<SeccompRule>, 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<Vec<SeccompRule>, BackendError> {
Ok(vec![SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Qword,
SeccompCmpOp::MaskedEq(libc::CLONE_NEWUSER as u64),
0,
)?])?])
}
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 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 <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)]
#[path = "../tests/unit/seccomp.rs"]
mod tests;