1use memf_core::object_reader::ObjectReader;
14use memf_format::PhysicalMemoryProvider;
15use serde::Serialize;
16
17use crate::{Error, Result};
18
19#[derive(Debug, Clone, Serialize)]
21pub struct DeletedExeInfo {
22 pub pid: u32,
24 pub comm: String,
26 pub exe_path: String,
28 pub is_deleted: bool,
30 pub is_suspicious: bool,
32}
33
34pub use crate::heuristics::classify_deleted_exe;
47
48pub fn walk_deleted_exe<P: PhysicalMemoryProvider>(
56 reader: &ObjectReader<P>,
57) -> Result<Vec<DeletedExeInfo>> {
58 let init_task_addr = reader
59 .symbols()
60 .symbol_address("init_task")
61 .ok_or_else(|| Error::MissingKernelSymbol {
62 name: "init_task".into(),
63 })?;
64
65 let tasks_offset = reader
66 .symbols()
67 .field_offset("task_struct", "tasks")
68 .ok_or_else(|| Error::MissingField {
69 struct_name: "task_struct".into(),
70 field_name: "tasks".into(),
71 })?;
72
73 let head_vaddr = init_task_addr + tasks_offset;
74 let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
75
76 let mut results = Vec::new();
77
78 if let Some(info) = read_deleted_exe_info(reader, init_task_addr) {
80 results.push(info);
81 }
82
83 for &task_addr in &task_addrs {
84 if let Some(info) = read_deleted_exe_info(reader, task_addr) {
85 results.push(info);
86 }
87 }
88
89 results.sort_by_key(|r| r.pid);
90 Ok(results)
91}
92
93fn read_deleted_exe_info<P: PhysicalMemoryProvider>(
97 reader: &ObjectReader<P>,
98 task_addr: u64,
99) -> Option<DeletedExeInfo> {
100 let pid: u32 = reader.read_field(task_addr, "task_struct", "pid").ok()?;
101 let comm = reader
102 .read_field_string(task_addr, "task_struct", "comm", 16)
103 .unwrap_or_default();
104
105 let mm_ptr: u64 = reader.read_field(task_addr, "task_struct", "mm").ok()?;
107 if mm_ptr == 0 {
108 return None;
109 }
110
111 let exe_file_ptr: u64 = reader.read_field(mm_ptr, "mm_struct", "exe_file").ok()?;
113 if exe_file_ptr == 0 {
114 return None;
115 }
116
117 let exe_path = read_file_dentry_name(reader, exe_file_ptr).unwrap_or_default();
119
120 let is_deleted = exe_path.contains("(deleted)");
121 let is_suspicious = classify_deleted_exe(&exe_path, &comm);
122
123 Some(DeletedExeInfo {
124 pid,
125 comm,
126 exe_path,
127 is_deleted,
128 is_suspicious,
129 })
130}
131
132fn read_file_dentry_name<P: PhysicalMemoryProvider>(
138 reader: &ObjectReader<P>,
139 file_ptr: u64,
140) -> Option<String> {
141 let f_path_offset = reader.symbols().field_offset("file", "f_path")?;
142 let dentry_in_path = reader.symbols().field_offset("path", "dentry")?;
143 let d_name_offset = reader.symbols().field_offset("dentry", "d_name")?;
144 let name_in_qstr = reader.symbols().field_offset("qstr", "name")?;
145
146 let dentry_addr = file_ptr + f_path_offset + dentry_in_path;
148 let dentry_raw = reader.read_bytes(dentry_addr, 8).ok()?;
149 let dentry_ptr = u64::from_le_bytes(dentry_raw.try_into().ok()?);
150 if dentry_ptr == 0 {
151 return None;
152 }
153
154 let name_addr = dentry_ptr + d_name_offset + name_in_qstr;
156 let name_raw = reader.read_bytes(name_addr, 8).ok()?;
157 let name_ptr = u64::from_le_bytes(name_raw.try_into().ok()?);
158 if name_ptr == 0 {
159 return None;
160 }
161
162 reader.read_string(name_ptr, 256).ok()
163}
164
165pub fn is_deleted_exe(exe_path: &str) -> bool {
174 exe_path.ends_with(" (deleted)") || exe_path.ends_with("(deleted)")
175}
176
177pub fn strip_deleted_suffix(exe_path: &str) -> &str {
183 if let Some(stripped) = exe_path.strip_suffix("(deleted)") {
184 stripped.trim_end()
185 } else {
186 exe_path
187 }
188}
189
190#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
193pub struct DeletedExeFinding {
194 pub pid: u32,
196 pub comm: String,
198 pub exe_path: String,
200 pub original_path: String,
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use memf_core::object_reader::ObjectReader;
208
209 const KNOWN_BENIGN_COMMS: &[&str] = &[
210 "apt",
211 "apt-get",
212 "apt-check",
213 "aptd",
214 "dpkg",
215 "dpkg-deb",
216 "yum",
217 "dnf",
218 "rpm",
219 "rpmdb",
220 "packagekitd",
221 "unattended-upgr",
222 ];
223 use memf_core::test_builders::{flags, PageTableBuilder, SyntheticPhysMem};
224 use memf_core::vas::{TranslationMode, VirtualAddressSpace};
225 use memf_symbols::isf::IsfResolver;
226 use memf_symbols::test_builders::IsfBuilder;
227
228 #[test]
231 fn classify_normal_benign() {
232 assert!(
234 !classify_deleted_exe("/usr/bin/nginx", "nginx"),
235 "a live (non-deleted) executable must not be flagged suspicious"
236 );
237 }
238
239 #[test]
240 fn classify_deleted_suspicious() {
241 assert!(
243 classify_deleted_exe("/tmp/.x11 (deleted)", "payload"),
244 "a deleted exe from unknown process 'payload' must be suspicious"
245 );
246 }
247
248 #[test]
249 fn classify_deleted_apt_benign() {
250 assert!(
252 !classify_deleted_exe("/usr/bin/apt (deleted)", "apt"),
253 "apt with deleted exe during package upgrade must not be suspicious"
254 );
255 }
256
257 #[test]
258 fn classify_deleted_dpkg_benign() {
259 assert!(
261 !classify_deleted_exe("/usr/bin/dpkg (deleted)", "dpkg"),
262 "dpkg with deleted exe during package upgrade must not be suspicious"
263 );
264 }
265
266 #[test]
267 fn classify_kernel_thread_benign() {
268 assert!(
270 !classify_deleted_exe("", ""),
271 "kernel thread with empty exe and comm must not be suspicious"
272 );
273 }
274
275 #[test]
276 fn classify_empty_path_benign() {
277 assert!(
280 !classify_deleted_exe("", "kworker/0:1"),
281 "empty exe path must not be flagged suspicious"
282 );
283 }
284
285 #[test]
286 fn classify_deleted_yum_benign() {
287 assert!(
289 !classify_deleted_exe("/usr/bin/yum (deleted)", "yum"),
290 "yum with deleted exe during package upgrade must not be suspicious"
291 );
292 }
293
294 #[test]
295 fn classify_deleted_with_suspicious_name() {
296 assert!(
298 classify_deleted_exe("/dev/shm/.hidden (deleted)", "a]"),
299 "deleted exe from /dev/shm with obfuscated name must be suspicious"
300 );
301 }
302
303 #[test]
304 fn classify_deleted_empty_comm_benign() {
305 assert!(
307 !classify_deleted_exe("/tmp/.evil (deleted)", ""),
308 "empty comm with deleted exe must not be suspicious"
309 );
310 }
311
312 #[test]
313 fn classify_all_known_benign_comms() {
314 for comm in KNOWN_BENIGN_COMMS {
316 let path = format!("/usr/bin/{comm} (deleted)");
317 assert!(
318 !classify_deleted_exe(&path, comm),
319 "known-benign comm '{comm}' must not be flagged suspicious"
320 );
321 }
322 }
323
324 #[test]
325 fn classify_benign_comm_case_insensitive() {
326 assert!(!classify_deleted_exe("/usr/bin/APT (deleted)", "APT"));
328 assert!(!classify_deleted_exe("/usr/bin/Dpkg (deleted)", "Dpkg"));
329 assert!(!classify_deleted_exe("/usr/bin/YUM (deleted)", "YUM"));
330 }
331
332 #[test]
333 fn classify_near_benign_name_suspicious() {
334 assert!(classify_deleted_exe("/usr/bin/apt2 (deleted)", "apt2"));
336 assert!(classify_deleted_exe(
338 "/usr/bin/dpkg-query (deleted)",
339 "dpkg-query"
340 ));
341 }
342
343 #[test]
344 fn classify_deleted_exe_info_struct_fields() {
345 let info = DeletedExeInfo {
346 pid: 999,
347 comm: "evil".to_string(),
348 exe_path: "/tmp/.x (deleted)".to_string(),
349 is_deleted: true,
350 is_suspicious: true,
351 };
352 let cloned = info.clone();
353 assert_eq!(cloned.pid, 999);
354 assert!(cloned.is_deleted);
355 assert!(cloned.is_suspicious);
356 let dbg = format!("{cloned:?}");
357 assert!(dbg.contains("evil"));
358 }
359
360 #[test]
361 fn classify_deleted_exe_info_serializes_to_json() {
362 let info = DeletedExeInfo {
363 pid: 42,
364 comm: "malware".to_string(),
365 exe_path: "/dev/shm/.bin (deleted)".to_string(),
366 is_deleted: true,
367 is_suspicious: true,
368 };
369 let json = serde_json::to_string(&info).unwrap();
370 assert!(json.contains("\"pid\":42"));
371 assert!(json.contains("\"is_deleted\":true"));
372 assert!(json.contains("\"is_suspicious\":true"));
373 }
374
375 fn make_reader_no_symbol() -> ObjectReader<SyntheticPhysMem> {
379 let isf = IsfBuilder::new()
380 .add_struct("task_struct", 128)
381 .add_field("task_struct", "pid", 0, "int")
382 .add_field("task_struct", "tasks", 16, "list_head")
383 .add_struct("list_head", 16)
384 .add_field("list_head", "next", 0, "pointer")
385 .add_field("list_head", "prev", 8, "pointer")
386 .build_json();
387
388 let resolver = IsfResolver::from_value(&isf).unwrap();
389 let (cr3, mem) = PageTableBuilder::new().build();
390 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
391 ObjectReader::new(vas, Box::new(resolver))
392 }
393
394 #[test]
395 fn walk_no_symbol_returns_error() {
396 let reader = make_reader_no_symbol();
398 let result = walk_deleted_exe(&reader);
399 assert!(
400 matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "init_task"),
401 "expected MissingKernelSymbol {{name: \"init_task\"}}, got {result:?}"
402 );
403 }
404
405 #[test]
406 fn walk_missing_tasks_field_returns_missing_field() {
407 let isf = IsfBuilder::new()
408 .add_struct("task_struct", 128)
409 .add_field("task_struct", "pid", 0, "int")
410 .add_struct("list_head", 16)
412 .add_field("list_head", "next", 0, "pointer")
413 .add_field("list_head", "prev", 8, "pointer")
414 .add_symbol("init_task", 0xFFFF_8000_0010_0000)
415 .build_json();
416 let resolver = IsfResolver::from_value(&isf).unwrap();
417 let (cr3, mem) = PageTableBuilder::new().build();
418 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
419 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
420 let result = walk_deleted_exe(&reader);
421 assert!(
422 matches!(result, Err(crate::Error::MissingField { ref struct_name, ref field_name }) if struct_name == "task_struct" && field_name == "tasks"),
423 "expected MissingField task_struct.tasks, got {result:?}"
424 );
425 }
426
427 #[test]
431 fn walk_deleted_exe_mm_non_null_exe_file_null_returns_empty() {
432 let tasks_offset: u64 = 0x10;
433 let mm_offset: u64 = 0x30;
434 let sym_vaddr: u64 = 0xFFFF_8800_0090_0000;
435 let sym_paddr: u64 = 0x0090_0000; let mm_vaddr: u64 = 0xFFFF_8800_0091_0000;
437 let mm_paddr: u64 = 0x0091_0000;
438
439 let mut task_page = [0u8; 4096];
441 task_page[0..4].copy_from_slice(&5u32.to_le_bytes());
443 let self_ptr = sym_vaddr + tasks_offset;
445 task_page[tasks_offset as usize..tasks_offset as usize + 8]
446 .copy_from_slice(&self_ptr.to_le_bytes());
447 task_page[mm_offset as usize..mm_offset as usize + 8]
449 .copy_from_slice(&mm_vaddr.to_le_bytes());
450 task_page[0x20..0x26].copy_from_slice(b"worker");
452
453 let mm_page = [0u8; 4096];
455
456 let isf = IsfBuilder::new()
457 .add_symbol("init_task", sym_vaddr)
458 .add_struct("list_head", 0x10)
459 .add_field("list_head", "next", 0x00, "pointer")
460 .add_field("list_head", "prev", 0x08, "pointer")
461 .add_struct("task_struct", 0x400)
462 .add_field("task_struct", "tasks", tasks_offset, "pointer")
463 .add_field("task_struct", "pid", 0x00, "unsigned int")
464 .add_field("task_struct", "comm", 0x20, "char")
465 .add_field("task_struct", "mm", mm_offset, "pointer")
466 .add_struct("mm_struct", 0x200)
467 .add_field("mm_struct", "exe_file", 0x18, "pointer")
468 .build_json();
469 let resolver = IsfResolver::from_value(&isf).unwrap();
470
471 let (cr3, mem) = PageTableBuilder::new()
472 .map_4k(sym_vaddr, sym_paddr, flags::WRITABLE)
473 .write_phys(sym_paddr, &task_page)
474 .map_4k(mm_vaddr, mm_paddr, flags::WRITABLE)
475 .write_phys(mm_paddr, &mm_page)
476 .build();
477
478 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
479 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
480
481 let result = walk_deleted_exe(&reader).unwrap();
482 assert!(
483 result.is_empty(),
484 "mm non-null but exe_file==0 → read_deleted_exe_info returns None → empty"
485 );
486 }
487
488 #[test]
492 fn walk_deleted_exe_full_chain_no_deleted_marker() {
493 use memf_core::test_builders::flags as ptf;
494
495 let sym_vaddr: u64 = 0xFFFF_8800_00A0_0000;
502 let sym_paddr: u64 = 0x00A0_0000;
503 let mm_vaddr: u64 = 0xFFFF_8800_00A1_0000;
504 let mm_paddr: u64 = 0x00A1_0000;
505 let file_vaddr: u64 = 0xFFFF_8800_00A2_0000;
506 let file_paddr: u64 = 0x00A2_0000;
507 let dentry_vaddr: u64 = 0xFFFF_8800_00A3_0000;
508 let dentry_paddr: u64 = 0x00A3_0000;
509 let name_vaddr: u64 = 0xFFFF_8800_00A4_0000;
510 let name_paddr: u64 = 0x00A4_0000;
511
512 let tasks_offset: u64 = 0x10;
513 let mm_offset: u64 = 0x30;
514 let f_path_offset: u64 = 0x10; let dentry_in_path: u64 = 0x00; let d_name_offset: u64 = 0x08; let name_in_qstr: u64 = 0x00; let mut task_page = [0u8; 4096];
521 task_page[0..4].copy_from_slice(&7u32.to_le_bytes()); let self_ptr = sym_vaddr + tasks_offset;
523 task_page[tasks_offset as usize..tasks_offset as usize + 8]
524 .copy_from_slice(&self_ptr.to_le_bytes());
525 task_page[0x20..0x25].copy_from_slice(b"bash\0");
526 task_page[mm_offset as usize..mm_offset as usize + 8]
527 .copy_from_slice(&mm_vaddr.to_le_bytes());
528
529 let mut mm_page = [0u8; 4096];
531 mm_page[0x18..0x20].copy_from_slice(&file_vaddr.to_le_bytes());
532
533 let mut file_page = [0u8; 4096];
535 file_page[0x10..0x18].copy_from_slice(&dentry_vaddr.to_le_bytes());
536
537 let mut dentry_page = [0u8; 4096];
539 dentry_page[0x08..0x10].copy_from_slice(&name_vaddr.to_le_bytes());
540
541 let mut name_page = [0u8; 4096];
543 name_page[..14].copy_from_slice(b"/usr/bin/bash\0");
544
545 let isf = IsfBuilder::new()
546 .add_symbol("init_task", sym_vaddr)
547 .add_struct("list_head", 0x10)
548 .add_field("list_head", "next", 0x00, "pointer")
549 .add_field("list_head", "prev", 0x08, "pointer")
550 .add_struct("task_struct", 0x400)
551 .add_field("task_struct", "tasks", tasks_offset, "pointer")
552 .add_field("task_struct", "pid", 0x00, "unsigned int")
553 .add_field("task_struct", "comm", 0x20, "char")
554 .add_field("task_struct", "mm", mm_offset, "pointer")
555 .add_struct("mm_struct", 0x200)
556 .add_field("mm_struct", "exe_file", 0x18, "pointer")
557 .add_struct("file", 0x200)
558 .add_field("file", "f_path", f_path_offset, "pointer")
559 .add_struct("path", 0x20)
560 .add_field("path", "dentry", dentry_in_path, "pointer")
561 .add_struct("dentry", 0x200)
562 .add_field("dentry", "d_name", d_name_offset, "pointer")
563 .add_struct("qstr", 0x20)
564 .add_field("qstr", "name", name_in_qstr, "pointer")
565 .build_json();
566 let resolver = IsfResolver::from_value(&isf).unwrap();
567
568 let (cr3, mem) = PageTableBuilder::new()
569 .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
570 .write_phys(sym_paddr, &task_page)
571 .map_4k(mm_vaddr, mm_paddr, ptf::WRITABLE)
572 .write_phys(mm_paddr, &mm_page)
573 .map_4k(file_vaddr, file_paddr, ptf::WRITABLE)
574 .write_phys(file_paddr, &file_page)
575 .map_4k(dentry_vaddr, dentry_paddr, ptf::WRITABLE)
576 .write_phys(dentry_paddr, &dentry_page)
577 .map_4k(name_vaddr, name_paddr, ptf::WRITABLE)
578 .write_phys(name_paddr, &name_page)
579 .build();
580
581 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
582 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
583
584 let result = walk_deleted_exe(&reader).unwrap();
585 assert_eq!(
587 result.len(),
588 1,
589 "init_task with full dentry chain should produce one entry"
590 );
591 assert_eq!(result[0].pid, 7);
592 assert!(
593 !result[0].is_deleted,
594 "path without (deleted) must not be flagged"
595 );
596 assert!(!result[0].is_suspicious);
597 }
598
599 #[test]
603 fn walk_deleted_exe_symbol_present_kernel_thread_returns_empty() {
604 let tasks_offset: u64 = 0x10;
606 let sym_vaddr: u64 = 0xFFFF_8800_0080_0000;
607 let sym_paddr: u64 = 0x0080_0000; let isf = IsfBuilder::new()
610 .add_symbol("init_task", sym_vaddr)
611 .add_struct("list_head", 0x10)
612 .add_field("list_head", "next", 0x00, "pointer")
613 .add_struct("task_struct", 0x400)
614 .add_field("task_struct", "tasks", tasks_offset, "pointer")
615 .add_field("task_struct", "pid", 0x00, "unsigned int")
616 .add_field("task_struct", "comm", 0x20, "char")
617 .add_field("task_struct", "mm", 0x30, "pointer")
618 .build_json();
619 let resolver = IsfResolver::from_value(&isf).unwrap();
620
621 let mut page = [0u8; 4096];
623 let self_ptr = sym_vaddr + tasks_offset;
624 page[tasks_offset as usize..tasks_offset as usize + 8]
625 .copy_from_slice(&self_ptr.to_le_bytes());
626 let (cr3, mem) = PageTableBuilder::new()
629 .map_4k(sym_vaddr, sym_paddr, flags::WRITABLE)
630 .write_phys(sym_paddr, &page)
631 .build();
632
633 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
634 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
635
636 let result = walk_deleted_exe(&reader).unwrap();
637 assert!(
638 result.is_empty(),
639 "init_task with mm=0 → skipped as kernel thread → empty results"
640 );
641 }
642
643 #[test]
647 fn walk_deleted_exe_task_list_loop_body_covered() {
648 use memf_core::test_builders::flags;
649
650 let tasks_offset: u64 = 0x10;
651 let mm_offset: u64 = 0x30;
652
653 let init_vaddr: u64 = 0xFFFF_8800_00B0_0000;
655 let init_paddr: u64 = 0x00B0_0000;
656
657 let task2_vaddr: u64 = 0xFFFF_8800_00B1_0000;
659 let task2_paddr: u64 = 0x00B1_0000;
660
661 let mm2_vaddr: u64 = 0xFFFF_8800_00B2_0000;
663 let mm2_paddr: u64 = 0x00B2_0000;
664
665 let mut init_page = [0u8; 4096];
667 let task2_tasks_vaddr = task2_vaddr + tasks_offset;
669 init_page[tasks_offset as usize..tasks_offset as usize + 8]
670 .copy_from_slice(&task2_tasks_vaddr.to_le_bytes());
671 let mut task2_page = [0u8; 4096];
676 let init_tasks_vaddr = init_vaddr + tasks_offset;
677 task2_page[tasks_offset as usize..tasks_offset as usize + 8]
678 .copy_from_slice(&init_tasks_vaddr.to_le_bytes()); task2_page[0x00..0x04].copy_from_slice(&8u32.to_le_bytes());
681 task2_page[0x20..0x25].copy_from_slice(b"proc2");
683 task2_page[mm_offset as usize..mm_offset as usize + 8]
685 .copy_from_slice(&mm2_vaddr.to_le_bytes());
686
687 let mm2_page = [0u8; 4096];
689
690 let isf = IsfBuilder::new()
691 .add_symbol("init_task", init_vaddr)
692 .add_struct("list_head", 0x10)
693 .add_field("list_head", "next", 0x00, "pointer")
694 .add_field("list_head", "prev", 0x08, "pointer")
695 .add_struct("task_struct", 0x400)
696 .add_field("task_struct", "tasks", tasks_offset, "pointer")
697 .add_field("task_struct", "pid", 0x00, "unsigned int")
698 .add_field("task_struct", "comm", 0x20, "char")
699 .add_field("task_struct", "mm", mm_offset, "pointer")
700 .add_struct("mm_struct", 0x200)
701 .add_field("mm_struct", "exe_file", 0x18, "pointer")
702 .build_json();
703 let resolver = IsfResolver::from_value(&isf).unwrap();
704
705 let (cr3, mem) = PageTableBuilder::new()
706 .map_4k(init_vaddr, init_paddr, flags::WRITABLE)
707 .write_phys(init_paddr, &init_page)
708 .map_4k(task2_vaddr, task2_paddr, flags::WRITABLE)
709 .write_phys(task2_paddr, &task2_page)
710 .map_4k(mm2_vaddr, mm2_paddr, flags::WRITABLE)
711 .write_phys(mm2_paddr, &mm2_page)
712 .build();
713
714 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
715 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
716
717 let result = walk_deleted_exe(&reader).unwrap();
720 assert!(
721 result.is_empty(),
722 "both tasks skipped (mm=0 or exe_file=0) → empty results, but loop body was exercised"
723 );
724 }
725
726 #[test]
729 fn walk_deleted_exe_full_chain_with_deleted_marker() {
730 use memf_core::test_builders::flags as ptf;
731
732 let sym_vaddr: u64 = 0xFFFF_8800_00C0_0000;
733 let sym_paddr: u64 = 0x00C0_0000;
734 let mm_vaddr: u64 = 0xFFFF_8800_00C1_0000;
735 let mm_paddr: u64 = 0x00C1_0000;
736 let file_vaddr: u64 = 0xFFFF_8800_00C2_0000;
737 let file_paddr: u64 = 0x00C2_0000;
738 let dentry_vaddr: u64 = 0xFFFF_8800_00C3_0000;
739 let dentry_paddr: u64 = 0x00C3_0000;
740 let name_vaddr: u64 = 0xFFFF_8800_00C4_0000;
741 let name_paddr: u64 = 0x00C4_0000;
742
743 let tasks_offset: u64 = 0x10;
744 let mm_offset: u64 = 0x30;
745 let f_path_offset: u64 = 0x10;
746 let dentry_in_path: u64 = 0x00;
747 let d_name_offset: u64 = 0x08;
748 let name_in_qstr: u64 = 0x00;
749
750 let mut task_page = [0u8; 4096];
751 task_page[0..4].copy_from_slice(&3u32.to_le_bytes()); let self_ptr = sym_vaddr + tasks_offset;
753 task_page[tasks_offset as usize..tasks_offset as usize + 8]
754 .copy_from_slice(&self_ptr.to_le_bytes()); task_page[0x20..0x27].copy_from_slice(b"payload");
756 task_page[mm_offset as usize..mm_offset as usize + 8]
757 .copy_from_slice(&mm_vaddr.to_le_bytes());
758
759 let mut mm_page = [0u8; 4096];
760 mm_page[0x18..0x20].copy_from_slice(&file_vaddr.to_le_bytes());
761
762 let mut file_page = [0u8; 4096];
763 file_page[0x10..0x18].copy_from_slice(&dentry_vaddr.to_le_bytes());
764
765 let mut dentry_page = [0u8; 4096];
766 dentry_page[0x08..0x10].copy_from_slice(&name_vaddr.to_le_bytes());
767
768 let mut name_page = [0u8; 4096];
769 let name_str = b"/tmp/.x11 (deleted)\0";
770 name_page[..name_str.len()].copy_from_slice(name_str);
771
772 let isf = IsfBuilder::new()
773 .add_symbol("init_task", sym_vaddr)
774 .add_struct("list_head", 0x10)
775 .add_field("list_head", "next", 0x00, "pointer")
776 .add_field("list_head", "prev", 0x08, "pointer")
777 .add_struct("task_struct", 0x400)
778 .add_field("task_struct", "tasks", tasks_offset, "pointer")
779 .add_field("task_struct", "pid", 0x00, "unsigned int")
780 .add_field("task_struct", "comm", 0x20, "char")
781 .add_field("task_struct", "mm", mm_offset, "pointer")
782 .add_struct("mm_struct", 0x200)
783 .add_field("mm_struct", "exe_file", 0x18, "pointer")
784 .add_struct("file", 0x200)
785 .add_field("file", "f_path", f_path_offset, "pointer")
786 .add_struct("path", 0x20)
787 .add_field("path", "dentry", dentry_in_path, "pointer")
788 .add_struct("dentry", 0x200)
789 .add_field("dentry", "d_name", d_name_offset, "pointer")
790 .add_struct("qstr", 0x20)
791 .add_field("qstr", "name", name_in_qstr, "pointer")
792 .build_json();
793 let resolver = IsfResolver::from_value(&isf).unwrap();
794
795 let (cr3, mem) = PageTableBuilder::new()
796 .map_4k(sym_vaddr, sym_paddr, ptf::WRITABLE)
797 .write_phys(sym_paddr, &task_page)
798 .map_4k(mm_vaddr, mm_paddr, ptf::WRITABLE)
799 .write_phys(mm_paddr, &mm_page)
800 .map_4k(file_vaddr, file_paddr, ptf::WRITABLE)
801 .write_phys(file_paddr, &file_page)
802 .map_4k(dentry_vaddr, dentry_paddr, ptf::WRITABLE)
803 .write_phys(dentry_paddr, &dentry_page)
804 .map_4k(name_vaddr, name_paddr, ptf::WRITABLE)
805 .write_phys(name_paddr, &name_page)
806 .build();
807
808 let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
809 let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
810
811 let result = walk_deleted_exe(&reader).unwrap();
812 assert_eq!(result.len(), 1, "init_task with deleted exe → one entry");
813 assert_eq!(result[0].pid, 3);
814 assert!(
815 result[0].is_deleted,
816 "exe_path contains (deleted) → is_deleted=true"
817 );
818 assert!(
819 result[0].is_suspicious,
820 "payload with deleted exe → suspicious"
821 );
822 }
823
824 #[test]
829 fn is_deleted_exe_space_prefix_true() {
830 assert!(is_deleted_exe("/usr/bin/xmrig (deleted)"));
831 }
832
833 #[test]
834 fn is_deleted_exe_bare_suffix_true() {
835 assert!(is_deleted_exe("/usr/bin/xmrig(deleted)"));
836 }
837
838 #[test]
839 fn is_deleted_exe_live_binary_false() {
840 assert!(!is_deleted_exe("/usr/bin/bash"));
841 }
842
843 #[test]
844 fn is_deleted_exe_empty_string_false() {
845 assert!(!is_deleted_exe(""));
846 }
847
848 #[test]
849 fn strip_deleted_suffix_removes_space_prefix() {
850 assert_eq!(
851 strip_deleted_suffix("/usr/bin/xmrig (deleted)"),
852 "/usr/bin/xmrig"
853 );
854 }
855
856 #[test]
857 fn strip_deleted_suffix_removes_bare_suffix() {
858 assert_eq!(
859 strip_deleted_suffix("/usr/bin/xmrig(deleted)"),
860 "/usr/bin/xmrig"
861 );
862 }
863
864 #[test]
865 fn strip_deleted_suffix_no_marker_unchanged() {
866 assert_eq!(strip_deleted_suffix("/usr/bin/bash"), "/usr/bin/bash");
867 }
868
869 #[test]
870 fn strip_deleted_suffix_empty_unchanged() {
871 assert_eq!(strip_deleted_suffix(""), "");
872 }
873
874 #[test]
875 fn deleted_exe_finding_fields_constructible() {
876 let finding = DeletedExeFinding {
877 pid: 999,
878 comm: "evil".to_string(),
879 exe_path: "/tmp/.x (deleted)".to_string(),
880 original_path: "/tmp/.x".to_string(),
881 };
882 assert_eq!(finding.pid, 999);
883 assert_eq!(finding.original_path, "/tmp/.x");
884 }
885
886 #[test]
887 fn deleted_exe_finding_serializes_to_json() {
888 let finding = DeletedExeFinding {
889 pid: 42,
890 comm: "malware".to_string(),
891 exe_path: "/dev/shm/.bin (deleted)".to_string(),
892 original_path: "/dev/shm/.bin".to_string(),
893 };
894 let json = serde_json::to_string(&finding).unwrap();
895 assert!(json.contains("\"pid\":42"));
896 assert!(json.contains("\"exe_path\""));
897 assert!(json.contains("\"original_path\""));
898 }
899
900 #[test]
901 fn deleted_exe_finding_clone_and_debug() {
902 let finding = DeletedExeFinding {
903 pid: 7,
904 comm: "sh".to_string(),
905 exe_path: "/bin/sh (deleted)".to_string(),
906 original_path: "/bin/sh".to_string(),
907 };
908 let cloned = finding.clone();
909 let dbg = format!("{cloned:?}");
910 assert!(dbg.contains("DeletedExeFinding"));
911 }
912}