capsule_runtime/security.rs
1//! Process-level hardening against the `/proc/<pid>/environ` side channel.
2//!
3//! `build_shell_env` (see `shell.rs`) sanitizes the environment array handed to each
4//! spawned subprocess, but that only covers the array `Command` passes at `execve`
5//! time. It does nothing about a bash child reading its own parent's raw kernel-recorded
6//! environment via `/proc/$PPID/environ` — a channel entirely outside `Command`'s
7//! control. Marking the runtime process non-dumpable closes that channel: the kernel's
8//! `ptrace_may_access` check (which gates `/proc/pid/environ` reads) fails for a
9//! non-dumpable target even when the reader shares the same UID.
10//!
11//! **Scope: this process, not its descendants.** The flag is inherited across `fork()`, but the
12//! forked shell-tool child deliberately undoes it for itself
13//! (`sandbox::linux_enforce::restore_child_dumpable`, inside `pre_exec`, before the seccomp filter
14//! is installed). It has to: the same `ptrace_may_access` check that closes this side channel also
15//! blocks the seccomp-notify supervisor thread — which lives in *this* process — from reading the
16//! child's `/proc/<pid>/mem` to recover the pathname of a notified `execve`, so every allowlisted
17//! `execve` was denied for a non-root user until the child re-enabled its own flag. The
18//! asymmetry is the point: this process may hold raw, unfiltered secrets in its environment, while
19//! the child's environment is already reduced to `shell::DEFAULT_ENV_BASELINE` plus explicit
20//! overrides by `shell::build_shell_env` before it is spawned. The resulting exposure, and the
21//! hand-run check for it, are in
22//! `docs/content/reference/workdir-device-node-manual-verification.md`.
23
24/// Marks the current process non-dumpable so no same-UID process — including its own
25/// shell descendants — can read this process's `/proc/<pid>/environ`.
26///
27/// Must be called as the first statement of `main()` in every binary that links this
28/// crate and can reach `execute_shell`, before any argument parsing or subprocess-capable
29/// code runs. A future binary that links `capsule-runtime` and reaches `execute_shell`
30/// inherits the same obligation.
31///
32/// Covers this process for its entire life and nothing else. The spawned shell-tool child
33/// re-enables its own flag from `pre_exec` — see this module's header for why that is required
34/// and what it trades away.
35///
36/// No-op on non-Linux targets: `/proc/<pid>/environ` and `prctl(2)` don't exist there,
37/// so there is no side channel to close.
38#[cfg(target_os = "linux")]
39#[allow(unsafe_code)]
40pub fn harden_process_dumpable() -> Result<(), String> {
41 // SAFETY: PR_SET_DUMPABLE takes a single int argument and has no pointer/lifetime
42 // requirements; the extra 0 args are ignored by prctl's variadic C signature.
43 let result = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
44 if result == 0 {
45 Ok(())
46 } else {
47 Err(format!(
48 "prctl(PR_SET_DUMPABLE, 0) failed: {}",
49 std::io::Error::last_os_error()
50 ))
51 }
52}
53
54#[cfg(not(target_os = "linux"))]
55pub fn harden_process_dumpable() -> Result<(), String> {
56 Ok(())
57}
58
59#[cfg(all(test, target_os = "linux"))]
60mod tests {
61 use super::*;
62
63 #[test]
64 #[allow(unsafe_code)]
65 fn harden_process_dumpable_clears_dumpable_flag() {
66 harden_process_dumpable().expect("prctl(PR_SET_DUMPABLE) should succeed");
67
68 // SAFETY: PR_GET_DUMPABLE takes no further arguments; reading back our own
69 // process's flag is side-effect-free.
70 let flag = unsafe { libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0) };
71 assert_eq!(
72 flag, 0,
73 "process should report non-dumpable after hardening"
74 );
75 }
76}
77
78#[cfg(all(test, not(target_os = "linux")))]
79mod non_linux_tests {
80 use super::*;
81
82 #[test]
83 fn harden_process_dumpable_is_noop_ok() {
84 assert!(harden_process_dumpable().is_ok());
85 }
86}