1use 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
20const 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#[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
55pub struct RootkitDetector {
57 config: RootkitConfig,
58 system_hashes: RwLock<HashMap<String, String>>, 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 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, }
99 }
100 }
101
102 let count = hashes.len();
103 *self.system_hashes.write() = hashes;
104 self.save_baseline();
105 Ok(count)
106 }
107
108 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 for (path_str, expected_hash) in baseline.iter() {
119 let path = Path::new(path_str);
120
121 if !path.exists() {
122 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 ¤t_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 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 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 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 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 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 if let Ok(environ) = std::fs::read("/proc/self/environ") {
281 let env_str = String::from_utf8_lossy(&environ);
282 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 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 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 let status_path = format!("/proc/{}/status", pid);
355 match std::fs::read_to_string(&status_path) {
356 Ok(status) => {
357 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 let exe_path = format!("/proc/{}/exe", pid);
368 if std::fs::read_link(&exe_path).is_err() {
369 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 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 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 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 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 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 pub fn baseline_count(&self) -> usize {
476 self.system_hashes.read().len()
477 }
478}
479
480fn 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 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 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 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 assert!(KNOWN_ROOTKIT_MODULES.contains(&"diamorphine"));
596 assert!(KNOWN_ROOTKIT_MODULES.contains(&"reptile"));
597 }
598
599 #[test]
600 fn ld_preload_environ_parsing() {
601 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 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 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(); }
656}