160 lines
6.5 KiB
Rust
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;
|