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}