Skip to main content

nexus_shield/endpoint/
rootkit_detector.rs

1// ============================================================================
2// File: endpoint/rootkit_detector.rs
3// Description: System integrity verification and rootkit detection
4// Author: Andrew Jewell Sr. - AutomataNexus
5// Updated: March 24, 2026
6// ============================================================================
7//! Rootkit Detector — verifies system binary integrity, detects hidden processes,
8//! checks for suspicious kernel modules, and monitors LD_PRELOAD injection.
9
10use super::{DetectionCategory, RecommendedAction, ScanResult, Severity};
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashMap;
15use std::io::Read;
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19
20/// Known rootkit kernel module names.
21const KNOWN_ROOTKIT_MODULES: &[&str] = &[
22    "diamorphine", "reptile", "bdvl", "suterusu", "adore-ng",
23    "knark", "rkkit", "heroin", "override", "modhide",
24    "enyelkm", "kbeast", "azazel", "jynx", "brootus",
25    "nurupo", "phalanx", "suckit", "synapsys", "khook",
26];
27
28/// Configuration for the rootkit detector.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct RootkitConfig {
31    pub scan_interval_secs: u64,
32    pub system_dirs: Vec<PathBuf>,
33    pub hash_db_path: PathBuf,
34    pub check_kernel_modules: bool,
35    pub check_ld_preload: bool,
36}
37
38impl RootkitConfig {
39    pub fn new(data_dir: PathBuf) -> Self {
40        Self {
41            scan_interval_secs: 300,
42            system_dirs: vec![
43                PathBuf::from("/usr/bin"),
44                PathBuf::from("/usr/sbin"),
45                PathBuf::from("/bin"),
46                PathBuf::from("/sbin"),
47            ],
48            hash_db_path: data_dir.join("system-hashes.json"),
49            check_kernel_modules: true,
50            check_ld_preload: true,
51        }
52    }
53}
54
55/// Rootkit detector with system integrity verification.
56pub struct RootkitDetector {
57    config: RootkitConfig,
58    system_hashes: RwLock<HashMap<String, String>>, // path -> sha256
59    running: Arc<AtomicBool>,
60}
61
62impl RootkitDetector {
63    pub fn new(config: RootkitConfig) -> Self {
64        let detector = Self {
65            config: config.clone(),
66            system_hashes: RwLock::new(HashMap::new()),
67            running: Arc::new(AtomicBool::new(true)),
68        };
69        detector.load_baseline();
70        detector
71    }
72
73    /// Build a baseline of SHA-256 hashes for all system binaries.
74    /// Returns the number of files hashed.
75    pub fn build_baseline(&self) -> Result<usize, String> {
76        let mut hashes = HashMap::new();
77
78        for dir in &self.config.system_dirs {
79            if !dir.exists() {
80                continue;
81            }
82
83            let entries = std::fs::read_dir(dir).map_err(|e| {
84                format!("Cannot read {}: {}", dir.display(), e)
85            })?;
86
87            for entry in entries.flatten() {
88                let path = entry.path();
89                if !path.is_file() {
90                    continue;
91                }
92
93                match compute_file_hash(&path) {
94                    Ok(hash) => {
95                        hashes.insert(path.to_string_lossy().to_string(), hash);
96                    }
97                    Err(_) => continue, // Skip unreadable files
98                }
99            }
100        }
101
102        let count = hashes.len();
103        *self.system_hashes.write() = hashes;
104        self.save_baseline();
105        Ok(count)
106    }
107
108    /// Verify system binary integrity against the stored baseline.
109    pub fn verify_integrity(&self) -> Vec<ScanResult> {
110        let mut results = Vec::new();
111        let baseline = self.system_hashes.read();
112
113        if baseline.is_empty() {
114            return results;
115        }
116
117        // Check each baselined file
118        for (path_str, expected_hash) in baseline.iter() {
119            let path = Path::new(path_str);
120
121            if !path.exists() {
122                // Binary was removed
123                results.push(ScanResult::new(
124                    "rootkit_detector",
125                    path_str,
126                    Severity::Medium,
127                    DetectionCategory::RootkitIndicator {
128                        technique: "binary_removed".to_string(),
129                    },
130                    format!("System binary removed: {} — may indicate rootkit replacing binaries", path_str),
131                    0.6,
132                    RecommendedAction::Alert,
133                ));
134                continue;
135            }
136
137            match compute_file_hash(path) {
138                Ok(current_hash) => {
139                    if &current_hash != expected_hash {
140                        results.push(ScanResult::new(
141                            "rootkit_detector",
142                            path_str,
143                            Severity::Critical,
144                            DetectionCategory::RootkitIndicator {
145                                technique: "binary_modified".to_string(),
146                            },
147                            format!(
148                                "System binary MODIFIED: {} — expected hash {:.16}…, got {:.16}…",
149                                path_str,
150                                expected_hash,
151                                current_hash,
152                            ),
153                            0.95,
154                            RecommendedAction::Alert,
155                        ));
156                    }
157                }
158                Err(_) => {
159                    // Can't read — permissions changed?
160                    results.push(ScanResult::new(
161                        "rootkit_detector",
162                        path_str,
163                        Severity::Medium,
164                        DetectionCategory::RootkitIndicator {
165                            technique: "binary_unreadable".to_string(),
166                        },
167                        format!("System binary unreadable: {} — permissions may have been modified", path_str),
168                        0.5,
169                        RecommendedAction::Alert,
170                    ));
171                }
172            }
173        }
174
175        // Check for NEW files in system dirs not in baseline
176        for dir in &self.config.system_dirs {
177            if !dir.exists() {
178                continue;
179            }
180            if let Ok(entries) = std::fs::read_dir(dir) {
181                for entry in entries.flatten() {
182                    let path = entry.path();
183                    if path.is_file() {
184                        let path_str = path.to_string_lossy().to_string();
185                        if !baseline.contains_key(&path_str) {
186                            results.push(ScanResult::new(
187                                "rootkit_detector",
188                                &path_str,
189                                Severity::Info,
190                                DetectionCategory::RootkitIndicator {
191                                    technique: "new_binary".to_string(),
192                                },
193                                format!("New binary in system directory: {}", path_str),
194                                0.2,
195                                RecommendedAction::LogOnly,
196                            ));
197                        }
198                    }
199                }
200            }
201        }
202
203        results
204    }
205
206    /// Check loaded kernel modules for known rootkit names.
207    pub fn check_kernel_modules(&self) -> Vec<ScanResult> {
208        if !self.config.check_kernel_modules {
209            return Vec::new();
210        }
211
212        let modules_content = match std::fs::read_to_string("/proc/modules") {
213            Ok(c) => c,
214            Err(_) => return Vec::new(),
215        };
216
217        let mut results = Vec::new();
218
219        for line in modules_content.lines() {
220            let module_name = match line.split_whitespace().next() {
221                Some(n) => n,
222                None => continue,
223            };
224
225            let module_lower = module_name.to_lowercase();
226            for rootkit_name in KNOWN_ROOTKIT_MODULES {
227                if module_lower.contains(rootkit_name) {
228                    results.push(ScanResult::new(
229                        "rootkit_detector",
230                        module_name,
231                        Severity::Critical,
232                        DetectionCategory::RootkitIndicator {
233                            technique: "rootkit_kernel_module".to_string(),
234                        },
235                        format!(
236                            "ROOTKIT kernel module detected: '{}' matches known rootkit '{}'",
237                            module_name, rootkit_name
238                        ),
239                        0.95,
240                        RecommendedAction::Alert,
241                    ));
242                    break;
243                }
244            }
245        }
246
247        results
248    }
249
250    /// Check for LD_PRELOAD injection.
251    pub fn check_ld_preload(&self) -> Vec<ScanResult> {
252        if !self.config.check_ld_preload {
253            return Vec::new();
254        }
255
256        let mut results = Vec::new();
257
258        // Check /etc/ld.so.preload
259        if let Ok(content) = std::fs::read_to_string("/etc/ld.so.preload") {
260            let content = content.trim();
261            if !content.is_empty() && !content.starts_with('#') {
262                results.push(ScanResult::new(
263                    "rootkit_detector",
264                    "/etc/ld.so.preload",
265                    Severity::High,
266                    DetectionCategory::RootkitIndicator {
267                        technique: "ld_preload_file".to_string(),
268                    },
269                    format!(
270                        "/etc/ld.so.preload contains entries: '{}' — libraries will be injected into ALL processes",
271                        content.lines().next().unwrap_or("")
272                    ),
273                    0.8,
274                    RecommendedAction::Alert,
275                ));
276            }
277        }
278
279        // Check current process environment for LD_PRELOAD
280        if let Ok(environ) = std::fs::read("/proc/self/environ") {
281            let env_str = String::from_utf8_lossy(&environ);
282            // environ is null-separated
283            for var in env_str.split('\0') {
284                if var.starts_with("LD_PRELOAD=") {
285                    let value = &var["LD_PRELOAD=".len()..];
286                    if !value.is_empty() {
287                        results.push(ScanResult::new(
288                            "rootkit_detector",
289                            "LD_PRELOAD",
290                            Severity::High,
291                            DetectionCategory::RootkitIndicator {
292                                technique: "ld_preload_env".to_string(),
293                            },
294                            format!(
295                                "LD_PRELOAD set in current process: '{}' — possible library injection",
296                                value
297                            ),
298                            0.85,
299                            RecommendedAction::Alert,
300                        ));
301                    }
302                }
303            }
304        }
305
306        // Check PID 1 (init/systemd) environment for LD_PRELOAD
307        if let Ok(environ) = std::fs::read("/proc/1/environ") {
308            let env_str = String::from_utf8_lossy(&environ);
309            for var in env_str.split('\0') {
310                if var.starts_with("LD_PRELOAD=") {
311                    let value = &var["LD_PRELOAD=".len()..];
312                    if !value.is_empty() {
313                        results.push(ScanResult::new(
314                            "rootkit_detector",
315                            "LD_PRELOAD:init",
316                            Severity::Critical,
317                            DetectionCategory::RootkitIndicator {
318                                technique: "ld_preload_init".to_string(),
319                            },
320                            format!(
321                                "LD_PRELOAD set in init process (PID 1): '{}' — system-wide library injection",
322                                value
323                            ),
324                            0.95,
325                            RecommendedAction::Alert,
326                        ));
327                    }
328                }
329            }
330        }
331
332        results
333    }
334
335    /// Check for hidden processes (PIDs visible in readdir but inaccessible).
336    pub fn check_hidden_processes(&self) -> Vec<ScanResult> {
337        let mut results = Vec::new();
338
339        let entries = match std::fs::read_dir("/proc") {
340            Ok(e) => e,
341            Err(_) => return results,
342        };
343
344        let my_uid = nix::unistd::getuid().as_raw();
345
346        for entry in entries.flatten() {
347            let name = entry.file_name();
348            let pid: u32 = match name.to_string_lossy().parse() {
349                Ok(p) => p,
350                Err(_) => continue,
351            };
352
353            // Try to read /proc/[pid]/status
354            let status_path = format!("/proc/{}/status", pid);
355            match std::fs::read_to_string(&status_path) {
356                Ok(status) => {
357                    // Check if UID matches ours — if so, we should be able to read everything
358                    let proc_uid: u32 = status
359                        .lines()
360                        .find(|l| l.starts_with("Uid:"))
361                        .and_then(|l| l.split_whitespace().nth(1))
362                        .and_then(|s| s.parse().ok())
363                        .unwrap_or(u32::MAX);
364
365                    if proc_uid == my_uid {
366                        // Our process — check if /proc/[pid]/exe is accessible
367                        let exe_path = format!("/proc/{}/exe", pid);
368                        if std::fs::read_link(&exe_path).is_err() {
369                            // Our own process but can't read exe — suspicious
370                            results.push(ScanResult::new(
371                                "rootkit_detector",
372                                format!("pid:{}", pid),
373                                Severity::Medium,
374                                DetectionCategory::RootkitIndicator {
375                                    technique: "hidden_process_exe".to_string(),
376                                },
377                                format!(
378                                    "Process {} owned by us but /proc/{}/exe inaccessible — possible process hiding",
379                                    pid, pid
380                                ),
381                                0.5,
382                                RecommendedAction::Alert,
383                            ));
384                        }
385                    }
386                }
387                Err(_) => {
388                    // PID visible in readdir but status unreadable
389                    // This is normal for other users' processes — only flag if we're root
390                    if my_uid == 0 {
391                        results.push(ScanResult::new(
392                            "rootkit_detector",
393                            format!("pid:{}", pid),
394                            Severity::High,
395                            DetectionCategory::RootkitIndicator {
396                                technique: "hidden_process".to_string(),
397                            },
398                            format!(
399                                "Process {} visible in /proc but status unreadable as root — possible kernel-level hiding",
400                                pid
401                            ),
402                            0.85,
403                            RecommendedAction::Alert,
404                        ));
405                    }
406                }
407            }
408        }
409
410        results
411    }
412
413    /// Run all rootkit detection checks.
414    pub fn scan_all(&self) -> Vec<ScanResult> {
415        let mut results = Vec::new();
416        results.extend(self.verify_integrity());
417        results.extend(self.check_kernel_modules());
418        results.extend(self.check_ld_preload());
419        results.extend(self.check_hidden_processes());
420        results
421    }
422
423    /// Save the baseline hash database to disk.
424    pub fn save_baseline(&self) {
425        let hashes = self.system_hashes.read();
426        if let Ok(json) = serde_json::to_string_pretty(&*hashes) {
427            if let Some(parent) = self.config.hash_db_path.parent() {
428                let _ = std::fs::create_dir_all(parent);
429            }
430            let tmp = self.config.hash_db_path.with_extension("json.tmp");
431            if std::fs::write(&tmp, &json).is_ok() {
432                let _ = std::fs::rename(&tmp, &self.config.hash_db_path);
433            }
434        }
435    }
436
437    /// Load the baseline hash database from disk.
438    pub fn load_baseline(&self) {
439        if let Ok(content) = std::fs::read_to_string(&self.config.hash_db_path) {
440            if let Ok(hashes) = serde_json::from_str::<HashMap<String, String>>(&content) {
441                *self.system_hashes.write() = hashes;
442            }
443        }
444    }
445
446    /// Start periodic rootkit scanning in a background task.
447    pub fn start(
448        self: Arc<Self>,
449        detection_tx: tokio::sync::mpsc::UnboundedSender<ScanResult>,
450    ) -> tokio::task::JoinHandle<()> {
451        let running = Arc::clone(&self.running);
452        let interval_secs = self.config.scan_interval_secs;
453
454        tokio::spawn(async move {
455            let mut interval =
456                tokio::time::interval(std::time::Duration::from_secs(interval_secs));
457
458            while running.load(Ordering::Relaxed) {
459                interval.tick().await;
460                let results = self.scan_all();
461                for result in results {
462                    if detection_tx.send(result).is_err() {
463                        return;
464                    }
465                }
466            }
467        })
468    }
469
470    pub fn stop(&self) {
471        self.running.store(false, Ordering::Relaxed);
472    }
473
474    /// Get the number of baselined files.
475    pub fn baseline_count(&self) -> usize {
476        self.system_hashes.read().len()
477    }
478}
479
480/// Compute SHA-256 of a file in streaming 8KB chunks.
481fn compute_file_hash(path: &Path) -> std::io::Result<String> {
482    let mut file = std::fs::File::open(path)?;
483    let mut hasher = Sha256::new();
484    let mut buf = [0u8; 8192];
485    loop {
486        let n = file.read(&mut buf)?;
487        if n == 0 {
488            break;
489        }
490        hasher.update(&buf[..n]);
491    }
492    Ok(hex::encode(hasher.finalize()))
493}
494
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn build_baseline_on_temp_dir() {
502        let dir = std::env::temp_dir().join(format!("nexus-rootkit-test-{}", uuid::Uuid::new_v4()));
503        let bin_dir = dir.join("bin");
504        let _ = std::fs::create_dir_all(&bin_dir);
505
506        // Create test "binaries"
507        std::fs::write(bin_dir.join("ls"), b"fake ls binary").unwrap();
508        std::fs::write(bin_dir.join("cat"), b"fake cat binary").unwrap();
509        std::fs::write(bin_dir.join("grep"), b"fake grep binary").unwrap();
510
511        let config = RootkitConfig {
512            scan_interval_secs: 300,
513            system_dirs: vec![bin_dir.clone()],
514            hash_db_path: dir.join("hashes.json"),
515            check_kernel_modules: false,
516            check_ld_preload: false,
517        };
518
519        let detector = RootkitDetector::new(config);
520        let count = detector.build_baseline().unwrap();
521        assert_eq!(count, 3);
522        assert_eq!(detector.baseline_count(), 3);
523
524        let _ = std::fs::remove_dir_all(&dir);
525    }
526
527    #[test]
528    fn verify_detects_modified_file() {
529        let dir = std::env::temp_dir().join(format!("nexus-rootkit-mod-{}", uuid::Uuid::new_v4()));
530        let bin_dir = dir.join("bin");
531        let _ = std::fs::create_dir_all(&bin_dir);
532
533        std::fs::write(bin_dir.join("ls"), b"original content").unwrap();
534
535        let config = RootkitConfig {
536            scan_interval_secs: 300,
537            system_dirs: vec![bin_dir.clone()],
538            hash_db_path: dir.join("hashes.json"),
539            check_kernel_modules: false,
540            check_ld_preload: false,
541        };
542
543        let detector = RootkitDetector::new(config);
544        detector.build_baseline().unwrap();
545
546        // Modify the file
547        std::fs::write(bin_dir.join("ls"), b"MODIFIED by rootkit!").unwrap();
548
549        let results = detector.verify_integrity();
550        assert!(!results.is_empty());
551        assert!(results.iter().any(|r| r.severity == Severity::Critical));
552        assert!(results.iter().any(|r| r.description.contains("MODIFIED")));
553
554        let _ = std::fs::remove_dir_all(&dir);
555    }
556
557    #[test]
558    fn verify_detects_removed_file() {
559        let dir = std::env::temp_dir().join(format!("nexus-rootkit-rm-{}", uuid::Uuid::new_v4()));
560        let bin_dir = dir.join("bin");
561        let _ = std::fs::create_dir_all(&bin_dir);
562
563        std::fs::write(bin_dir.join("ls"), b"binary").unwrap();
564
565        let config = RootkitConfig {
566            scan_interval_secs: 300,
567            system_dirs: vec![bin_dir.clone()],
568            hash_db_path: dir.join("hashes.json"),
569            check_kernel_modules: false,
570            check_ld_preload: false,
571        };
572
573        let detector = RootkitDetector::new(config);
574        detector.build_baseline().unwrap();
575
576        // Remove the file
577        std::fs::remove_file(bin_dir.join("ls")).unwrap();
578
579        let results = detector.verify_integrity();
580        assert!(results.iter().any(|r| r.description.contains("removed")));
581
582        let _ = std::fs::remove_dir_all(&dir);
583    }
584
585    #[test]
586    fn kernel_module_name_matching() {
587        for name in KNOWN_ROOTKIT_MODULES {
588            assert!(
589                name.to_lowercase() == *name,
590                "Rootkit name '{}' should be lowercase",
591                name
592            );
593        }
594        // Check a known rootkit is in the list
595        assert!(KNOWN_ROOTKIT_MODULES.contains(&"diamorphine"));
596        assert!(KNOWN_ROOTKIT_MODULES.contains(&"reptile"));
597    }
598
599    #[test]
600    fn ld_preload_environ_parsing() {
601        // Simulate /proc/self/environ format (null-separated)
602        let environ = "HOME=/root\0PATH=/usr/bin\0LD_PRELOAD=/tmp/evil.so\0TERM=xterm\0";
603        let has_preload = environ
604            .split('\0')
605            .any(|v| v.starts_with("LD_PRELOAD=") && !v["LD_PRELOAD=".len()..].is_empty());
606        assert!(has_preload);
607
608        // Without LD_PRELOAD
609        let clean = "HOME=/root\0PATH=/usr/bin\0TERM=xterm\0";
610        let no_preload = clean
611            .split('\0')
612            .any(|v| v.starts_with("LD_PRELOAD=") && !v["LD_PRELOAD=".len()..].is_empty());
613        assert!(!no_preload);
614    }
615
616    #[test]
617    fn baseline_save_load_roundtrip() {
618        let dir = std::env::temp_dir().join(format!("nexus-rootkit-rt-{}", uuid::Uuid::new_v4()));
619        let bin_dir = dir.join("bin");
620        let _ = std::fs::create_dir_all(&bin_dir);
621
622        std::fs::write(bin_dir.join("test"), b"test binary").unwrap();
623
624        let config = RootkitConfig {
625            scan_interval_secs: 300,
626            system_dirs: vec![bin_dir.clone()],
627            hash_db_path: dir.join("hashes.json"),
628            check_kernel_modules: false,
629            check_ld_preload: false,
630        };
631
632        let detector = RootkitDetector::new(config.clone());
633        detector.build_baseline().unwrap();
634        assert_eq!(detector.baseline_count(), 1);
635
636        // New detector should load saved baseline
637        let detector2 = RootkitDetector::new(config);
638        assert_eq!(detector2.baseline_count(), 1);
639
640        let _ = std::fs::remove_dir_all(&dir);
641    }
642
643    #[test]
644    fn scan_all_no_crash() {
645        let dir = std::env::temp_dir().join("nexus-rootkit-nocrash");
646        let config = RootkitConfig {
647            scan_interval_secs: 300,
648            system_dirs: vec![],
649            hash_db_path: dir.join("hashes.json"),
650            check_kernel_modules: true,
651            check_ld_preload: true,
652        };
653        let detector = RootkitDetector::new(config);
654        let _ = detector.scan_all(); // Should not crash
655    }
656}