Skip to main content

arkhe_forge_platform/process_protection/
linux.rs

1//! Linux process protection — `mlockall` + `PR_SET_DUMPABLE` +
2//! `PR_SET_PTRACER` with an advisory `yama.ptrace_scope` probe.
3//!
4//! This file opts into `unsafe_code` scoped to the libc FFI calls; every
5//! `unsafe { ... }` block carries a SAFETY note that justifies the call
6//! arguments.
7
8#![allow(unsafe_code)]
9
10use super::{ProcessProtection, ProtectionError};
11
12/// Linux impl.
13pub struct LinuxProcessProtection;
14
15/// Read `errno` after a failing libc call.
16///
17/// SAFETY: `libc::__errno_location` returns a pointer to a thread-local
18/// `int` owned by the libc runtime; dereferencing it is always defined for
19/// the current thread.
20fn last_errno() -> i32 {
21    unsafe { *libc::__errno_location() }
22}
23
24impl ProcessProtection for LinuxProcessProtection {
25    fn lock_memory(&self) -> Result<(), ProtectionError> {
26        // SAFETY: `mlockall` takes a bitmask of integer constants and has no
27        // pointer arguments. `MCL_CURRENT | MCL_FUTURE` is a valid flag set
28        // per `man 2 mlockall`.
29        let rc = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
30        if rc == 0 {
31            Ok(())
32        } else {
33            Err(ProtectionError::SyscallFailed {
34                op: "mlockall",
35                code: last_errno(),
36            })
37        }
38    }
39
40    fn disable_core_dump(&self) -> Result<(), ProtectionError> {
41        // SAFETY: `prctl(PR_SET_DUMPABLE, 0, ...)` takes `c_ulong` integer
42        // arguments; the fifth `0` fills the unused slot per `man 2 prctl`.
43        let rc = unsafe {
44            libc::prctl(
45                libc::PR_SET_DUMPABLE,
46                0 as libc::c_ulong,
47                0 as libc::c_ulong,
48                0 as libc::c_ulong,
49                0 as libc::c_ulong,
50            )
51        };
52        if rc == 0 {
53            Ok(())
54        } else {
55            Err(ProtectionError::SyscallFailed {
56                op: "prctl(PR_SET_DUMPABLE)",
57                code: last_errno(),
58            })
59        }
60    }
61
62    fn disable_ptrace(&self) -> Result<(), ProtectionError> {
63        // First: detect whether a tracer is **already attached** via
64        // `/proc/self/status` `TracerPid`. If non-zero we surface a
65        // hard error so callers cannot misread `.is_ok()` as "no
66        // debugger" — `disable_ptrace` would otherwise return Ok
67        // even with an attacker process holding ptrace control.
68        if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
69            for line in status.lines() {
70                if let Some(rest) = line.strip_prefix("TracerPid:") {
71                    if rest.trim() != "0" {
72                        return Err(ProtectionError::DebuggerAttached(
73                            "TracerPid != 0 in /proc/self/status",
74                        ));
75                    }
76                    break;
77                }
78            }
79        }
80
81        // SAFETY: `prctl(PR_SET_PTRACER, 0, ...)` sets the process-wide
82        // ptracer pid to 0 (= no process may ptrace-attach). Pure integer
83        // call, no pointers.
84        let rc = unsafe {
85            libc::prctl(
86                libc::PR_SET_PTRACER,
87                0 as libc::c_ulong,
88                0 as libc::c_ulong,
89                0 as libc::c_ulong,
90                0 as libc::c_ulong,
91            )
92        };
93        if rc != 0 {
94            return Err(ProtectionError::SyscallFailed {
95                op: "prctl(PR_SET_PTRACER)",
96                code: last_errno(),
97            });
98        }
99
100        // Advisory: `/proc/sys/kernel/yama/ptrace_scope` is a system-wide
101        // knob. Anything other than `2` (admin-only) means some ptracer
102        // parent could still attach. We surface a stderr warning but do not
103        // reject — a per-process API cannot enforce a system-wide setting.
104        // (Distinct from the TracerPid check above, which is a present-
105        // tense attach detection rather than a system policy advisory.)
106        match std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") {
107            Ok(content) => {
108                let scope = content.trim();
109                if scope != "2" {
110                    eprintln!(
111                        "arkhe-forge-platform: yama.ptrace_scope={scope} (want 2); \
112                         per-process ptrace protection cannot cover this gap."
113                    );
114                }
115            }
116            Err(_) => {
117                // Yama LSM not active on this kernel — advisory only.
118                eprintln!(
119                    "arkhe-forge-platform: /proc/sys/kernel/yama/ptrace_scope unreadable; \
120                     yama LSM likely disabled."
121                );
122            }
123        }
124
125        Ok(())
126    }
127}
128
129#[cfg(test)]
130#[allow(clippy::panic, clippy::unwrap_used)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn apply_all_returns_ok_or_specific_error() {
136        let proto = LinuxProcessProtection;
137        match proto.apply_all() {
138            Ok(()) => {}
139            Err(ProtectionError::SyscallFailed { op, code }) => {
140                // Container / unprivileged user environments may produce
141                // EPERM/ENOMEM — returning a specific error is itself
142                // evidence the implementation is complete.
143                eprintln!("apply_all reported SyscallFailed op={op} code={code}");
144            }
145            Err(ProtectionError::DebuggerAttached(reason)) => {
146                // m6 — running under cargo-test with `lldb` / `rr` etc.
147                // would surface this. Test ack only; CI normally won't.
148                eprintln!("apply_all reported DebuggerAttached: {reason}");
149            }
150            Err(other) => panic!("unexpected error variant: {other:?}"),
151        }
152    }
153
154    #[test]
155    fn disable_core_dump_either_succeeds_or_reports_errno() {
156        let proto = LinuxProcessProtection;
157        match proto.disable_core_dump() {
158            Ok(()) => {}
159            Err(ProtectionError::SyscallFailed { op, code }) => {
160                assert_eq!(op, "prctl(PR_SET_DUMPABLE)");
161                assert!(code != 0, "errno should be non-zero on failure");
162            }
163            Err(other) => panic!("unexpected error variant: {other:?}"),
164        }
165    }
166
167    #[test]
168    fn disable_ptrace_signals_attach_explicitly() {
169        // m6 — disable_ptrace must surface `DebuggerAttached` rather
170        // than silently returning Ok when a tracer is present.
171        // Outside a debugger the call returns Ok; the variant exists
172        // so callers can fail-close on attach.
173        let proto = LinuxProcessProtection;
174        match proto.disable_ptrace() {
175            Ok(()) => {}
176            Err(ProtectionError::DebuggerAttached(_)) => {}
177            Err(ProtectionError::SyscallFailed { op, .. }) => {
178                assert_eq!(op, "prctl(PR_SET_PTRACER)");
179            }
180            Err(other) => panic!("unexpected error variant: {other:?}"),
181        }
182    }
183}