Skip to main content

vtcode_process_hardening/
lib.rs

1#[cfg(unix)]
2use std::ffi::OsString;
3#[cfg(unix)]
4use std::os::unix::ffi::OsStrExt;
5
6use ctor::ctor;
7
8#[cfg(any(target_os = "linux", target_os = "android"))]
9#[allow(unsafe_code)]
10fn prctl_set_dumpable() -> i32 {
11    // SAFETY: `prctl` is called with the documented `PR_SET_DUMPABLE` command and
12    // integer arguments only. No pointers are dereferenced.
13    unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }
14}
15
16#[cfg(target_os = "macos")]
17#[allow(unsafe_code)]
18fn ptrace_deny_attach() -> i32 {
19    // SAFETY: `ptrace(PT_DENY_ATTACH, ...)` is the documented macOS hardening call.
20    // The null pointer argument is not dereferenced by this request type.
21    unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) }
22}
23
24#[cfg(unix)]
25#[allow(unsafe_code)]
26fn remove_env_var(key: OsString) {
27    // SAFETY: Caller must ensure this runs during single-threaded early process
28    // startup, before any threads are spawned, which satisfies the environment
29    // mutation safety requirement.
30    unsafe { std::env::remove_var(key) }
31}
32
33/// Perform early process hardening as the first operation in `main()`.
34///
35/// Call this before any other operations, thread spawning, or heap
36/// allocation to ensure the process is locked down before potential
37/// adversaries can influence its state. Steps include:
38/// - disabling core dumps
39/// - disabling ptrace attach on Linux and macOS
40/// - removing dangerous environment variables such as LD_PRELOAD and DYLD_*
41pub fn pre_main_hardening() {
42    #[cfg(any(target_os = "linux", target_os = "android"))]
43    pre_main_hardening_linux();
44
45    #[cfg(target_os = "macos")]
46    pre_main_hardening_macos();
47
48    #[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
49    pre_main_hardening_bsd();
50
51    #[cfg(windows)]
52    pre_main_hardening_windows();
53}
54
55#[cfg(any(target_os = "linux", target_os = "android"))]
56const PRCTL_FAILED_EXIT_CODE: i32 = 5;
57
58#[cfg(target_os = "macos")]
59const PTRACE_DENY_ATTACH_FAILED_EXIT_CODE: i32 = 6;
60
61#[cfg(any(
62    target_os = "linux",
63    target_os = "android",
64    target_os = "macos",
65    target_os = "freebsd",
66    target_os = "netbsd",
67    target_os = "openbsd"
68))]
69const SET_RLIMIT_CORE_FAILED_EXIT_CODE: i32 = 7;
70
71#[cfg(any(target_os = "linux", target_os = "android"))]
72pub(crate) fn pre_main_hardening_linux() {
73    cap_stack_rlimit();
74
75    // Disable ptrace attach / mark process non-dumpable.
76    let ret_code = prctl_set_dumpable();
77    if ret_code != 0 {
78        eprintln!(
79            "ERROR: prctl(PR_SET_DUMPABLE, 0) failed: {}",
80            std::io::Error::last_os_error()
81        );
82        std::process::exit(PRCTL_FAILED_EXIT_CODE);
83    }
84
85    // For "defense in depth," set the core file size limit to 0.
86    set_core_file_size_limit_to_zero();
87
88    // VT Code is primarily MUSL-linked in release builds, which means that variables such
89    // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here.
90    let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_");
91    for key in ld_keys {
92        remove_env_var(key);
93    }
94}
95
96#[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
97pub(crate) fn pre_main_hardening_bsd() {
98    cap_stack_rlimit();
99
100    // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars
101    set_core_file_size_limit_to_zero();
102
103    let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_");
104    for key in ld_keys {
105        remove_env_var(key);
106    }
107}
108
109#[cfg(target_os = "macos")]
110pub(crate) fn pre_main_hardening_macos() {
111    cap_stack_rlimit();
112
113    // Prevent debuggers from attaching to this process.
114    let ret_code = ptrace_deny_attach();
115    if ret_code == -1 {
116        eprintln!(
117            "ERROR: ptrace(PT_DENY_ATTACH) failed: {}",
118            std::io::Error::last_os_error()
119        );
120        std::process::exit(PTRACE_DENY_ATTACH_FAILED_EXIT_CODE);
121    }
122
123    // Set the core file size limit to 0 to prevent core dumps.
124    set_core_file_size_limit_to_zero();
125
126    // Remove all DYLD_* environment variables, which can be used to subvert
127    // library loading.
128    let dyld_keys = env_keys_with_prefix(std::env::vars_os(), b"DYLD_");
129    for key in dyld_keys {
130        remove_env_var(key);
131    }
132}
133
134#[cfg(unix)]
135#[allow(unsafe_code)]
136fn set_core_file_size_limit_to_zero() {
137    let rlim = libc::rlimit {
138        rlim_cur: 0,
139        rlim_max: 0,
140    };
141    // SAFETY: `rlim` is fully initialized and passed by shared reference for the
142    // duration of the syscall only.
143    let ret_code = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim) };
144    if ret_code != 0 {
145        eprintln!(
146            "ERROR: setrlimit(RLIMIT_CORE) failed: {}",
147            std::io::Error::last_os_error()
148        );
149        std::process::exit(SET_RLIMIT_CORE_FAILED_EXIT_CODE);
150    }
151}
152
153/// Cap the main thread stack size if it is currently unlimited.
154///
155/// This is a complementary hardening measure to Rust's built-in stack clash
156/// protection (`-C probe-stack=inline`).  While probe-stack prevents attackers
157/// from jumping over the kernel guard page with a single large allocation,
158/// capping `RLIMIT_STACK` bounds stack resource exhaustion attacks.
159///
160/// Linux allows `RLIMIT_STACK` to be `RLIM_INFINITY` (unlimited).  We set it
161/// to a fixed cap (8 MiB) when that is the case.  If the current stack usage
162/// already exceeds the cap the call will fail harmlessly with `EINVAL`.
163// This function is a no-op on targets without RLIMIT_STACK (e.g., Windows).
164// The cfg gate prevents dead_code on those targets; the allow attribute
165// suppresses the remaining unused-variables warning on the function-level
166// `current` borrow when no cfg branch matches.
167#[allow(unused_variables)]
168#[allow(unsafe_code)]
169fn cap_stack_rlimit() {
170    #[cfg(any(
171        target_os = "linux",
172        target_os = "android",
173        target_os = "macos",
174        target_os = "freebsd",
175        target_os = "netbsd",
176        target_os = "openbsd"
177    ))]
178    {
179        const STACK_CAP_BYTES: u64 = 8 * 1024 * 1024;
180
181        let mut current: libc::rlimit = libc::rlimit {
182            rlim_cur: 0,
183            rlim_max: 0,
184        };
185        // SAFETY: `current` points to valid writable memory for the syscall to fill.
186        let ret = unsafe { libc::getrlimit(libc::RLIMIT_STACK, &mut current) };
187        if ret != 0 {
188            return;
189        }
190        // Only cap if the soft limit is currently unlimited.
191        if current.rlim_cur != libc::RLIM_INFINITY {
192            return;
193        }
194        let capped = libc::rlimit {
195            rlim_cur: STACK_CAP_BYTES,
196            rlim_max: STACK_CAP_BYTES,
197        };
198        // SAFETY: `capped` is fully initialized and passed by shared reference for
199        // the duration of the syscall only.
200        let _ = unsafe { libc::setrlimit(libc::RLIMIT_STACK, &capped) };
201    }
202}
203
204#[cfg(windows)]
205pub(crate) fn pre_main_hardening_windows() {
206    // Windows process hardening would involve using Job Objects to limit
207    // resource usage and restrict UI access, or using restricted tokens.
208    // This is currently a future enhancement.
209}
210
211// ===========================================================================
212// Pre-main constructors (life before main)
213//
214// These run before main() via linker-section constructor tables. Priority
215// 0-100 is reserved for the C runtime; we use 101+ to run after libc init.
216// Constraints: no heap allocation, no locks, no panics, no stdio.
217// ===========================================================================
218
219/// Post-hardening sanity: verify that core dumps are disabled on Unix.
220///
221/// Runs at priority 101 — after C runtime init but before `main()`.
222/// This is a lightweight check that catches misconfigurations early.
223#[cfg(unix)]
224#[ctor(unsafe, priority = 101)]
225fn verify_hardening_effectiveness() {
226    use std::mem::MaybeUninit;
227
228    // SAFETY: getrlimit is a pure syscall that fills the output struct.
229    #[cfg(any(
230        target_os = "linux",
231        target_os = "android",
232        target_os = "macos",
233        target_os = "freebsd",
234        target_os = "netbsd",
235        target_os = "openbsd"
236    ))]
237    {
238        // SAFETY: rlim is zeroed and passed by mutable ref to getrlimit which
239        // fills it. The syscall is infallible on valid pointers.
240        #[allow(unsafe_code)]
241        let mut rlim: libc::rlimit = unsafe { MaybeUninit::zeroed().assume_init() };
242        // SAFETY: getrlimit fills rlim via mutable pointer; the struct is
243        // zeroed above so no uninitialized memory is exposed.
244        #[allow(unsafe_code)]
245        let ret = unsafe { libc::getrlimit(libc::RLIMIT_CORE, &mut rlim) };
246        if ret == 0 && rlim.rlim_cur != 0 {
247            // Core dumps are not disabled. Write a warning directly to stderr
248            // using libc (stdio may not be initialized yet).
249            // SAFETY: msg is a static byte string; write is a POSIX syscall
250            // that does not dereference the pointer beyond msg.len() bytes.
251            #[allow(unsafe_code)]
252            unsafe {
253                let msg =
254                    b"warning: vtcode-process-hardening: RLIMIT_CORE is not zero after hardening\n";
255                libc::write(
256                    libc::STDERR_FILENO,
257                    msg.as_ptr() as *const libc::c_void,
258                    msg.len(),
259                );
260            }
261        }
262    }
263}
264
265/// Environment sanity: warn if essential env vars are missing.
266///
267/// Runs at priority 102. Checks that `HOME` (and on Unix, `USER`) are set,
268/// which are required for config resolution. Uses raw libc writes because
269/// stdio may not be initialized yet.
270#[cfg(unix)]
271#[ctor(unsafe, priority = 102)]
272fn check_environment_sanity() {
273    let home_set = std::env::var_os("HOME").is_some();
274    if !home_set {
275        // SAFETY: msg is a static byte string; write is a POSIX syscall
276        // that does not dereference the pointer beyond msg.len() bytes.
277        #[allow(unsafe_code)]
278        unsafe {
279            let msg =
280                b"warning: HOME environment variable is not set; config resolution may fail\n";
281            libc::write(
282                libc::STDERR_FILENO,
283                msg.as_ptr() as *const libc::c_void,
284                msg.len(),
285            );
286        }
287    }
288
289    // USER is not strictly required on all platforms but is a strong signal.
290    #[cfg(not(target_os = "windows"))]
291    {
292        let user_set = std::env::var_os("USER").is_some();
293        if !user_set {
294            // SAFETY: msg is a static byte string; write is a POSIX syscall
295            // that does not dereference the pointer beyond msg.len() bytes.
296            #[allow(unsafe_code)]
297            unsafe {
298                let msg = b"warning: USER environment variable is not set; some features may not work correctly\n";
299                libc::write(
300                    libc::STDERR_FILENO,
301                    msg.as_ptr() as *const libc::c_void,
302                    msg.len(),
303                );
304            }
305        }
306    }
307}
308
309#[cfg(unix)]
310fn env_keys_with_prefix<I>(vars: I, prefix: &[u8]) -> Vec<OsString>
311where
312    I: IntoIterator<Item = (OsString, OsString)>,
313{
314    vars.into_iter()
315        .filter_map(|(key, _)| {
316            key.as_os_str()
317                .as_bytes()
318                .starts_with(prefix)
319                .then_some(key)
320        })
321        .collect()
322}
323
324#[cfg(all(test, unix))]
325mod tests {
326    use super::*;
327    use pretty_assertions::assert_eq;
328    use std::ffi::OsStr;
329    use std::os::unix::ffi::OsStrExt;
330    use std::os::unix::ffi::OsStringExt;
331
332    #[test]
333    fn env_keys_with_prefix_handles_non_utf8_entries() {
334        // RÖDBURK - non-UTF8 environment variable name
335        let non_utf8_key1 = OsStr::from_bytes(b"R\xD6DBURK").to_os_string();
336        assert!(non_utf8_key1.clone().into_string().is_err());
337
338        let non_utf8_key2 = OsString::from_vec(vec![b'L', b'D', b'_', 0xF0]);
339        assert!(non_utf8_key2.clone().into_string().is_err());
340
341        let non_utf8_value = OsString::from_vec(vec![0xF0, 0x9F, 0x92, 0xA9]);
342
343        let keys = env_keys_with_prefix(
344            vec![
345                (non_utf8_key1, non_utf8_value.clone()),
346                (non_utf8_key2.clone(), non_utf8_value),
347            ],
348            b"LD_",
349        );
350
351        assert_eq!(
352            keys,
353            vec![non_utf8_key2],
354            "non-UTF-8 env entries with LD_ prefix should be retained"
355        );
356    }
357
358    #[test]
359    fn env_keys_with_prefix_filters_only_matching_keys() {
360        let ld_test_var = OsStr::from_bytes(b"LD_TEST");
361        let vars = vec![
362            (OsString::from("PATH"), OsString::from("/usr/bin")),
363            (ld_test_var.to_os_string(), OsString::from("1")),
364            (OsString::from("DYLD_FOO"), OsString::from("bar")),
365        ];
366
367        let keys = env_keys_with_prefix(vars, b"LD_");
368        assert_eq!(keys.len(), 1);
369        assert_eq!(keys[0].as_os_str(), ld_test_var);
370    }
371
372    #[test]
373    fn env_keys_with_prefix_returns_empty_when_no_matches_exist() {
374        let vars = vec![
375            (OsString::from("PATH"), OsString::from("/usr/bin")),
376            (OsString::from("HOME"), OsString::from("/tmp/home")),
377        ];
378
379        let keys = env_keys_with_prefix(vars, b"LD_");
380        assert!(keys.is_empty());
381    }
382
383    #[test]
384    fn env_keys_with_prefix_matches_exact_prefix_and_is_case_sensitive() {
385        let vars = vec![
386            (OsString::from("LD_"), OsString::from("exact-prefix")),
387            (OsString::from("Ld_TEST"), OsString::from("mixed-case")),
388            (OsString::from("LD_PRELOAD"), OsString::from("/tmp/lib.so")),
389        ];
390
391        let keys = env_keys_with_prefix(vars, b"LD_");
392        assert_eq!(
393            keys,
394            vec![OsString::from("LD_"), OsString::from("LD_PRELOAD")]
395        );
396    }
397
398    #[test]
399    fn env_keys_with_prefix_supports_dyld_prefix_filtering() {
400        let vars = vec![
401            (
402                OsString::from("DYLD_INSERT_LIBRARIES"),
403                OsString::from("/tmp/inject.dylib"),
404            ),
405            (OsString::from("DYLD_FOO"), OsString::from("bar")),
406            (
407                OsString::from("LD_PRELOAD"),
408                OsString::from("/tmp/other.so"),
409            ),
410        ];
411
412        let keys = env_keys_with_prefix(vars, b"DYLD_");
413        assert_eq!(
414            keys,
415            vec![
416                OsString::from("DYLD_INSERT_LIBRARIES"),
417                OsString::from("DYLD_FOO")
418            ]
419        );
420    }
421}