fix(sandbox): close io_uring and sendmmsg holes in the child network filter
Port of upstream grok-build 9684fa3 (1.0.11 window) `child_net.rs`. The per-child seccomp filter matched seven socket syscalls by number and allowed everything else, so a restricted child could still reach the network through `sendmmsg` or an io_uring submission queue, and a syscall issued through another arch table (x32 on x86_64) never matched the deny list at all. - Gate on the audit arch before matching `nr` and reject x32 numbers on x86_64 (foreign-table syscalls now get EPERM instead of passing). - Add `sendmmsg`, `io_uring_setup`, `io_uring_enter`, `io_uring_register`. - Build the program once (`prebuilt_child_network_filter`) and hand the slice to the `pre_exec` installer; the old code allocated the Vec after `fork`, which is not async-signal-safe. All three callers updated. - Tests evaluate the BPF program over synthetic `seccomp_data`. Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
1 parent
b957e12370
commit
cadc46f5dc
3 files changed
+218
-77
No files matched your search
@@ -1,91 +1,130 @@
|
||||
//! Per-child seccomp network filter. No-op on non-Linux.
|
||||
|
||||
/// Install seccomp BPF filter blocking network syscalls.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod bpf {
|
||||
use libc::sock_filter;
|
||||
|
||||
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||
pub(super) const EPERM_VAL: u32 = 1;
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
|
||||
/// `seccomp_data` field offsets.
|
||||
pub(super) const OFF_NR: u32 = 0;
|
||||
pub(super) const OFF_ARCH: u32 = 4;
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0;
|
||||
|
||||
pub(super) fn stmt(code: u32, k: u32) -> sock_filter {
|
||||
sock_filter {
|
||||
code: code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
|
||||
sock_filter {
|
||||
code: code as u16,
|
||||
jt,
|
||||
jf,
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject a foreign-arch (and, on x86_64, x32) syscall number before
|
||||
/// matching `nr`, so a syscall reached through another table cannot
|
||||
/// alias past the deny list.
|
||||
pub(super) fn push_arch_nr_gate(f: &mut Vec<sock_filter>) {
|
||||
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W};
|
||||
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
f.push(jump(
|
||||
BPF_JMP | libc::BPF_JSET | BPF_K,
|
||||
X32_SYSCALL_BIT,
|
||||
0,
|
||||
1,
|
||||
));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Syscalls that open or send on a socket, including the `io_uring` and
|
||||
/// `sendmmsg` paths that bypass the classic entry points.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn child_network_blocked_syscalls() -> [u32; 11] {
|
||||
use libc::{
|
||||
SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register,
|
||||
SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto,
|
||||
};
|
||||
[
|
||||
SYS_connect as u32,
|
||||
SYS_bind as u32,
|
||||
SYS_sendto as u32,
|
||||
SYS_sendmsg as u32,
|
||||
SYS_sendmmsg as u32,
|
||||
SYS_listen as u32,
|
||||
SYS_accept as u32,
|
||||
SYS_accept4 as u32,
|
||||
SYS_io_uring_setup as u32,
|
||||
SYS_io_uring_enter as u32,
|
||||
SYS_io_uring_register as u32,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_child_network_filter() -> Vec<libc::sock_filter> {
|
||||
use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt};
|
||||
use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET};
|
||||
|
||||
let blocked = child_network_blocked_syscalls();
|
||||
let mut f = Vec::with_capacity(blocked.len() + 10);
|
||||
bpf::push_arch_nr_gate(&mut f);
|
||||
let n = blocked.len();
|
||||
for (i, &sys) in blocked.iter().enumerate() {
|
||||
let remaining = n - i - 1;
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0));
|
||||
}
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
f
|
||||
}
|
||||
|
||||
/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] {
|
||||
static FILTER: std::sync::OnceLock<Vec<libc::sock_filter>> = std::sync::OnceLock::new();
|
||||
FILTER.get_or_init(build_child_network_filter)
|
||||
}
|
||||
|
||||
/// Install the seccomp BPF filter blocking network syscalls.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
use libc::{
|
||||
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
|
||||
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
|
||||
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
|
||||
};
|
||||
|
||||
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||
// libc::EPERM
|
||||
const EPERM_VAL: u32 = 1;
|
||||
|
||||
macro_rules! bpf_stmt {
|
||||
($code:expr, $k:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! bpf_jump {
|
||||
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: $jt,
|
||||
jf: $jf,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// seccomp_data.nr offset
|
||||
const NR_OFFSET: u32 = 0;
|
||||
|
||||
let blocked_syscalls: &[i64] = &[
|
||||
SYS_connect,
|
||||
SYS_bind,
|
||||
SYS_sendto,
|
||||
SYS_sendmsg,
|
||||
SYS_listen,
|
||||
SYS_accept,
|
||||
SYS_accept4,
|
||||
];
|
||||
|
||||
let mut filter: Vec<sock_filter> = Vec::new();
|
||||
let total_checks = blocked_syscalls.len();
|
||||
|
||||
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
|
||||
|
||||
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
|
||||
let remaining = total_checks - i - 1;
|
||||
filter.push(bpf_jump!(
|
||||
BPF_JMP | BPF_JEQ | BPF_K,
|
||||
syscall,
|
||||
// match: jump to ERRNO
|
||||
remaining as u8 + 1,
|
||||
// no match: check next
|
||||
0
|
||||
));
|
||||
}
|
||||
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
|
||||
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
|
||||
|
||||
let prog = sock_fprog {
|
||||
len: filter.len() as u16,
|
||||
filter: filter.as_mut_ptr(),
|
||||
filter: filter.as_ptr().cast_mut(),
|
||||
};
|
||||
|
||||
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
|
||||
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
|
||||
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
|
||||
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
|
||||
if unsafe {
|
||||
prctl(
|
||||
PR_SET_SECCOMP,
|
||||
@@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
{
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::bpf::*;
|
||||
use super::{build_child_network_filter, child_network_blocked_syscalls};
|
||||
use libc::sock_filter;
|
||||
|
||||
/// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields.
|
||||
fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 {
|
||||
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
|
||||
let mut pc = 0usize;
|
||||
let mut a = 0u32;
|
||||
for _ in 0..filter.len().saturating_mul(2) {
|
||||
let insn = &filter[pc];
|
||||
let op = insn.code as u32;
|
||||
if op == (BPF_LD | BPF_W | BPF_ABS) {
|
||||
a = match insn.k {
|
||||
OFF_NR => nr,
|
||||
OFF_ARCH => arch,
|
||||
_ => 0,
|
||||
};
|
||||
pc += 1;
|
||||
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
|
||||
pc = if a == insn.k {
|
||||
pc + 1 + insn.jt as usize
|
||||
} else {
|
||||
pc + 1 + insn.jf as usize
|
||||
};
|
||||
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
|
||||
pc = if a & insn.k != 0 {
|
||||
pc + 1 + insn.jt as usize
|
||||
} else {
|
||||
pc + 1 + insn.jf as usize
|
||||
};
|
||||
} else if op == (BPF_RET | BPF_K) {
|
||||
return insn.k;
|
||||
} else {
|
||||
panic!("unsupported opcode {:#x} at {pc}", insn.code);
|
||||
}
|
||||
assert!(pc < filter.len(), "pc out of range");
|
||||
}
|
||||
panic!("filter did not RET");
|
||||
}
|
||||
|
||||
fn is_allow(r: u32) -> bool {
|
||||
r == SECCOMP_RET_ALLOW
|
||||
}
|
||||
|
||||
fn is_eperm(r: u32) -> bool {
|
||||
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_every_network_syscall_and_allows_read_and_socket() {
|
||||
let f = build_child_network_filter();
|
||||
for sys in child_network_blocked_syscalls() {
|
||||
assert!(
|
||||
is_eperm(eval(&f, EXPECTED_ARCH, sys)),
|
||||
"syscall {sys} must be EPERM"
|
||||
);
|
||||
}
|
||||
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32)));
|
||||
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_io_uring_and_sendmmsg() {
|
||||
let blocked = child_network_blocked_syscalls();
|
||||
for sys in [
|
||||
libc::SYS_sendmmsg,
|
||||
libc::SYS_io_uring_setup,
|
||||
libc::SYS_io_uring_enter,
|
||||
libc::SYS_io_uring_register,
|
||||
] {
|
||||
assert!(blocked.contains(&(sys as u32)), "{sys} missing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_arch_and_x32_are_denied_before_the_nr_match() {
|
||||
let f = build_child_network_filter();
|
||||
assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32)));
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert!(is_eperm(eval(
|
||||
&f,
|
||||
EXPECTED_ARCH,
|
||||
(libc::SYS_read as u32) | X32_SYSCALL_BIT
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prebuilt_filter_is_shared() {
|
||||
let a = super::prebuilt_child_network_filter();
|
||||
let b = super::prebuilt_child_network_filter();
|
||||
assert!(std::ptr::eq(a, b));
|
||||
assert_eq!(a.len(), build_child_network_filter().len());
|
||||
}
|
||||
}
|
||||
@@ -912,7 +912,10 @@ fn spawn_with_argv(
|
||||
if kigi_sandbox::should_restrict_child_network() {
|
||||
// SAFETY: single prctl syscall (async-signal-safe).
|
||||
unsafe {
|
||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
||||
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||
cmd.pre_exec(move || {
|
||||
kigi_sandbox::child_net::install_child_network_filter(filter)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -634,7 +634,8 @@ impl LocalTerminalActor {
|
||||
#[cfg(target_os = "linux")]
|
||||
if kigi_sandbox::should_restrict_child_network() {
|
||||
unsafe {
|
||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
||||
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2646,7 +2647,8 @@ fn spawn_shell_command(
|
||||
#[cfg(target_os = "linux")]
|
||||
if kigi_sandbox::should_restrict_child_network() {
|
||||
unsafe {
|
||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
||||
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
|
||||
}
|
||||
}
|
||||
cmd
|
||||
|
||||
Reference in new issue
Block a user