mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 17:02:08 -06:00
## Stack The Python cells work is split by layer. It merges in this order, each with a merge commit rather than a squash. Nobody can switch Python on before #3481. 1. #3475 depends on `main` 2. #3476 depends on `main` **(this pull request)** 3. #3477 depends on [#3475](https://github.com/storytold/gridcraft/pull/3475) and [#3476](https://github.com/storytold/gridcraft/pull/3476) 4. #3478 depends on [#3477](https://github.com/storytold/gridcraft/pull/3477) 5. #3479 depends on [#3478](https://github.com/storytold/gridcraft/pull/3478) 6. #3480 depends on [#3479](https://github.com/storytold/gridcraft/pull/3479) 7. #3481 depends on [#3480](https://github.com/storytold/gridcraft/pull/3480) ## Summary `crates/sandbox` runs a program that can read only the paths its policy lists, write nothing except `/dev/null`, start no process, and use no network. An application re-executes itself with a launcher flag, closes every inherited descriptor above standard error, applies the confinement, checks that it holds, and only then runs the program. If any of that fails, the program does not run. ## Changes - **`crates/sandbox`:** the policy, the Linux backend (Landlock, and seccomp that denies all sockets, with an x32 filter), the macOS backend (`/usr/bin/sandbox-exec` with a deny-default profile), the probes and a canary. - **`apps/gridcraft`, `apps/gridcraft-cli`:** the launcher flag comes first in `main`, before any window or log. - **`gridcraft-cli sandbox-check`:** says whether this system confines programs, and can run a confined self-test. - **`docs/sandbox.md`:** what is refused and how. - **`nix`:** a new dependency on Linux and macOS, for closing descriptors without `unsafe`. ## Compatibility Windows and FreeBSD have no backend here. There the crate reports that it can't confine, and nothing that needs it runs. On Linux a launch needs Landlock ABI 3, which is Linux 6.2 and later, so an older kernel is treated the same way: this excludes, for example, Debian 12's kernel (6.1) and Ubuntu 22.04's original kernel (5.15). Nothing changes for a process started without the launcher flag. ## Review fixes - **File metadata on Linux.** Landlock has no right for a file's permissions, owner, extended attributes, times or flags, and the seccomp filter allows what it doesn't name, so a confined program could change them on any file its user owns, outside the policy too. The filter now refuses the `chmod`, `chown`, `setxattr`, `removexattr` and timestamp families with their `*at` variants, by path and by descriptor, and any `ioctl` whose number says it hands the kernel data, as `FS_IOC_SETFLAGS` and `FS_IOC_FSSETXATTR` do on a descriptor opened only to read. A filter allows what it doesn't name, so a call a later kernel adds is allowed until it is named; the newest named is `file_setattr` (Linux 6.17). - **Truncation on Linux.** Landlock covers truncation only from ABI 3, so on ABI 1 or 2 `os.truncate(path, 0)` emptied any file its user can write, and `open` with `O_RDONLY | O_TRUNC` emptied one it may read. A launch now requires that right. A filter can't take its place: `openat2` hides its flags from seccomp, and refusing `O_TRUNC` would refuse opening `/dev/null` to write, as `open(os.devnull, "w")` does. - **Standard streams.** They stay open with the rights they were opened with, so a stream that is a file can be written and truncated, and one that is a socket can still send. The page and `launch_command` say so, and that a parent gives the program pipes. - **The enforcement check, again.** It now also tries to change the canary's permissions and to truncate it, asking for what it already has, so nothing changes if one works. - **Other processes on Linux.** On a kernel without Landlock's signal scope, a confined program could signal any process of the same user, and `kill(-1, 9)` would reach them all. The seccomp filter now refuses signals to any process but the program itself, whose ID `exec` keeps; changes to the limits, priority or scheduling of any other process; `pidfd_open`, `pidfd_send_signal`, `kcmp`, `get_robust_list`, `migrate_pages` and `move_pages`; every System V shared memory, message queue and semaphore call and every POSIX message queue call, which no path rule covers; and `syslog`. - **The terminal on Linux.** A program started from a shell could type into its terminal. `TIOCSTI` and `TIOCLINUX` are refused, compared in 32 bits as the kernel reads them, so high bits set on the argument don't get past the rule. - **The enforcement check.** It now also tries signal 0 on the launcher's parent, which delivers nothing, so `sandbox-check` reports a gap rather than assuming none. The launcher tries the same signal before it confines itself and refuses if that fails, and the check refuses if its parent has changed since, since a parent that refuses anyone would prove nothing. A launcher's arguments must include the current executable, which that check starts; otherwise the file rules refused the start and the check proved nothing about new processes. - **A refused launch.** The launcher read its arguments only after closing every inherited descriptor, so a refused launch had already closed them. It reads them first. - **A kernel without Landlock.** The refusal says so in that case too, which the tests that skip on such a machine look for. Only the first ABI's rights being refused, or `ENOSYS` or `EOPNOTSUPP` from creating the ruleset, says so. Any other failure, such as `EINVAL` or too many layers, is reported as what it is and doesn't skip the tests. - **Folders in the exec list on macOS.** A folder got a rule that matches only the folder, so a launcher under it failed to start where the same policy works on Linux. It now covers what is in it, in both rules. - **The sandbox page** names only what this layer has: only `gridcraft-cli sandbox-check` starts the launcher here. ## Testing All 10 steps of `cargo xtask ci` pass on macOS (arm64), and `cargo clippy` passes for this crate for both Linux architectures. The gate on ubuntu-24.04 is the hosted one on this pull request, and it executes the Linux backend through the real binary, including `a_confined_program_leaves_other_processes_alone` and `a_confined_program_changes_no_file`, in which a confined Python tries each refused call on a file inside the policy, one outside it and a descriptor, and the test then compares contents, permissions and modification time. A separate run on the fork, on Linux 7.0 with Landlock ABI 8, covered the same commit's code with parts of the fix taken out. Without the metadata rules, the launch check refuses with `change the canary's permissions: it worked`. Without those rules or the new checks, `a_confined_program_changes_no_file` fails with `permissions of ...: it worked`. With the Landlock rights limited to ABI 2, as on an older kernel, it fails with `truncate ...: it worked`. A kernel that is actually older than ABI 3, or that has no Landlock at all, has not been run. On macOS, terminal input was checked by hand on a pseudo-terminal: `TIOCSTI` is refused when confined and works when not. The commits group the change by area for reading. Only the last commit of the branch is expected to build. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added sandbox support for running commands with policy-limited file access and restrictions on network and process operations. The sandbox is available on supported Linux and macOS systems; launches are refused if confinement cannot be established or verified. * Added the `sandbox-check` command to check sandbox support and enforcement, or run a specified program in the sandbox. It reports when sandboxing is unsupported. * **Documentation** * Added guidance on sandbox behavior, platform support, access restrictions, launch checks, and `sandbox-check` usage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
498 lines
22 KiB
Rust
498 lines
22 KiB
Rust
//! Linux: the launcher confines itself with Landlock, then seccomp, then `exec`s the program,
|
|
//! which inherits both.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::ffi::OsString;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use landlock::{
|
|
ABI, Access, AccessFs, AccessNet, CompatLevel, Compatible, CreateRulesetError, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
|
|
RulesetStatus, Scope, path_beneath_rules,
|
|
};
|
|
use seccompiler::{BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, TargetArch};
|
|
|
|
use crate::policy::Checked;
|
|
use crate::{Result, SandboxError, Support};
|
|
|
|
/// The oldest Landlock ABI a launch accepts, whose filesystem rights are required: the first that
|
|
/// covers truncating a file (Linux 6.2). Before it, `truncate` and `open` with `O_TRUNC` empty a file
|
|
/// whatever the rules say. A filter can't take its place: `openat2` hides its flags from seccomp, and
|
|
/// refusing `O_TRUNC` would refuse opening `/dev/null` to write, as `open(os.devnull, "w")` does.
|
|
const OLDEST: ABI = ABI::V3;
|
|
/// The newest Landlock ABI this crate knows. Rights it adds past [`OLDEST`] are requested on a
|
|
/// best-effort basis.
|
|
const NEWEST: ABI = ABI::V9;
|
|
|
|
// System calls the `libc` crate doesn't name on both architectures. Numbers from 424 up are the same
|
|
// on x86_64 and aarch64.
|
|
const SYS_FCHMODAT2: libc::c_long = 452;
|
|
const SYS_SETXATTRAT: libc::c_long = 463;
|
|
const SYS_REMOVEXATTRAT: libc::c_long = 466;
|
|
const SYS_OPEN_TREE_ATTR: libc::c_long = 467;
|
|
const SYS_FILE_SETATTR: libc::c_long = 469;
|
|
|
|
pub fn support() -> Support {
|
|
match arch() {
|
|
Some(_) => Support::Available("Landlock and seccomp (checked again at each launch)".into()),
|
|
None => Support::Unsupported(format!("seccomp filters are built for x86_64 and aarch64, not {}", std::env::consts::ARCH)),
|
|
}
|
|
}
|
|
|
|
/// Linux runs the launcher directly: it confines itself.
|
|
pub fn command(_: &Checked, exe: &Path, launch: &[OsString]) -> Result<Command> {
|
|
arch().ok_or_else(|| SandboxError::Unsupported(format!("no seccomp filter for {}", std::env::consts::ARCH)))?;
|
|
let mut c = Command::new(exe);
|
|
c.args(launch);
|
|
Ok(c)
|
|
}
|
|
|
|
pub fn confine(policy: &Checked, _: u32, _: &[OsString]) -> Result<()> {
|
|
landlock(policy)?;
|
|
seccomp()
|
|
}
|
|
|
|
pub fn exec(program: &Path, args: &[OsString]) -> Result<std::convert::Infallible> {
|
|
crate::exec_unix(program, args)
|
|
}
|
|
|
|
fn not_enforced(e: impl std::fmt::Display) -> SandboxError {
|
|
SandboxError::NotEnforced(e.to_string())
|
|
}
|
|
|
|
/// A kernel without Landlock, or with it off, in the Landlock crate's own text. Only that case says
|
|
/// this, since the tests that skip on such a machine look for it: any other failure, such as too many
|
|
/// layers or a refused ruleset, comes from a machine that has Landlock, which must not be taken for
|
|
/// one that doesn't.
|
|
fn landlock_not_enforced(e: impl std::fmt::Display) -> SandboxError {
|
|
SandboxError::NotEnforced(format!("Landlock is not enforced: {e}"))
|
|
}
|
|
|
|
/// What a failed `landlock_create_ruleset` says about the kernel: `ENOSYS` is one built without
|
|
/// Landlock and `EOPNOTSUPP` one with it off at boot. Anything else, such as `EINVAL` or too many open
|
|
/// files, comes from a kernel that has it.
|
|
fn says_no_landlock(errno: Option<i32>) -> bool {
|
|
matches!(errno, Some(libc::ENOSYS | libc::EOPNOTSUPP))
|
|
}
|
|
|
|
/// A kernel without the rights a launch requires ([`OLDEST`]).
|
|
fn too_old_for_truncation(e: impl std::fmt::Display) -> SandboxError {
|
|
landlock_not_enforced(format!("this kernel has no Landlock, or one older than ABI 3 (Linux 6.2), which can't refuse truncating a file: {e}"))
|
|
}
|
|
|
|
fn ruleset_error(e: RulesetError) -> SandboxError {
|
|
let absent =
|
|
matches!(&e, RulesetError::CreateRuleset(CreateRulesetError::CreateRulesetCall { source, .. }) if says_no_landlock(source.raw_os_error()));
|
|
if absent { landlock_not_enforced(e) } else { not_enforced(e) }
|
|
}
|
|
|
|
/// Filesystem: every right Landlock handles is denied except reading the policy's read paths,
|
|
/// executing (and reading) its exec paths, and `/dev/null`. On kernels with network rights (ABI
|
|
/// 4), TCP binding and connecting are denied too; with scopes (ABI 6), signals to and abstract
|
|
/// Unix sockets of processes outside the sandbox.
|
|
fn landlock(policy: &Checked) -> Result<()> {
|
|
let ruleset = Ruleset::default()
|
|
.set_compatibility(CompatLevel::HardRequirement)
|
|
.handle_access(AccessFs::from_all(OLDEST))
|
|
.map_err(too_old_for_truncation)?
|
|
.set_compatibility(CompatLevel::BestEffort)
|
|
.handle_access(AccessFs::from_all(NEWEST))
|
|
.and_then(|r| r.handle_access(AccessNet::from_all(NEWEST)))
|
|
.and_then(|r| r.scope(Scope::from_all(NEWEST)))
|
|
.map_err(not_enforced)?
|
|
.create()
|
|
.map_err(ruleset_error)?;
|
|
let read = AccessFs::ReadFile | AccessFs::ReadDir;
|
|
let ruleset = ruleset
|
|
.set_compatibility(CompatLevel::HardRequirement)
|
|
.add_rules(path_beneath_rules(&policy.read, read))
|
|
.and_then(|r| r.add_rules(path_beneath_rules(&policy.exec, read | AccessFs::Execute)))
|
|
.and_then(|r| r.add_rules(path_beneath_rules(["/dev/null"], AccessFs::ReadFile | AccessFs::WriteFile)))
|
|
.map_err(not_enforced)?;
|
|
let status = ruleset.restrict_self().map_err(not_enforced)?;
|
|
if !status.no_new_privs {
|
|
return Err(SandboxError::NotEnforced("no_new_privs is not set".into()));
|
|
}
|
|
// The rights of `OLDEST` are a hard requirement, so "partially" means only newer rights are
|
|
// missing on this kernel; "not enforced" means nothing is.
|
|
if status.ruleset == RulesetStatus::NotEnforced {
|
|
return Err(SandboxError::NotEnforced(format!("Landlock is not enforced ({:?})", status.landlock)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn arch() -> Option<TargetArch> {
|
|
match std::env::consts::ARCH {
|
|
"x86_64" => Some(TargetArch::x86_64),
|
|
"aarch64" => Some(TargetArch::aarch64),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// System calls that fail with `EPERM` in the sandbox, unconditionally.
|
|
fn denied_calls() -> Vec<libc::c_long> {
|
|
let calls = vec![
|
|
// Network: no socket of any family (a Unix datagram socket can send without
|
|
// `connect`), and no connection or listener on one the program might hold.
|
|
libc::SYS_socket,
|
|
libc::SYS_socketpair,
|
|
libc::SYS_connect,
|
|
libc::SYS_bind,
|
|
libc::SYS_listen,
|
|
libc::SYS_accept,
|
|
libc::SYS_accept4,
|
|
// io_uring runs operations, connections included, outside the seccomp filter.
|
|
libc::SYS_io_uring_setup,
|
|
libc::SYS_io_uring_enter,
|
|
libc::SYS_io_uring_register,
|
|
// Inspecting or changing other processes.
|
|
libc::SYS_ptrace,
|
|
libc::SYS_process_vm_readv,
|
|
libc::SYS_process_vm_writev,
|
|
libc::SYS_pidfd_getfd,
|
|
libc::SYS_pidfd_open,
|
|
libc::SYS_pidfd_send_signal,
|
|
libc::SYS_kcmp,
|
|
libc::SYS_get_robust_list,
|
|
libc::SYS_migrate_pages,
|
|
libc::SYS_move_pages,
|
|
// System V shared memory, message queues and semaphores: a segment or queue this user can
|
|
// open holds what another process put there, and no path rule covers them.
|
|
libc::SYS_shmget,
|
|
libc::SYS_shmat,
|
|
libc::SYS_shmctl,
|
|
libc::SYS_shmdt,
|
|
libc::SYS_msgget,
|
|
libc::SYS_msgsnd,
|
|
libc::SYS_msgrcv,
|
|
libc::SYS_msgctl,
|
|
libc::SYS_semget,
|
|
libc::SYS_semop,
|
|
libc::SYS_semctl,
|
|
libc::SYS_semtimedop,
|
|
// What changes a file without Landlock having a right for it: permissions, owner, extended
|
|
// attributes, times and flags, by path or by descriptor, on any file this user owns.
|
|
libc::SYS_fchmod,
|
|
libc::SYS_fchmodat,
|
|
SYS_FCHMODAT2,
|
|
libc::SYS_fchown,
|
|
libc::SYS_fchownat,
|
|
libc::SYS_setxattr,
|
|
libc::SYS_lsetxattr,
|
|
libc::SYS_fsetxattr,
|
|
SYS_SETXATTRAT,
|
|
libc::SYS_removexattr,
|
|
libc::SYS_lremovexattr,
|
|
libc::SYS_fremovexattr,
|
|
SYS_REMOVEXATTRAT,
|
|
libc::SYS_utimensat,
|
|
SYS_FILE_SETATTR,
|
|
// POSIX message queues, which no path rule covers either: another process of the same user can
|
|
// open one, by name.
|
|
libc::SYS_mq_open,
|
|
libc::SYS_mq_unlink,
|
|
libc::SYS_mq_timedsend,
|
|
libc::SYS_mq_timedreceive,
|
|
libc::SYS_mq_notify,
|
|
libc::SYS_mq_getsetattr,
|
|
// Mounts, namespaces and file handles (ways around path-based rules).
|
|
libc::SYS_mount,
|
|
libc::SYS_umount2,
|
|
libc::SYS_pivot_root,
|
|
libc::SYS_chroot,
|
|
libc::SYS_unshare,
|
|
libc::SYS_setns,
|
|
libc::SYS_open_by_handle_at,
|
|
libc::SYS_name_to_handle_at,
|
|
libc::SYS_open_tree,
|
|
SYS_OPEN_TREE_ATTR,
|
|
libc::SYS_move_mount,
|
|
libc::SYS_fsopen,
|
|
libc::SYS_fsconfig,
|
|
libc::SYS_fsmount,
|
|
libc::SYS_fspick,
|
|
libc::SYS_mount_setattr,
|
|
// The kernel's own extension points and system state.
|
|
libc::SYS_bpf,
|
|
libc::SYS_perf_event_open,
|
|
libc::SYS_userfaultfd,
|
|
libc::SYS_keyctl,
|
|
libc::SYS_add_key,
|
|
libc::SYS_request_key,
|
|
libc::SYS_init_module,
|
|
libc::SYS_finit_module,
|
|
libc::SYS_delete_module,
|
|
libc::SYS_kexec_load,
|
|
libc::SYS_kexec_file_load,
|
|
libc::SYS_swapon,
|
|
libc::SYS_swapoff,
|
|
libc::SYS_reboot,
|
|
libc::SYS_syslog,
|
|
];
|
|
// x86_64 keeps the older process and file calls and port I/O; aarch64 has none of them.
|
|
#[cfg(target_arch = "x86_64")]
|
|
let calls = [
|
|
calls,
|
|
vec![
|
|
libc::SYS_fork,
|
|
libc::SYS_vfork,
|
|
libc::SYS_iopl,
|
|
libc::SYS_ioperm,
|
|
libc::SYS_chmod,
|
|
libc::SYS_chown,
|
|
libc::SYS_lchown,
|
|
libc::SYS_utime,
|
|
libc::SYS_utimes,
|
|
libc::SYS_futimesat,
|
|
],
|
|
]
|
|
.concat();
|
|
calls
|
|
}
|
|
|
|
/// `who` and `which` of `setpriority` and `ioprio_set` for one process.
|
|
const PRIO_PROCESS: u64 = 0;
|
|
const IOPRIO_WHO_PROCESS: u64 = 1;
|
|
// `libc::Ioctl` differs by C library.
|
|
#[allow(clippy::unnecessary_cast)]
|
|
const TIOCSTI: u64 = libc::TIOCSTI as u64;
|
|
#[allow(clippy::unnecessary_cast)]
|
|
const TIOCLINUX: u64 = libc::TIOCLINUX as u64;
|
|
/// The bit of an `ioctl` request, on both architectures, that says it hands the kernel data.
|
|
const IOC_WRITE: u64 = 0x4000_0000;
|
|
/// Changes a directory, though its number says it only reads, as the kernel's headers have it.
|
|
const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613;
|
|
|
|
/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match.
|
|
/// - `clone` without `CLONE_THREAD`: a new process.
|
|
/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer
|
|
/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one.
|
|
/// - A change to the limits, priority or scheduling of any process but `me`.
|
|
/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when
|
|
/// `gridcraft-cli sandbox-check` runs a program from a shell.
|
|
/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or
|
|
/// attributes do (`FS_IOC_SETFLAGS`, `FS_IOC_FSSETXATTR`) on a descriptor opened only to read.
|
|
/// Landlock's rights don't cover them for files. Those that ask, as `isatty` does, and those that
|
|
/// set a descriptor's own flags, as `FIONBIO` does, carry no such bit and still work.
|
|
///
|
|
/// Process IDs and `ioctl` requests are compared as the kernel reads them, in 32 bits, so high bits
|
|
/// set on the 64-bit argument don't get past a rule.
|
|
fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
|
|
let me = u64::from(me);
|
|
let word = |arg: u8, op: SeccompCmpOp, value: u64| SeccompCondition::new(arg, SeccompCmpArgLen::Dword, op, value).map_err(not_enforced);
|
|
let rule = |conditions: Vec<SeccompCondition>| SeccompRule::new(conditions).map_err(not_enforced);
|
|
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = denied_calls().into_iter().map(|nr| (nr, Vec::new())).collect();
|
|
let clone_thread = u64::try_from(libc::CLONE_THREAD).map_err(not_enforced)?;
|
|
let new_process = SeccompCondition::new(0, SeccompCmpArgLen::Qword, SeccompCmpOp::MaskedEq(clone_thread), 0).map_err(not_enforced)?;
|
|
rules.insert(libc::SYS_clone, vec![rule(vec![new_process])?]);
|
|
for nr in [libc::SYS_kill, libc::SYS_tkill, libc::SYS_tgkill, libc::SYS_rt_sigqueueinfo, libc::SYS_rt_tgsigqueueinfo] {
|
|
rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, me)?])?]);
|
|
}
|
|
// These take 0 for the caller too.
|
|
for nr in [libc::SYS_prlimit64, libc::SYS_sched_setaffinity, libc::SYS_sched_setscheduler, libc::SYS_sched_setparam, libc::SYS_sched_setattr] {
|
|
rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, me)?, word(0, SeccompCmpOp::Ne, 0)?])?]);
|
|
}
|
|
for (nr, one_process) in [(libc::SYS_setpriority, PRIO_PROCESS), (libc::SYS_ioprio_set, IOPRIO_WHO_PROCESS)] {
|
|
let another = rule(vec![word(1, SeccompCmpOp::Ne, me)?, word(1, SeccompCmpOp::Ne, 0)?])?;
|
|
rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, one_process)?])?, another]);
|
|
}
|
|
rules.insert(
|
|
libc::SYS_ioctl,
|
|
vec![
|
|
rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?,
|
|
rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?,
|
|
rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?,
|
|
rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?,
|
|
],
|
|
);
|
|
Ok(rules)
|
|
}
|
|
|
|
/// The filters: the first answers `clone3` with `ENOSYS`, so the C library falls back to `clone`,
|
|
/// whose flags a filter can read (a thread passes `CLONE_THREAD`; a new process doesn't); the
|
|
/// second is [`main_rules`]; on x86_64 a third refuses every x32 call, which carries the same
|
|
/// architecture value but other numbers.
|
|
fn seccomp() -> Result<()> {
|
|
let arch = arch().ok_or_else(|| SandboxError::Unsupported(format!("no seccomp filter for {}", std::env::consts::ARCH)))?;
|
|
let program = |rules: BTreeMap<i64, Vec<SeccompRule>>, errno: i32| -> Result<BpfProgram> {
|
|
let errno = u32::try_from(errno).map_err(not_enforced)?;
|
|
SeccompFilter::new(rules, SeccompAction::Allow, SeccompAction::Errno(errno), arch).and_then(BpfProgram::try_from).map_err(not_enforced)
|
|
};
|
|
let clone3 = program(BTreeMap::from([(libc::SYS_clone3, Vec::new())]), libc::ENOSYS)?;
|
|
let main = program(main_rules(std::process::id())?, libc::EPERM)?;
|
|
// Landlock already set no_new_privs, which installing a filter without privileges needs.
|
|
if std::env::consts::ARCH == "x86_64" {
|
|
seccompiler::apply_filter(&x32_filter()).map_err(not_enforced)?;
|
|
}
|
|
seccompiler::apply_filter(&clone3).map_err(not_enforced)?;
|
|
seccompiler::apply_filter(&main).map_err(not_enforced)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The x32 bit of a system call number on x86_64.
|
|
const X32_SYSCALL_BIT: u32 = 0x4000_0000;
|
|
|
|
/// A filter that refuses with `EPERM` every call whose number has the x32 bit. x32 calls report
|
|
/// the x86_64 architecture value, so the other filters' architecture check lets them through and
|
|
/// their numbers match none of the rules. A call of another architecture is left to the other
|
|
/// filters, which end the process.
|
|
fn x32_filter() -> BpfProgram {
|
|
// `struct seccomp_data`: the call number at offset 0, the architecture at offset 4.
|
|
const LD_W_ABS: u16 = 0x20;
|
|
const JEQ_K: u16 = 0x15;
|
|
const JGE_K: u16 = 0x35;
|
|
const RET_K: u16 = 0x06;
|
|
const AUDIT_ARCH_X86_64: u32 = 62 | 0x8000_0000 | 0x4000_0000;
|
|
let op = |code: u16, jt: u8, jf: u8, k: u32| seccompiler::sock_filter { code, jt, jf, k };
|
|
let errno = u32::try_from(libc::EPERM).unwrap_or(1);
|
|
vec![
|
|
op(LD_W_ABS, 0, 0, 4),
|
|
op(JEQ_K, 1, 0, AUDIT_ARCH_X86_64),
|
|
op(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW),
|
|
op(LD_W_ABS, 0, 0, 0),
|
|
op(JGE_K, 0, 1, X32_SYSCALL_BIT),
|
|
op(RET_K, 0, 0, libc::SECCOMP_RET_ERRNO | errno),
|
|
op(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_filters_build_for_both_architectures() {
|
|
// Building only: installing one would confine the test process.
|
|
for arch in [TargetArch::x86_64, TargetArch::aarch64] {
|
|
let f = SeccompFilter::new(main_rules(4242).unwrap(), SeccompAction::Allow, SeccompAction::Errno(1), arch);
|
|
assert!(f.and_then(BpfProgram::try_from).is_ok(), "{arch:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_calls_that_reach_another_process_have_rules() {
|
|
let rules = main_rules(4242).unwrap();
|
|
for nr in [
|
|
libc::SYS_kill,
|
|
libc::SYS_tkill,
|
|
libc::SYS_tgkill,
|
|
libc::SYS_rt_sigqueueinfo,
|
|
libc::SYS_rt_tgsigqueueinfo,
|
|
libc::SYS_prlimit64,
|
|
libc::SYS_setpriority,
|
|
libc::SYS_ioprio_set,
|
|
libc::SYS_sched_setaffinity,
|
|
libc::SYS_sched_setscheduler,
|
|
libc::SYS_sched_setparam,
|
|
libc::SYS_sched_setattr,
|
|
] {
|
|
assert_eq!(rules.get(&nr).map(Vec::len), Some(if nr == libc::SYS_setpriority || nr == libc::SYS_ioprio_set { 2 } else { 1 }), "{nr}");
|
|
}
|
|
for nr in [
|
|
libc::SYS_pidfd_open,
|
|
libc::SYS_pidfd_send_signal,
|
|
libc::SYS_kcmp,
|
|
libc::SYS_get_robust_list,
|
|
libc::SYS_migrate_pages,
|
|
libc::SYS_move_pages,
|
|
libc::SYS_shmget,
|
|
libc::SYS_shmat,
|
|
libc::SYS_msgget,
|
|
libc::SYS_semget,
|
|
libc::SYS_mq_open,
|
|
libc::SYS_mq_unlink,
|
|
libc::SYS_mq_timedsend,
|
|
libc::SYS_mq_timedreceive,
|
|
libc::SYS_mq_notify,
|
|
libc::SYS_mq_getsetattr,
|
|
libc::SYS_syslog,
|
|
] {
|
|
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright");
|
|
}
|
|
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4));
|
|
}
|
|
|
|
#[test]
|
|
fn what_landlock_has_no_right_for_is_refused_outright() {
|
|
let rules = main_rules(4242).unwrap();
|
|
let every_architecture = [
|
|
(libc::SYS_fchmod, "fchmod"),
|
|
(libc::SYS_fchmodat, "fchmodat"),
|
|
(SYS_FCHMODAT2, "fchmodat2"),
|
|
(libc::SYS_fchown, "fchown"),
|
|
(libc::SYS_fchownat, "fchownat"),
|
|
(libc::SYS_setxattr, "setxattr"),
|
|
(libc::SYS_lsetxattr, "lsetxattr"),
|
|
(libc::SYS_fsetxattr, "fsetxattr"),
|
|
(SYS_SETXATTRAT, "setxattrat"),
|
|
(libc::SYS_removexattr, "removexattr"),
|
|
(libc::SYS_lremovexattr, "lremovexattr"),
|
|
(libc::SYS_fremovexattr, "fremovexattr"),
|
|
(SYS_REMOVEXATTRAT, "removexattrat"),
|
|
(libc::SYS_utimensat, "utimensat"),
|
|
(SYS_FILE_SETATTR, "file_setattr"),
|
|
(SYS_OPEN_TREE_ATTR, "open_tree_attr"),
|
|
];
|
|
for (nr, name) in every_architecture {
|
|
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{name}");
|
|
}
|
|
#[cfg(target_arch = "x86_64")]
|
|
for (nr, name) in [
|
|
(libc::SYS_chmod, "chmod"),
|
|
(libc::SYS_chown, "chown"),
|
|
(libc::SYS_lchown, "lchown"),
|
|
(libc::SYS_utime, "utime"),
|
|
(libc::SYS_utimes, "utimes"),
|
|
(libc::SYS_futimesat, "futimesat"),
|
|
] {
|
|
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_numbers_written_here_are_the_kernels() {
|
|
// `libc` names this one on x86_64, so a slip in the shared table shows there.
|
|
#[cfg(target_arch = "x86_64")]
|
|
assert_eq!(SYS_FCHMODAT2, libc::SYS_fchmodat2);
|
|
assert_eq!((SYS_SETXATTRAT, SYS_REMOVEXATTRAT, SYS_OPEN_TREE_ATTR, SYS_FILE_SETATTR), (463, 466, 467, 469));
|
|
}
|
|
|
|
#[test]
|
|
fn a_kernel_that_cant_refuse_truncating_a_file_is_not_accepted() {
|
|
assert!(AccessFs::from_all(OLDEST).contains(AccessFs::Truncate));
|
|
assert!(!AccessFs::from_all(ABI::V2).contains(AccessFs::Truncate), "the oldest ABI with the right");
|
|
// It reads as a machine that can't confine, which the tests that skip on one look for.
|
|
let e = too_old_for_truncation("the crate's text").to_string();
|
|
assert!(e.contains("Landlock is not enforced:") && e.contains("Linux 6.2") && e.contains("the crate's text"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_kernel_without_landlock_says_so() {
|
|
// What the tests and a person read to tell this from a sandbox that failed its check.
|
|
assert!(landlock_not_enforced("the crate's text").to_string().contains("Landlock is not enforced: the crate's text"));
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_missing_or_disabled_landlock_says_it_is_not_enforced() {
|
|
assert!(says_no_landlock(Some(libc::ENOSYS)));
|
|
assert!(says_no_landlock(Some(libc::EOPNOTSUPP)));
|
|
for errno in [libc::EINVAL, libc::E2BIG, libc::EFAULT, libc::EMFILE, libc::ENOMEM, libc::EPERM] {
|
|
assert!(!says_no_landlock(Some(errno)), "{errno}");
|
|
}
|
|
assert!(!says_no_landlock(None));
|
|
// A ruleset failure of another kind, from the crate itself, on any kernel.
|
|
let e = Ruleset::default().create().err().map(ruleset_error).map(|e| e.to_string());
|
|
assert!(e.is_some_and(|e| !e.contains("Landlock is not enforced")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_x32_filter_refuses_numbers_with_the_x32_bit() {
|
|
let f = x32_filter();
|
|
assert_eq!(f.len(), 7);
|
|
// Load the number, then jump past the refusal for numbers below the x32 bit.
|
|
assert_eq!((f[3].code, f[3].k), (0x20, 0));
|
|
assert_eq!((f[4].code, f[4].jt, f[4].jf, f[4].k), (0x35, 0, 1, X32_SYSCALL_BIT));
|
|
assert_eq!(f[5].k, libc::SECCOMP_RET_ERRNO | 1);
|
|
}
|
|
}
|