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        // Fail closed when the probe itself is unreadable (hidepid=2
69        // procfs, sandbox mount masking): an Ok return GUARANTEES no
70        // tracer was attached at call time, so an unknown tracer state
71        // must be an error, not a silent pass.
72        let status = std::fs::read_to_string("/proc/self/status").map_err(|e| {
73            ProtectionError::SyscallFailed {
74                op: "read(/proc/self/status)",
75                code: e.raw_os_error().unwrap_or(0),
76            }
77        })?;
78        let mut tracer_line_seen = false;
79        for line in status.lines() {
80            if let Some(rest) = line.strip_prefix("TracerPid:") {
81                if rest.trim() != "0" {
82                    return Err(ProtectionError::DebuggerAttached(
83                        "TracerPid != 0 in /proc/self/status",
84                    ));
85                }
86                tracer_line_seen = true;
87                break;
88            }
89        }
90        // A status file without a TracerPid line (field-restricted procfs)
91        // leaves the tracer state unknown — fail closed, same as an
92        // unreadable probe.
93        if !tracer_line_seen {
94            return Err(ProtectionError::SyscallFailed {
95                op: "parse(/proc/self/status TracerPid)",
96                code: 0,
97            });
98        }
99
100        // SAFETY: `prctl(PR_SET_PTRACER, 0, ...)` sets the process-wide
101        // ptracer pid to 0 (= no process may ptrace-attach). Pure integer
102        // call, no pointers.
103        let rc = unsafe {
104            libc::prctl(
105                libc::PR_SET_PTRACER,
106                0 as libc::c_ulong,
107                0 as libc::c_ulong,
108                0 as libc::c_ulong,
109                0 as libc::c_ulong,
110            )
111        };
112        if rc != 0 {
113            return Err(ProtectionError::SyscallFailed {
114                op: "prctl(PR_SET_PTRACER)",
115                code: last_errno(),
116            });
117        }
118
119        // Advisory: `/proc/sys/kernel/yama/ptrace_scope` is a system-wide
120        // knob. Anything other than `2` (admin-only) means some ptracer
121        // parent could still attach. We surface a stderr warning but do not
122        // reject — a per-process API cannot enforce a system-wide setting.
123        // (Distinct from the TracerPid check above, which is a present-
124        // tense attach detection rather than a system policy advisory.)
125        match std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") {
126            Ok(content) => {
127                let scope = content.trim();
128                if scope != "2" {
129                    eprintln!(
130                        "arkhe-forge-platform: yama.ptrace_scope={scope} (want 2); \
131                         per-process ptrace protection cannot cover this gap."
132                    );
133                }
134            }
135            Err(_) => {
136                // Yama LSM not active on this kernel — advisory only.
137                eprintln!(
138                    "arkhe-forge-platform: /proc/sys/kernel/yama/ptrace_scope unreadable; \
139                     yama LSM likely disabled."
140                );
141            }
142        }
143
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149#[allow(clippy::panic, clippy::unwrap_used)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn apply_all_returns_ok_or_specific_error() {
155        let proto = LinuxProcessProtection;
156        match proto.apply_all() {
157            Ok(()) => {}
158            Err(ProtectionError::SyscallFailed { op, code }) => {
159                // Container / unprivileged user environments may produce
160                // EPERM/ENOMEM — returning a specific error is itself
161                // evidence the implementation is complete.
162                eprintln!("apply_all reported SyscallFailed op={op} code={code}");
163            }
164            Err(ProtectionError::DebuggerAttached(reason)) => {
165                // m6 — running under cargo-test with `lldb` / `rr` etc.
166                // would surface this. Test ack only; CI normally won't.
167                eprintln!("apply_all reported DebuggerAttached: {reason}");
168            }
169            Err(other) => panic!("unexpected error variant: {other:?}"),
170        }
171    }
172
173    #[test]
174    fn disable_core_dump_either_succeeds_or_reports_errno() {
175        let proto = LinuxProcessProtection;
176        match proto.disable_core_dump() {
177            Ok(()) => {}
178            Err(ProtectionError::SyscallFailed { op, code }) => {
179                assert_eq!(op, "prctl(PR_SET_DUMPABLE)");
180                assert!(code != 0, "errno should be non-zero on failure");
181            }
182            Err(other) => panic!("unexpected error variant: {other:?}"),
183        }
184    }
185
186    #[test]
187    fn disable_ptrace_signals_attach_explicitly() {
188        // m6 — disable_ptrace must surface `DebuggerAttached` rather
189        // than silently returning Ok when a tracer is present.
190        // Outside a debugger the call returns Ok; the variant exists
191        // so callers can fail-close on attach. A SyscallFailed may
192        // carry the prctl op, the fail-closed status-read op, or the
193        // fail-closed TracerPid-parse op (masked / field-restricted
194        // procfs environments).
195        let proto = LinuxProcessProtection;
196        match proto.disable_ptrace() {
197            Ok(()) => {}
198            Err(ProtectionError::DebuggerAttached(_)) => {}
199            Err(ProtectionError::SyscallFailed { op, .. }) => {
200                assert!(
201                    op == "prctl(PR_SET_PTRACER)"
202                        || op == "read(/proc/self/status)"
203                        || op == "parse(/proc/self/status TracerPid)",
204                    "unexpected SyscallFailed op: {op}"
205                );
206            }
207            Err(other) => panic!("unexpected error variant: {other:?}"),
208        }
209    }
210}