Skip to main content

memf_linux/
deleted_exe.rs

1//! Detect processes running from deleted executables.
2//!
3//! When malware deletes its binary after execution, the process keeps running
4//! but the `/proc/<pid>/exe` symlink (backed by `mm->exe_file->f_path->dentry->d_name`)
5//! shows `(deleted)`. This is a strong indicator of malicious activity.
6//!
7//! MITRE ATT&CK: T1070.004 — Indicator Removal: File Deletion.
8//!
9//! Legitimate cases include package manager upgrades (apt, dpkg, yum, dnf, rpm)
10//! where the old binary is replaced while the process is still running, and
11//! kernel threads with empty exe paths.
12
13use memf_core::object_reader::ObjectReader;
14use memf_format::PhysicalMemoryProvider;
15use serde::Serialize;
16
17use crate::{Error, Result};
18
19/// Information about a process whose executable may have been deleted.
20#[derive(Debug, Clone, Serialize)]
21pub struct DeletedExeInfo {
22    /// Process ID.
23    pub pid: u32,
24    /// Process command name (`task_struct.comm`, max 16 chars).
25    pub comm: String,
26    /// Executable path as read from memory (may include "(deleted)" suffix).
27    pub exe_path: String,
28    /// Whether the executable path contains the "(deleted)" marker.
29    pub is_deleted: bool,
30    /// Whether this deleted executable is suspicious (not a known-benign case).
31    pub is_suspicious: bool,
32}
33
34/// Classify whether a deleted executable is suspicious.
35///
36/// Returns `true` (suspicious) if:
37/// - The exe path contains "(deleted)" AND
38/// - The process is NOT a known-benign package manager process AND
39/// - The exe path is not empty (kernel threads have no exe)
40///
41/// Returns `false` (benign) for:
42/// - Normal executables (no "(deleted)" marker)
43/// - Package manager processes (apt, dpkg, yum, dnf, rpm, etc.)
44/// - Kernel threads with empty exe paths
45/// - Processes with empty comm (likely kernel threads)
46pub use crate::heuristics::classify_deleted_exe;
47
48/// Walk the task list and detect processes running from deleted executables.
49///
50/// For each process, reads the `mm->exe_file->f_path->dentry->d_name` chain
51/// to recover the executable path. If the path contains "(deleted)", the
52/// process is flagged and classified.
53///
54/// Kernel threads (NULL mm) are silently skipped.
55pub 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    // Include init_task itself
79    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
93/// Read the executable path for a single task and classify it.
94///
95/// Returns `None` for kernel threads (NULL mm) or if any field cannot be read.
96fn 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    // Kernel threads have mm == NULL — skip them.
106    let mm_ptr: u64 = reader.read_field(task_addr, "task_struct", "mm").ok()?;
107    if mm_ptr == 0 {
108        return None;
109    }
110
111    // Follow mm->exe_file (pointer to struct file).
112    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    // Navigate exe_file->f_path.dentry to read the path name.
118    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
132/// Read the dentry name from a `struct file` pointer via `f_path.dentry->d_name`.
133///
134/// Follows the embedded struct chain: `file.f_path` (embedded `struct path`) ->
135/// `path.dentry` (pointer) -> `dentry.d_name` (embedded `struct qstr`) ->
136/// `qstr.name` (pointer to string).
137fn 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    // file.f_path is embedded; dentry is a pointer within the embedded path struct.
147    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    // dentry.d_name is an embedded qstr; name is a pointer within qstr.
155    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
165// ---------------------------------------------------------------------------
166// Pure-logic helpers and finding type for LD_PRELOAD / Father rootkit detection
167// ---------------------------------------------------------------------------
168
169/// Returns `true` if `exe_path` carries a `(deleted)` suffix.
170///
171/// This covers both the kernel's canonical `" (deleted)"` (space-prefixed) and
172/// the bare `"(deleted)"` form occasionally written by userspace tools.
173pub fn is_deleted_exe(exe_path: &str) -> bool {
174    exe_path.ends_with(" (deleted)") || exe_path.ends_with("(deleted)")
175}
176
177/// Returns the path with any `(deleted)` suffix stripped, trimming trailing
178/// whitespace that the kernel inserts before the marker.
179///
180/// If the path carries no deleted marker the original string slice is returned
181/// unchanged.
182pub 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/// A lightweight finding produced by the pure-logic deleted-exe classifier,
191/// suitable for use without a full memory-image reader.
192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
193pub struct DeletedExeFinding {
194    /// Process ID.
195    pub pid: u32,
196    /// Process name (`task_struct.comm`, max 16 chars).
197    pub comm: String,
198    /// Full exe path as seen in memory — includes the `(deleted)` suffix.
199    pub exe_path: String,
200    /// Exe path with the `(deleted)` suffix and trailing whitespace stripped.
201    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    // --- classify_deleted_exe unit tests ---
229
230    #[test]
231    fn classify_normal_benign() {
232        // A normal executable that is NOT deleted should never be suspicious.
233        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        // A deleted executable from an unknown process IS suspicious.
242        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        // apt running from a deleted exe during upgrade is benign.
251        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        // dpkg running from a deleted exe during upgrade is benign.
260        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        // Kernel threads have empty comm or empty exe path — not suspicious.
269        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        // Empty exe path (kernel thread) with a comm name should not be suspicious
278        // even though it technically can't contain "(deleted)" — test the guard.
279        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        // yum running from a deleted exe during upgrade is benign.
288        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        // A process with a suspicious-looking name running from /dev/shm (deleted).
297        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        // Deleted path but empty comm → kernel thread, not suspicious
306        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        // Every entry in KNOWN_BENIGN_COMMS must be suppressed
315        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        // Classification is case-insensitive for known-benign names
327        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        // "apt2" is NOT in the benign list → suspicious
335        assert!(classify_deleted_exe("/usr/bin/apt2 (deleted)", "apt2"));
336        // "dpkg-query" is not in the list → suspicious
337        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    // --- walk_deleted_exe integration test ---
376
377    /// Helper: build an ObjectReader with no init_task symbol.
378    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        // Without init_task symbol, walk should return an error (not panic).
397        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            // tasks intentionally omitted
411            .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    // --- walk_deleted_exe: symbol present, self-pointing tasks list, mm != 0, exe_file == 0 ---
428    // Exercises read_deleted_exe_info: mm pointer is non-null (reads ok), but
429    // mm_struct.exe_file == 0 → returns None → result stays empty.
430    #[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; // < 16 MB
436        let mm_vaddr: u64 = 0xFFFF_8800_0091_0000;
437        let mm_paddr: u64 = 0x0091_0000;
438
439        // task page
440        let mut task_page = [0u8; 4096];
441        // pid = 5
442        task_page[0..4].copy_from_slice(&5u32.to_le_bytes());
443        // tasks self-pointing
444        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        // mm at offset 0x30 → non-zero (points to mm page)
448        task_page[mm_offset as usize..mm_offset as usize + 8]
449            .copy_from_slice(&mm_vaddr.to_le_bytes());
450        // comm = "worker"
451        task_page[0x20..0x26].copy_from_slice(b"worker");
452
453        // mm page: exe_file at offset 0x18 = 0 (null)
454        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    // --- walk_deleted_exe: exe_file non-null, dentry chain fully readable, non-deleted path ---
489    // Exercises read_deleted_exe_info returning Some (lines 120, 124-126), and
490    // read_file_dentry_name (lines 177-203) on a path without "(deleted)".
491    #[test]
492    fn walk_deleted_exe_full_chain_no_deleted_marker() {
493        use memf_core::test_builders::flags as ptf;
494
495        // Layout (all physical addrs < 16 MB):
496        //   sym_vaddr  = init_task                tasks @ 0x10, mm @ 0x30, pid @ 0, comm @ 0x20
497        //   mm_vaddr   = mm_struct                exe_file @ 0x18
498        //   file_vaddr = struct file              f_path embedded (f_path_offset=0x10)
499        //   dentry_vaddr = dentry struct          d_name embedded (d_name_offset=0x08)
500        //   name_vaddr   = actual name string     "/usr/bin/bash\0"
501        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; // offset of embedded path inside file
515        let dentry_in_path: u64 = 0x00; // offset of dentry* inside path
516        let d_name_offset: u64 = 0x08; // offset of embedded qstr inside dentry
517        let name_in_qstr: u64 = 0x00; // offset of name* inside qstr
518
519        // init_task page
520        let mut task_page = [0u8; 4096];
521        task_page[0..4].copy_from_slice(&7u32.to_le_bytes()); // pid=7
522        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        // mm_struct page: exe_file at 0x18
530        let mut mm_page = [0u8; 4096];
531        mm_page[0x18..0x20].copy_from_slice(&file_vaddr.to_le_bytes());
532
533        // file page: dentry ptr at f_path_offset + dentry_in_path = 0x10
534        let mut file_page = [0u8; 4096];
535        file_page[0x10..0x18].copy_from_slice(&dentry_vaddr.to_le_bytes());
536
537        // dentry page: name ptr at d_name_offset + name_in_qstr = 0x08
538        let mut dentry_page = [0u8; 4096];
539        dentry_page[0x08..0x10].copy_from_slice(&name_vaddr.to_le_bytes());
540
541        // name string page
542        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        // init_task (pid=7, comm="bash") has a fully readable exe path that is NOT deleted.
586        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    // --- walk_deleted_exe: symbol present, self-pointing tasks list, mm == 0 → exercises body ---
600    // Exercises the task-list body and `read_deleted_exe_info`: init_task has mm=0 (kernel thread),
601    // so it is skipped, and walk_list returns empty → result is empty but no error.
602    #[test]
603    fn walk_deleted_exe_symbol_present_kernel_thread_returns_empty() {
604        // tasks at offset 0x10; pid at 0x00; comm at 0x20; mm at 0x30.
605        let tasks_offset: u64 = 0x10;
606        let sym_vaddr: u64 = 0xFFFF_8800_0080_0000;
607        let sym_paddr: u64 = 0x0080_0000; // unique, < 16 MB
608
609        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        // Build init_task page: tasks.next self-pointing, mm = 0 (kernel thread).
622        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        // mm at 0x30 remains 0 → read_deleted_exe_info returns None.
627
628        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    // --- walk_deleted_exe: walk_list returns a non-empty task list → exercises the for loop body ---
644    // init_task has mm=0 (kernel thread, skipped). A linked task has mm != 0 but exe_file = 0
645    // → read_deleted_exe_info returns None → loop body runs but produces no result.
646    #[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        // init_task (kernel thread, mm=0)
654        let init_vaddr: u64 = 0xFFFF_8800_00B0_0000;
655        let init_paddr: u64 = 0x00B0_0000;
656
657        // task2 (non-kernel, mm != 0 → exe_file = 0 → skipped)
658        let task2_vaddr: u64 = 0xFFFF_8800_00B1_0000;
659        let task2_paddr: u64 = 0x00B1_0000;
660
661        // mm page for task2
662        let mm2_vaddr: u64 = 0xFFFF_8800_00B2_0000;
663        let mm2_paddr: u64 = 0x00B2_0000;
664
665        // init_task page: tasks.next → task2, mm = 0
666        let mut init_page = [0u8; 4096];
667        // tasks.next at offset 0x10 → task2_vaddr (pointing at task2's tasks field)
668        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        // mm at 0x30 = 0
672        // pid = 0 at offset 0
673
674        // task2 page: tasks.next → back to init_task tasks (forms a cycle-terminating list)
675        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()); // tasks.next → head
679                                                               // pid at 0x00 = 8
680        task2_page[0x00..0x04].copy_from_slice(&8u32.to_le_bytes());
681        // comm at 0x20 = "proc2"
682        task2_page[0x20..0x25].copy_from_slice(b"proc2");
683        // mm at 0x30 = mm2_vaddr (non-zero → has mm, exe_file at offset 0x18 = 0 → skipped)
684        task2_page[mm_offset as usize..mm_offset as usize + 8]
685            .copy_from_slice(&mm2_vaddr.to_le_bytes());
686
687        // mm2 page: exe_file at offset 0x18 = 0 → read_deleted_exe_info returns None
688        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        // walk_list should find task2 in the list. init_task has mm=0 → skipped.
718        // task2 has exe_file=0 → skipped. Result is empty but loop body executed.
719        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    // --- walk_deleted_exe: full chain produces a (deleted) exe entry ---
727    // Exercises lines 119-120 (init_task result pushed) and 160-162 (is_deleted=true, is_suspicious).
728    #[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()); // pid=3
752        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()); // self-pointing
755        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    // ---------------------------------------------------------------------------
825    // Tests for the new pure-logic helpers and DeletedExeFinding
826    // ---------------------------------------------------------------------------
827
828    #[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}