Skip to main content

text_mirror/runner/
linux.rs

1//! The Linux jail backend: user and network namespaces, Landlock
2//! filesystem rules, and a seccomp allow-list.
3//!
4//! The network boundary is a fresh network namespace created together
5//! with a user namespace, which Linux permits unprivileged when
6//! unprivileged user namespaces are enabled. The new namespace has a
7//! private network stack and no external interface. A seccomp
8//! allow-list is the second layer: it omits every socket, io_uring,
9//! bpf, setsid, and setpgid family syscall, and any syscall outside
10//! the list kills the process. Landlock confines the filesystem to
11//! the jail read-write, the worker binary execute-read, and a fixed
12//! runtime set read-only, with no best-effort mode: a kernel that
13//! cannot fully enforce the policy refuses the run. The kernel floor
14//! is Landlock ABI v3, Linux 6.2.
15
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::process::Command;
19
20use landlock::{
21    ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
22    RulesetCreatedAttr, RulesetStatus,
23};
24use nix::libc;
25use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, SeccompRule, TargetArch};
26
27use super::jail::{JailBackend, SpawnSpec};
28use super::{Runner, RunnerError};
29
30/// The Landlock ABI this backend requires in full.
31const LANDLOCK_ABI: ABI = ABI::V3;
32
33/// Runtime paths the adapter may read: the dynamic loader, the shared
34/// library trees, and the loader cache, and nothing else. The shipped
35/// worker is the pure-Rust anydoc PDF path, which links only the
36/// loader, libc, libm, and libgcc_s, so the minimum set is the
37/// standard library directories. The list deliberately excludes the
38/// rest of `/usr`, so `/usr/local`, `/usr/share`, `/usr/bin`, and
39/// site data are not readable. Missing entries are skipped, so one
40/// list serves distributions that place the libraries under `/lib`,
41/// `/lib64`, `/usr/lib`, or `/usr/lib64`.
42const RUNTIME_READ_PATHS: &[&str] = &[
43    "/lib",
44    "/lib64",
45    "/usr/lib",
46    "/usr/lib64",
47    "/etc/ld.so.cache",
48];
49
50/// The namespace, Landlock, and seccomp jail.
51pub struct NamespaceJail;
52
53impl JailBackend for NamespaceJail {
54    fn name(&self) -> &'static str {
55        "linux-namespace"
56    }
57
58    fn command(&self, spec: &SpawnSpec<'_>) -> Result<Command, RunnerError> {
59        let mut command = Command::new(spec.worker);
60        command
61            .arg("sandbox-helper")
62            .arg("--jail")
63            .arg(spec.jail)
64            .arg("--worker")
65            .arg(spec.worker)
66            .arg("--adapter")
67            .arg(spec.mode)
68            .arg("--rlimit-as")
69            .arg(spec.limits.address_space_bytes.to_string())
70            .arg("--rlimit-cpu")
71            .arg(spec.limits.cpu_seconds.to_string())
72            .arg("--rlimit-fsize")
73            .arg(spec.limits.file_size_bytes.to_string())
74            .arg("--rlimit-nproc")
75            .arg(spec.limits.max_processes.to_string())
76            .arg("--seccomp")
77            .arg(if spec.seccomp { "kill" } else { "off" });
78        Ok(command)
79    }
80
81    fn policy_material(&self) -> String {
82        format!(
83            "newuser+newnet landlock-abi-v3 runtime={} seccomp-allow={}",
84            RUNTIME_READ_PATHS.join(","),
85            allowed_syscalls()
86                .iter()
87                .map(|n| n.to_string())
88                .collect::<Vec<_>>()
89                .join(",")
90        )
91    }
92
93    /// Two stages. The first runs the network probe with the syscall
94    /// filter off, so the namespace layer's denial is observed
95    /// directly. The second proves the filter layer by attempting one
96    /// socket creation under it and requiring the kernel to kill the
97    /// process.
98    fn probe(&self, runner: &Runner) -> Result<(), RunnerError> {
99        super::jail::probe_net(runner, false)?;
100        match runner.run_unprobed("probe-seccomp", serde_json::json!({}), &[], true) {
101            Err(e) if e.code == "adapter_crash" => Ok(()),
102            Ok(_) => Err(RunnerError {
103                code: "sandbox_probe_failed",
104                message: "the syscall filter let a socket creation through".to_string(),
105            }),
106            Err(e) => Err(RunnerError {
107                code: "sandbox_probe_failed",
108                message: format!("the syscall filter probe failed to run: {e}"),
109            }),
110        }
111    }
112}
113
114/// Applies the namespace layer: a fresh user namespace with one-ID
115/// maps and a fresh network namespace.
116pub(super) fn unshare_namespaces() -> Result<(), String> {
117    use std::os::unix::fs::MetadataExt;
118    let this = std::fs::metadata("/proc/self")
119        .map_err(|e| format!("cannot read process identity: {e}"))?;
120    let (uid, gid) = (this.uid(), this.gid());
121    nix::sched::unshare(
122        nix::sched::CloneFlags::CLONE_NEWUSER | nix::sched::CloneFlags::CLONE_NEWNET,
123    )
124    .map_err(|e| format!("unshare(newuser|newnet) failed: {e}"))?;
125    std::fs::write("/proc/self/setgroups", "deny")
126        .map_err(|e| format!("cannot deny setgroups: {e}"))?;
127    std::fs::write("/proc/self/uid_map", format!("{uid} {uid} 1\n"))
128        .map_err(|e| format!("cannot write the uid map: {e}"))?;
129    std::fs::write("/proc/self/gid_map", format!("{gid} {gid} 1\n"))
130        .map_err(|e| format!("cannot write the gid map: {e}"))?;
131    Ok(())
132}
133
134/// Applies the Landlock filesystem policy and requires it fully
135/// enforced. Best-effort is only acceptable in nothing: a partial
136/// policy refuses the run.
137pub(super) fn apply_landlock(jail: &Path, worker: &Path) -> Result<(), String> {
138    let fail = |stage: &str, e: &dyn std::fmt::Display| format!("landlock {stage}: {e}");
139    let mut ruleset = Ruleset::default()
140        .set_compatibility(CompatLevel::HardRequirement)
141        .handle_access(AccessFs::from_all(LANDLOCK_ABI))
142        .map_err(|e| fail("handle_access", &e))?
143        .create()
144        .map_err(|e| fail("create", &e))?;
145    ruleset = ruleset
146        .add_rule(PathBeneath::new(
147            PathFd::new(jail).map_err(|e| fail("open jail", &e))?,
148            AccessFs::from_all(LANDLOCK_ABI),
149        ))
150        .map_err(|e| fail("jail rule", &e))?;
151    ruleset = ruleset
152        .add_rule(PathBeneath::new(
153            PathFd::new(worker).map_err(|e| fail("open worker", &e))?,
154            AccessFs::Execute | AccessFs::ReadFile,
155        ))
156        .map_err(|e| fail("worker rule", &e))?;
157    for path in RUNTIME_READ_PATHS {
158        if !Path::new(path).exists() {
159            continue;
160        }
161        let access = if Path::new(path).is_dir() {
162            AccessFs::from_read(LANDLOCK_ABI)
163        } else {
164            AccessFs::ReadFile.into()
165        };
166        ruleset = ruleset
167            .add_rule(PathBeneath::new(
168                PathFd::new(path).map_err(|e| fail("open runtime path", &e))?,
169                access,
170            ))
171            .map_err(|e| fail("runtime rule", &e))?;
172    }
173    let status = ruleset
174        .restrict_self()
175        .map_err(|e| fail("restrict_self", &e))?;
176    if status.ruleset != RulesetStatus::FullyEnforced {
177        return Err(format!(
178            "landlock policy is {:?}, full enforcement is required, the kernel floor is 6.2",
179            status.ruleset
180        ));
181    }
182    Ok(())
183}
184
185/// Installs the seccomp allow-list with kill-process as the mismatch
186/// action.
187pub(super) fn apply_seccomp() -> Result<(), String> {
188    let arch = TargetArch::try_from(std::env::consts::ARCH)
189        .map_err(|e| format!("seccomp does not support this architecture: {e:?}"))?;
190    let rules: BTreeMap<i64, Vec<SeccompRule>> = allowed_syscalls()
191        .iter()
192        .map(|syscall| (*syscall, Vec::new()))
193        .collect();
194    let filter = SeccompFilter::new(
195        rules,
196        SeccompAction::KillProcess,
197        SeccompAction::Allow,
198        arch,
199    )
200    .map_err(|e| format!("seccomp filter build failed: {e}"))?;
201    let program: BpfProgram = filter
202        .try_into()
203        .map_err(|e: seccompiler::BackendError| format!("seccomp compile failed: {e}"))?;
204    seccompiler::apply_filter(&program).map_err(|e| format!("seccomp install failed: {e}"))
205}
206
207/// The syscall allow-list for the adapter and the loader under it.
208///
209/// The list deliberately omits every socket-family syscall, io_uring,
210/// bpf, setsid, setpgid, setns, mount, and ptrace. Threads are
211/// allowed because they do not weaken the network or filesystem
212/// boundary.
213fn allowed_syscalls() -> Vec<i64> {
214    let mut list = vec![
215        libc::SYS_read,
216        libc::SYS_write,
217        libc::SYS_readv,
218        libc::SYS_writev,
219        libc::SYS_pread64,
220        libc::SYS_pwrite64,
221        libc::SYS_close,
222        libc::SYS_fstat,
223        libc::SYS_newfstatat,
224        libc::SYS_statx,
225        libc::SYS_lseek,
226        libc::SYS_mmap,
227        libc::SYS_mprotect,
228        libc::SYS_munmap,
229        libc::SYS_mremap,
230        libc::SYS_madvise,
231        libc::SYS_brk,
232        libc::SYS_futex,
233        libc::SYS_sched_yield,
234        libc::SYS_sched_getaffinity,
235        libc::SYS_clock_gettime,
236        libc::SYS_clock_getres,
237        libc::SYS_clock_nanosleep,
238        libc::SYS_gettimeofday,
239        libc::SYS_getrandom,
240        libc::SYS_exit,
241        libc::SYS_exit_group,
242        libc::SYS_rt_sigaction,
243        libc::SYS_rt_sigprocmask,
244        libc::SYS_rt_sigreturn,
245        libc::SYS_sigaltstack,
246        libc::SYS_tgkill,
247        libc::SYS_tkill,
248        libc::SYS_getpid,
249        libc::SYS_gettid,
250        libc::SYS_getppid,
251        libc::SYS_getuid,
252        libc::SYS_geteuid,
253        libc::SYS_getgid,
254        libc::SYS_getegid,
255        libc::SYS_getrlimit,
256        libc::SYS_prlimit64,
257        libc::SYS_getrusage,
258        libc::SYS_sysinfo,
259        libc::SYS_times,
260        libc::SYS_uname,
261        libc::SYS_umask,
262        libc::SYS_openat,
263        libc::SYS_faccessat,
264        libc::SYS_faccessat2,
265        libc::SYS_getdents64,
266        libc::SYS_readlinkat,
267        libc::SYS_getcwd,
268        libc::SYS_chdir,
269        libc::SYS_fchdir,
270        libc::SYS_fcntl,
271        libc::SYS_dup,
272        libc::SYS_dup3,
273        libc::SYS_pipe2,
274        libc::SYS_ioctl,
275        libc::SYS_ppoll,
276        libc::SYS_pselect6,
277        libc::SYS_epoll_create1,
278        libc::SYS_epoll_ctl,
279        libc::SYS_epoll_pwait,
280        libc::SYS_eventfd2,
281        libc::SYS_mkdirat,
282        libc::SYS_unlinkat,
283        libc::SYS_renameat,
284        libc::SYS_ftruncate,
285        libc::SYS_fallocate,
286        libc::SYS_fsync,
287        libc::SYS_fdatasync,
288        libc::SYS_flock,
289        libc::SYS_utimensat,
290        libc::SYS_set_tid_address,
291        libc::SYS_set_robust_list,
292        libc::SYS_rseq,
293        libc::SYS_membarrier,
294        libc::SYS_prctl,
295        libc::SYS_clone,
296        libc::SYS_clone3,
297        libc::SYS_execve,
298        libc::SYS_wait4,
299    ];
300    #[cfg(target_arch = "x86_64")]
301    list.extend_from_slice(&[
302        libc::SYS_open,
303        libc::SYS_stat,
304        libc::SYS_lstat,
305        libc::SYS_access,
306        libc::SYS_readlink,
307        libc::SYS_poll,
308        libc::SYS_select,
309        libc::SYS_dup2,
310        libc::SYS_pipe,
311        libc::SYS_epoll_wait,
312        libc::SYS_mkdir,
313        libc::SYS_unlink,
314        libc::SYS_rename,
315        libc::SYS_arch_prctl,
316        libc::SYS_nanosleep,
317        libc::SYS_time,
318        libc::SYS_getdents,
319    ]);
320    list.sort_unstable();
321    list
322}