Skip to main content

memf_linux/
dentry_cache.rs

1//! Detect files hidden via dentry unlink (open-but-unlinked file descriptors).
2//!
3//! A classic rootkit technique is to `unlink()` a file while keeping a file
4//! descriptor open. The file disappears from the directory tree (`i_nlink == 0`)
5//! but remains accessible via the open fd. This walker scans every process's
6//! open fd table looking for file-backed fds whose dentry inode has `i_nlink == 0`.
7//!
8//! MITRE ATT&CK: T1564.001 — Hide Artifacts: Hidden Files and Directories.
9
10use memf_core::object_reader::ObjectReader;
11use memf_format::PhysicalMemoryProvider;
12use serde::Serialize;
13
14use crate::Result;
15
16/// Information about a hidden (unlinked but open) file descriptor.
17#[derive(Debug, Clone, Serialize)]
18pub struct HiddenDentryInfo {
19    /// Process ID.
20    pub pid: u32,
21    /// Process command name (`task_struct.comm`, max 16 chars).
22    pub comm: String,
23    /// File descriptor number.
24    pub fd: u32,
25    /// Virtual address of the `struct dentry` in kernel memory.
26    pub dentry_addr: u64,
27    /// Filename from `dentry->d_name`.
28    pub filename: String,
29    /// Inode number from `dentry->d_inode->i_ino`.
30    pub inode_num: u64,
31    /// File size in bytes from `dentry->d_inode->i_size`.
32    pub file_size: u64,
33    /// Hard link count (`dentry->d_inode->i_nlink`); 0 means the file is unlinked.
34    pub nlink: u32,
35    /// Whether this hidden dentry is considered suspicious.
36    pub is_suspicious: bool,
37}
38
39/// Classify whether an open-but-unlinked file descriptor is suspicious.
40///
41/// Returns `true` (suspicious) if:
42/// - `nlink == 0` (file is unlinked, only reachable via open fd), OR
43/// - `filename` ends with a suspicious extension (`.so`, `.py`, `.sh`, `.elf`, `.bin`).
44///
45/// Returns `false` (benign) if:
46/// - `filename` is empty (kernel internal anonymous files).
47/// - `nlink > 0` and no suspicious extension.
48pub use crate::heuristics::classify_hidden_dentry;
49
50/// Walk the task list and enumerate all open-but-unlinked file descriptors.
51///
52/// For each process, walks `task_struct.files -> files_struct.fdt -> fdtable.fd[]`,
53/// then reads `file->f_path.dentry->d_inode->i_nlink`. Entries with `i_nlink == 0`
54/// are recorded as hidden.
55///
56/// Gracefully returns `Ok(vec![])` if any required symbol is absent.
57pub fn walk_dentry_cache<P: PhysicalMemoryProvider>(
58    reader: &ObjectReader<P>,
59) -> Result<Vec<HiddenDentryInfo>> {
60    // --- symbol resolution (graceful degradation) ---
61    let init_task_addr = match reader.symbols().symbol_address("init_task") {
62        Some(a) => a,
63        None => return Ok(vec![]),
64    };
65    let tasks_offset = match reader.symbols().field_offset("task_struct", "tasks") {
66        Some(o) => o,
67        None => return Ok(vec![]),
68    };
69    if reader
70        .symbols()
71        .field_offset("task_struct", "files")
72        .is_none()
73    {
74        return Ok(vec![]);
75    }
76
77    let head_vaddr = init_task_addr + tasks_offset;
78    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
79
80    let mut results: Vec<HiddenDentryInfo> = Vec::new();
81
82    collect_hidden_dentries_for_task(reader, init_task_addr, &mut results);
83    for &task_addr in &task_addrs {
84        collect_hidden_dentries_for_task(reader, task_addr, &mut results);
85    }
86
87    results.sort_by_key(|r| (r.pid, r.fd));
88    Ok(results)
89}
90
91/// Collect hidden-dentry information for a single task.
92fn collect_hidden_dentries_for_task<P: PhysicalMemoryProvider>(
93    reader: &ObjectReader<P>,
94    task_addr: u64,
95    out: &mut Vec<HiddenDentryInfo>,
96) {
97    let pid: u32 = match reader.read_field(task_addr, "task_struct", "pid") {
98        Ok(v) => v,
99        Err(_) => return,
100    };
101    let comm = reader
102        .read_field_string(task_addr, "task_struct", "comm", 16)
103        .unwrap_or_default();
104
105    // files_struct pointer.
106    let files_ptr: u64 = match reader.read_field(task_addr, "task_struct", "files") {
107        Ok(v) => v,
108        Err(_) => return,
109    };
110    if files_ptr == 0 {
111        return;
112    }
113
114    // files_struct.fdt → fdtable pointer.
115    let fdt_ptr: u64 = match reader.read_field(files_ptr, "files_struct", "fdt") {
116        Ok(v) => v,
117        Err(_) => return,
118    };
119    if fdt_ptr == 0 {
120        return;
121    }
122
123    // fdtable.max_fds → number of valid slots in the fd array.
124    // Cap at 65536 to avoid scanning corrupt/huge values.
125    let max_fds: u64 = match reader.read_field::<u32>(fdt_ptr, "fdtable", "max_fds") {
126        Ok(v) => u64::from(v).min(65536),
127        Err(_) => return,
128    };
129
130    // fdtable.fd → pointer to array of file pointers.
131    let fd_array_ptr: u64 = match reader.read_field(fdt_ptr, "fdtable", "fd") {
132        Ok(v) => v,
133        Err(_) => return,
134    };
135    if fd_array_ptr == 0 {
136        return;
137    }
138
139    for fd_index in 0u64..max_fds {
140        let file_slot_addr = fd_array_ptr + fd_index * 8;
141        let file_ptr_raw = match reader.read_bytes(file_slot_addr, 8) {
142            Ok(b) => b,
143            Err(_) => break,
144        };
145        let file_ptr = u64::from_le_bytes(match file_ptr_raw.try_into() {
146            Ok(b) => b,
147            Err(_) => break,
148        });
149        if file_ptr == 0 {
150            continue;
151        }
152
153        if let Some(info) = try_read_hidden_dentry(reader, pid, &comm, fd_index as u32, file_ptr) {
154            out.push(info);
155        }
156    }
157}
158
159/// Attempt to read hidden-dentry information from a single open file.
160///
161/// Returns `None` if the dentry is not unlinked or fields cannot be read.
162fn try_read_hidden_dentry<P: PhysicalMemoryProvider>(
163    reader: &ObjectReader<P>,
164    pid: u32,
165    comm: &str,
166    fd: u32,
167    file_ptr: u64,
168) -> Option<HiddenDentryInfo> {
169    // Navigate file->f_path.dentry.
170    let f_path_offset = reader.symbols().field_offset("file", "f_path")?;
171    let dentry_in_path = reader.symbols().field_offset("path", "dentry")?;
172
173    let dentry_slot = file_ptr + f_path_offset + dentry_in_path;
174    let dentry_raw = reader.read_bytes(dentry_slot, 8).ok()?;
175    let dentry_ptr = u64::from_le_bytes(dentry_raw.try_into().ok()?);
176    if dentry_ptr == 0 {
177        return None;
178    }
179
180    // dentry->d_inode (pointer).
181    let inode_ptr: u64 = reader.read_field(dentry_ptr, "dentry", "d_inode").ok()?;
182    if inode_ptr == 0 {
183        return None;
184    }
185
186    // inode->i_nlink (u32).
187    let nlink: u32 = reader.read_field(inode_ptr, "inode", "i_nlink").ok()?;
188    // inode->i_size (stored as u64 for simplicity).
189    let file_size: u64 = reader
190        .read_field::<u64>(inode_ptr, "inode", "i_size")
191        .unwrap_or(0);
192    // inode->i_ino (unsigned long, 8 bytes on x86_64).
193    let inode_num: u64 = reader
194        .read_field::<u64>(inode_ptr, "inode", "i_ino")
195        .unwrap_or(0);
196
197    // dentry->d_name (qstr) → name pointer.
198    let filename = read_dentry_name(reader, dentry_ptr).unwrap_or_default();
199
200    let is_suspicious = classify_hidden_dentry(nlink, &filename);
201
202    // Skip entries that are neither unlinked nor suspicious.
203    if nlink > 0 && !is_suspicious {
204        return None;
205    }
206
207    Some(HiddenDentryInfo {
208        pid,
209        comm: comm.to_string(),
210        fd,
211        dentry_addr: dentry_ptr,
212        filename,
213        inode_num,
214        file_size,
215        nlink,
216        is_suspicious,
217    })
218}
219
220/// Read `dentry->d_name.name` string.
221fn read_dentry_name<P: PhysicalMemoryProvider>(
222    reader: &ObjectReader<P>,
223    dentry_ptr: u64,
224) -> Option<String> {
225    let d_name_offset = reader.symbols().field_offset("dentry", "d_name")?;
226    let name_in_qstr = reader.symbols().field_offset("qstr", "name")?;
227
228    let name_ptr_addr = dentry_ptr + d_name_offset + name_in_qstr;
229    let name_raw = reader.read_bytes(name_ptr_addr, 8).ok()?;
230    let name_ptr = u64::from_le_bytes(name_raw.try_into().ok()?);
231    if name_ptr == 0 {
232        return None;
233    }
234
235    reader.read_string(name_ptr, 256).ok()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use memf_core::object_reader::ObjectReader;
242    use memf_core::test_builders::{flags, PageTableBuilder, SyntheticPhysMem};
243    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
244    use memf_symbols::isf::IsfResolver;
245    use memf_symbols::test_builders::IsfBuilder;
246
247    // -----------------------------------------------------------------------
248    // classify_hidden_dentry unit tests
249    // -----------------------------------------------------------------------
250
251    #[test]
252    fn classify_hidden_nlink_zero_is_suspicious() {
253        assert!(
254            classify_hidden_dentry(0, "rootkit.so"),
255            "nlink==0 file must be suspicious"
256        );
257    }
258
259    #[test]
260    fn classify_hidden_so_file_suspicious() {
261        assert!(
262            classify_hidden_dentry(0, "libevil.so"),
263            "unlinked .so file must be suspicious"
264        );
265    }
266
267    #[test]
268    fn classify_hidden_nlink_positive_not_suspicious() {
269        assert!(
270            !classify_hidden_dentry(1, "normal.txt"),
271            "file with nlink>0 and no suspicious extension must not be suspicious"
272        );
273    }
274
275    #[test]
276    fn classify_hidden_empty_filename_not_suspicious() {
277        assert!(
278            !classify_hidden_dentry(0, ""),
279            "empty filename (kernel internal) must not be suspicious"
280        );
281    }
282
283    #[test]
284    fn classify_hidden_sh_script_suspicious() {
285        assert!(
286            classify_hidden_dentry(0, "dropper.sh"),
287            "unlinked .sh script must be suspicious"
288        );
289    }
290
291    #[test]
292    fn classify_hidden_py_script_suspicious() {
293        assert!(
294            classify_hidden_dentry(0, "stage2.py"),
295            "unlinked .py script must be suspicious"
296        );
297    }
298
299    // -----------------------------------------------------------------------
300    // walk_dentry_cache integration tests
301    // -----------------------------------------------------------------------
302
303    fn make_reader_no_init_task() -> ObjectReader<SyntheticPhysMem> {
304        let isf = IsfBuilder::new()
305            .add_struct("task_struct", 128)
306            .add_field("task_struct", "pid", 0, "int")
307            .add_struct("list_head", 16)
308            .add_field("list_head", "next", 0, "pointer")
309            .add_field("list_head", "prev", 8, "pointer")
310            .build_json();
311
312        let resolver = IsfResolver::from_value(&isf).unwrap();
313        let (cr3, mem) = PageTableBuilder::new().build();
314        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
315        ObjectReader::new(vas, Box::new(resolver))
316    }
317
318    fn make_reader_no_open_files() -> ObjectReader<SyntheticPhysMem> {
319        let vaddr: u64 = 0xFFFF_8000_0010_0000;
320        let paddr: u64 = 0x0080_0000;
321
322        let mut data = vec![0u8; 4096];
323        data[0..4].copy_from_slice(&1u32.to_le_bytes());
324        let tasks_next = vaddr + 16;
325        data[16..24].copy_from_slice(&tasks_next.to_le_bytes());
326        data[24..32].copy_from_slice(&tasks_next.to_le_bytes());
327        data[32..39].copy_from_slice(b"kthread");
328        // files = 0 (NULL — no open fds)
329        data[48..56].copy_from_slice(&0u64.to_le_bytes());
330
331        let isf = IsfBuilder::new()
332            .add_struct("task_struct", 128)
333            .add_field("task_struct", "pid", 0, "int")
334            .add_field("task_struct", "tasks", 16, "list_head")
335            .add_field("task_struct", "comm", 32, "char")
336            .add_field("task_struct", "files", 48, "pointer")
337            .add_struct("list_head", 16)
338            .add_field("list_head", "next", 0, "pointer")
339            .add_field("list_head", "prev", 8, "pointer")
340            .add_symbol("init_task", vaddr)
341            .build_json();
342
343        let resolver = IsfResolver::from_value(&isf).unwrap();
344        let (cr3, mem) = PageTableBuilder::new()
345            .map_4k(vaddr, paddr, flags::WRITABLE)
346            .write_phys(paddr, &data)
347            .build();
348        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
349        ObjectReader::new(vas, Box::new(resolver))
350    }
351
352    #[test]
353    fn walk_dentry_missing_init_task_returns_empty() {
354        let reader = make_reader_no_init_task();
355        let result = walk_dentry_cache(&reader).expect("should not error");
356        assert!(
357            result.is_empty(),
358            "missing init_task must yield empty results (graceful degradation)"
359        );
360    }
361
362    #[test]
363    fn walk_dentry_no_open_files_returns_empty() {
364        let reader = make_reader_no_open_files();
365        let result = walk_dentry_cache(&reader).expect("should not error");
366        assert!(
367            result.is_empty(),
368            "kernel thread with files==NULL must produce no hidden-dentry results"
369        );
370    }
371
372    #[test]
373    fn walk_dentry_missing_tasks_field_returns_empty() {
374        // init_task symbol present but task_struct.tasks field is not defined.
375        let isf = IsfBuilder::new()
376            .add_struct("task_struct", 128)
377            .add_field("task_struct", "pid", 0, "int")
378            // No "tasks" field → graceful degradation
379            .add_field("task_struct", "files", 48, "pointer")
380            .add_symbol("init_task", 0xFFFF_8000_0000_0000)
381            .build_json();
382
383        let resolver = IsfResolver::from_value(&isf).unwrap();
384        let (cr3, mem) = PageTableBuilder::new().build();
385        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
386        let reader = ObjectReader::new(vas, Box::new(resolver));
387
388        let result = walk_dentry_cache(&reader).expect("should not error");
389        assert!(
390            result.is_empty(),
391            "missing tasks field must yield empty (graceful degradation)"
392        );
393    }
394
395    #[test]
396    fn walk_dentry_missing_files_field_returns_empty() {
397        // init_task and tasks present, but task_struct.files field missing.
398        let isf = IsfBuilder::new()
399            .add_struct("task_struct", 128)
400            .add_field("task_struct", "pid", 0, "int")
401            .add_field("task_struct", "tasks", 16, "list_head")
402            // No "files" field → graceful degradation
403            .add_struct("list_head", 16)
404            .add_field("list_head", "next", 0, "pointer")
405            .add_field("list_head", "prev", 8, "pointer")
406            .add_symbol("init_task", 0xFFFF_8000_0000_0000)
407            .build_json();
408
409        let resolver = IsfResolver::from_value(&isf).unwrap();
410        let (cr3, mem) = PageTableBuilder::new().build();
411        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
412        let reader = ObjectReader::new(vas, Box::new(resolver));
413
414        let result = walk_dentry_cache(&reader).expect("should not error");
415        assert!(
416            result.is_empty(),
417            "missing files field must yield empty (graceful degradation)"
418        );
419    }
420
421    // -----------------------------------------------------------------------
422    // walk_dentry_cache: symbol present + self-pointing list (walk body runs)
423    // -----------------------------------------------------------------------
424
425    #[test]
426    fn walk_dentry_symbol_present_empty_list() {
427        // init_task present, self-pointing tasks list, files == NULL.
428        // The walk body runs the list loop but finds no hidden dentries.
429        let sym_vaddr: u64 = 0xFFFF_8800_0030_0000;
430        let sym_paddr: u64 = 0x0040_0000;
431        let tasks_offset = 16u64;
432
433        let mut page = [0u8; 4096];
434        // pid = 1
435        page[0..4].copy_from_slice(&1u32.to_le_bytes());
436        // self-pointing tasks list
437        let list_self = sym_vaddr + tasks_offset;
438        page[tasks_offset as usize..tasks_offset as usize + 8]
439            .copy_from_slice(&list_self.to_le_bytes());
440        page[tasks_offset as usize + 8..tasks_offset as usize + 16]
441            .copy_from_slice(&list_self.to_le_bytes());
442        // comm = "init"
443        page[32..36].copy_from_slice(b"init");
444        // files = 0 (NULL → no open fds, collect function returns early)
445        page[48..56].copy_from_slice(&0u64.to_le_bytes());
446
447        let isf = IsfBuilder::new()
448            .add_struct("task_struct", 128)
449            .add_field("task_struct", "pid", 0, "unsigned int")
450            .add_field("task_struct", "tasks", 16, "pointer")
451            .add_field("task_struct", "comm", 32, "char")
452            .add_field("task_struct", "files", 48, "pointer")
453            .add_symbol("init_task", sym_vaddr)
454            .build_json();
455
456        let resolver = IsfResolver::from_value(&isf).unwrap();
457        let (cr3, mem) = PageTableBuilder::new()
458            .map_4k(sym_vaddr, sym_paddr, flags::WRITABLE)
459            .write_phys(sym_paddr, &page)
460            .build();
461        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
462        let reader = ObjectReader::new(vas, Box::new(resolver));
463
464        let result = walk_dentry_cache(&reader).unwrap_or_default();
465        assert!(
466            result.is_empty(),
467            "no hidden dentries expected for task with files==NULL"
468        );
469    }
470
471    // -----------------------------------------------------------------------
472    // Additional classify_hidden_dentry branch coverage
473    // -----------------------------------------------------------------------
474
475    #[test]
476    fn classify_hidden_nlink_positive_so_is_suspicious() {
477        // nlink > 0 but .so extension → suspicious (file in use with dangerous extension)
478        assert!(
479            classify_hidden_dentry(2, "libplugin.so"),
480            "linked .so file must be suspicious due to extension"
481        );
482    }
483
484    #[test]
485    fn classify_hidden_nlink_positive_bin_is_suspicious() {
486        assert!(
487            classify_hidden_dentry(1, "stage2.bin"),
488            "linked .bin file must be suspicious due to extension"
489        );
490    }
491
492    #[test]
493    fn classify_hidden_nlink_positive_elf_is_suspicious() {
494        assert!(
495            classify_hidden_dentry(1, "payload.elf"),
496            "linked .elf file must be suspicious due to extension"
497        );
498    }
499
500    #[test]
501    fn classify_hidden_nlink_positive_py_is_suspicious() {
502        assert!(
503            classify_hidden_dentry(3, "backdoor.py"),
504            "linked .py file must be suspicious due to extension"
505        );
506    }
507
508    #[test]
509    fn classify_hidden_nlink_positive_sh_is_suspicious() {
510        assert!(
511            classify_hidden_dentry(1, "install.sh"),
512            "linked .sh file must be suspicious due to extension"
513        );
514    }
515
516    #[test]
517    fn classify_hidden_extension_check_is_case_insensitive() {
518        // Extension matching uses to_lowercase()
519        assert!(
520            classify_hidden_dentry(1, "PAYLOAD.SO"),
521            "extension check should be case-insensitive"
522        );
523    }
524
525    #[test]
526    fn hidden_dentry_info_serializes() {
527        let info = HiddenDentryInfo {
528            pid: 42,
529            comm: "evil".to_string(),
530            fd: 3,
531            dentry_addr: 0xFFFF_8000_0001_0000,
532            filename: "rootkit.so".to_string(),
533            inode_num: 12345,
534            file_size: 65536,
535            nlink: 0,
536            is_suspicious: true,
537        };
538        let json = serde_json::to_string(&info).unwrap();
539        assert!(json.contains("\"pid\":42"));
540        assert!(json.contains("rootkit.so"));
541        assert!(json.contains("\"is_suspicious\":true"));
542    }
543
544    // -----------------------------------------------------------------------
545    // walk_dentry_cache: full happy path exercising try_read_hidden_dentry
546    // and read_dentry_name for an unlinked (nlink==0) file.
547    //
548    // Memory layout (all physical addresses < 16 MB):
549    //   task page     @ paddr 0x0100_0000 (vaddr 0xFFFF_C800_0100_0000)
550    //   files page    @ paddr 0x0101_0000 (vaddr 0xFFFF_C800_0101_0000)
551    //   fdtable page  @ paddr 0x0102_0000 (vaddr 0xFFFF_C800_0102_0000)
552    //   fd_array page @ paddr 0x0103_0000 (vaddr 0xFFFF_C800_0103_0000)
553    //   file page     @ paddr 0x0104_0000 (vaddr 0xFFFF_C800_0104_0000)
554    //   dentry page   @ paddr 0x0105_0000 (vaddr 0xFFFF_C800_0105_0000)
555    //   inode page    @ paddr 0x0106_0000 (vaddr 0xFFFF_C800_0106_0000)
556    //   name str page @ paddr 0x0107_0000 (vaddr 0xFFFF_C800_0107_0000)
557    // -----------------------------------------------------------------------
558    #[test]
559    fn walk_dentry_unlinked_file_detected() {
560        // Virtual addresses
561        let task_vaddr: u64 = 0xFFFF_C800_0100_0000;
562        let files_vaddr: u64 = 0xFFFF_C800_0101_0000;
563        let fdt_vaddr: u64 = 0xFFFF_C800_0102_0000;
564        let fd_arr_vaddr: u64 = 0xFFFF_C800_0103_0000;
565        let file_vaddr: u64 = 0xFFFF_C800_0104_0000;
566        let dentry_vaddr: u64 = 0xFFFF_C800_0105_0000;
567        let inode_vaddr: u64 = 0xFFFF_C800_0106_0000;
568        let name_vaddr: u64 = 0xFFFF_C800_0107_0000;
569
570        // Physical addresses (must be < 16 MB = 0xFF_FFFF; 4 KiB aligned)
571        let task_paddr: u64 = 0x010_000;
572        let files_paddr: u64 = 0x011_000;
573        let fdt_paddr: u64 = 0x012_000;
574        let fd_arr_paddr: u64 = 0x013_000;
575        let file_paddr: u64 = 0x014_000;
576        let dentry_paddr: u64 = 0x015_000;
577        let inode_paddr: u64 = 0x016_000;
578        let name_paddr: u64 = 0x017_000;
579
580        // Field offsets in ISF (we choose these to fit in one 4096-byte page)
581        // task_struct: pid@0, tasks@8, comm@24, files@40
582        let tasks_offset: u64 = 8;
583        let task_comm_offset: u64 = 24;
584        let task_files_offset: u64 = 40;
585
586        // files_struct: fdt@0
587        let files_fdt_offset: u64 = 0;
588        // fdtable: max_fds@0, fd@8
589        let fdt_max_fds_offset: u64 = 0;
590        let fdt_fd_offset: u64 = 8;
591        // file: f_path@0, path.dentry@8
592        let file_fpath_offset: u64 = 0;
593        let path_dentry_offset: u64 = 8;
594        // dentry: d_inode@0, d_name@16
595        let dentry_inode_offset: u64 = 0;
596        let dentry_dname_offset: u64 = 16;
597        // qstr: name@0
598        let qstr_name_offset: u64 = 0;
599        // inode: i_nlink@0, i_size@8, i_ino@16
600        let inode_nlink_offset: u64 = 0;
601        let inode_size_offset: u64 = 8;
602        let inode_ino_offset: u64 = 16;
603
604        // Build task page
605        let mut task_page = [0u8; 4096];
606        // pid = 999
607        task_page[0..4].copy_from_slice(&999u32.to_le_bytes());
608        // tasks: self-pointing (no other tasks in list)
609        let list_self = task_vaddr + tasks_offset;
610        task_page[tasks_offset as usize..tasks_offset as usize + 8]
611            .copy_from_slice(&list_self.to_le_bytes());
612        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
613            .copy_from_slice(&list_self.to_le_bytes());
614        // comm = "malware"
615        task_page[task_comm_offset as usize..task_comm_offset as usize + 7]
616            .copy_from_slice(b"malware");
617        // files ptr
618        task_page[task_files_offset as usize..task_files_offset as usize + 8]
619            .copy_from_slice(&files_vaddr.to_le_bytes());
620
621        // Build files_struct page: fdt at offset 0
622        let mut files_page = [0u8; 4096];
623        files_page[files_fdt_offset as usize..files_fdt_offset as usize + 8]
624            .copy_from_slice(&fdt_vaddr.to_le_bytes());
625
626        // Build fdtable page: max_fds at fdt_max_fds_offset, fd array ptr at fdt_fd_offset
627        let mut fdt_page = [0u8; 4096];
628        fdt_page[fdt_max_fds_offset as usize..fdt_max_fds_offset as usize + 4]
629            .copy_from_slice(&10u32.to_le_bytes()); // max_fds = 10
630        fdt_page[fdt_fd_offset as usize..fdt_fd_offset as usize + 8]
631            .copy_from_slice(&fd_arr_vaddr.to_le_bytes());
632
633        // Build fd_array page: slot 0 → file ptr, slot 1 → 0
634        let mut fd_arr_page = [0u8; 4096];
635        fd_arr_page[0..8].copy_from_slice(&file_vaddr.to_le_bytes());
636        // slot 1 zero (loop will continue past it)
637
638        // Build file page: f_path.dentry = dentry_vaddr
639        // file.f_path at offset 0, path.dentry at +8 inside f_path
640        let mut file_page = [0u8; 4096];
641        file_page[(file_fpath_offset + path_dentry_offset) as usize
642            ..(file_fpath_offset + path_dentry_offset) as usize + 8]
643            .copy_from_slice(&dentry_vaddr.to_le_bytes());
644
645        // Build dentry page:
646        //   d_inode at offset 0 → inode_vaddr
647        //   d_name (qstr) at offset 16 → name ptr at +0 inside qstr → name_vaddr
648        let mut dentry_page = [0u8; 4096];
649        dentry_page[dentry_inode_offset as usize..dentry_inode_offset as usize + 8]
650            .copy_from_slice(&inode_vaddr.to_le_bytes());
651        dentry_page[(dentry_dname_offset + qstr_name_offset) as usize
652            ..(dentry_dname_offset + qstr_name_offset) as usize + 8]
653            .copy_from_slice(&name_vaddr.to_le_bytes());
654
655        // Build inode page: nlink=0 (unlinked!), size=4096, ino=42
656        let mut inode_page = [0u8; 4096];
657        inode_page[inode_nlink_offset as usize..inode_nlink_offset as usize + 4]
658            .copy_from_slice(&0u32.to_le_bytes()); // nlink=0
659        inode_page[inode_size_offset as usize..inode_size_offset as usize + 8]
660            .copy_from_slice(&4096u64.to_le_bytes());
661        inode_page[inode_ino_offset as usize..inode_ino_offset as usize + 8]
662            .copy_from_slice(&42u64.to_le_bytes());
663
664        // Build name string page: "hidden.so\0"
665        let mut name_page = [0u8; 4096];
666        name_page[..10].copy_from_slice(b"hidden.so\0");
667
668        let isf = IsfBuilder::new()
669            .add_struct("task_struct", 256)
670            .add_field("task_struct", "pid", 0u64, "unsigned int")
671            .add_field("task_struct", "tasks", tasks_offset, "list_head")
672            .add_field("task_struct", "comm", task_comm_offset, "char")
673            .add_field("task_struct", "files", task_files_offset, "pointer")
674            .add_struct("list_head", 16)
675            .add_field("list_head", "next", 0u64, "pointer")
676            .add_field("list_head", "prev", 8u64, "pointer")
677            .add_struct("files_struct", 64)
678            .add_field("files_struct", "fdt", files_fdt_offset, "pointer")
679            .add_struct("fdtable", 64)
680            .add_field("fdtable", "max_fds", fdt_max_fds_offset, "unsigned int")
681            .add_field("fdtable", "fd", fdt_fd_offset, "pointer")
682            .add_struct("file", 256)
683            .add_field("file", "f_path", file_fpath_offset, "path")
684            .add_struct("path", 16)
685            .add_field("path", "dentry", path_dentry_offset, "pointer")
686            .add_struct("dentry", 256)
687            .add_field("dentry", "d_inode", dentry_inode_offset, "pointer")
688            .add_field("dentry", "d_name", dentry_dname_offset, "qstr")
689            .add_struct("qstr", 16)
690            .add_field("qstr", "name", qstr_name_offset, "pointer")
691            .add_struct("inode", 256)
692            .add_field("inode", "i_nlink", inode_nlink_offset, "unsigned int")
693            .add_field("inode", "i_size", inode_size_offset, "long")
694            .add_field("inode", "i_ino", inode_ino_offset, "unsigned long")
695            .add_symbol("init_task", task_vaddr)
696            .build_json();
697
698        let resolver = IsfResolver::from_value(&isf).unwrap();
699        let (cr3, mem) = PageTableBuilder::new()
700            .map_4k(task_vaddr, task_paddr, flags::WRITABLE)
701            .write_phys(task_paddr, &task_page)
702            .map_4k(files_vaddr, files_paddr, flags::WRITABLE)
703            .write_phys(files_paddr, &files_page)
704            .map_4k(fdt_vaddr, fdt_paddr, flags::WRITABLE)
705            .write_phys(fdt_paddr, &fdt_page)
706            .map_4k(fd_arr_vaddr, fd_arr_paddr, flags::WRITABLE)
707            .write_phys(fd_arr_paddr, &fd_arr_page)
708            .map_4k(file_vaddr, file_paddr, flags::WRITABLE)
709            .write_phys(file_paddr, &file_page)
710            .map_4k(dentry_vaddr, dentry_paddr, flags::WRITABLE)
711            .write_phys(dentry_paddr, &dentry_page)
712            .map_4k(inode_vaddr, inode_paddr, flags::WRITABLE)
713            .write_phys(inode_paddr, &inode_page)
714            .map_4k(name_vaddr, name_paddr, flags::WRITABLE)
715            .write_phys(name_paddr, &name_page)
716            .build();
717
718        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
719        let reader = ObjectReader::new(vas, Box::new(resolver));
720
721        let results = walk_dentry_cache(&reader).expect("walk should succeed");
722        assert_eq!(results.len(), 1, "should detect exactly one hidden dentry");
723        let entry = &results[0];
724        assert_eq!(entry.pid, 999);
725        assert_eq!(entry.fd, 0);
726        assert_eq!(entry.nlink, 0, "file must be unlinked");
727        assert!(entry.is_suspicious, "unlinked .so must be suspicious");
728        assert_eq!(entry.filename, "hidden.so");
729        assert_eq!(entry.inode_num, 42);
730        assert_eq!(entry.file_size, 4096);
731        assert!(
732            entry.comm.contains("malware"),
733            "comm should contain 'malware'"
734        );
735    }
736
737    // -----------------------------------------------------------------------
738    // try_read_hidden_dentry: dentry_ptr == 0 path (returns None early)
739    // -----------------------------------------------------------------------
740    #[test]
741    fn walk_dentry_null_dentry_ptr_skipped() {
742        // file page has f_path.dentry = 0 → try_read_hidden_dentry returns None
743        let task_vaddr: u64 = 0xFFFF_C900_0100_0000;
744        let files_vaddr: u64 = 0xFFFF_C900_0101_0000;
745        let fdt_vaddr: u64 = 0xFFFF_C900_0102_0000;
746        let fd_arr_vaddr: u64 = 0xFFFF_C900_0103_0000;
747        let file_vaddr: u64 = 0xFFFF_C900_0104_0000;
748
749        let task_paddr: u64 = 0x018_000;
750        let files_paddr: u64 = 0x019_000;
751        let fdt_paddr: u64 = 0x01A_000;
752        let fd_arr_paddr: u64 = 0x01B_000;
753        let file_paddr: u64 = 0x01C_000;
754
755        let tasks_offset: u64 = 8;
756        let task_files_offset: u64 = 40;
757        let path_dentry_offset: u64 = 8;
758
759        let mut task_page = [0u8; 4096];
760        task_page[0..4].copy_from_slice(&1001u32.to_le_bytes());
761        let list_self = task_vaddr + tasks_offset;
762        task_page[tasks_offset as usize..tasks_offset as usize + 8]
763            .copy_from_slice(&list_self.to_le_bytes());
764        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
765            .copy_from_slice(&list_self.to_le_bytes());
766        task_page[task_files_offset as usize..task_files_offset as usize + 8]
767            .copy_from_slice(&files_vaddr.to_le_bytes());
768
769        let mut files_page = [0u8; 4096];
770        files_page[0..8].copy_from_slice(&fdt_vaddr.to_le_bytes());
771
772        // fdtable page: max_fds at offset 0 (u32 = 8 slots), fd array ptr at offset 8
773        let mut fdt_page = [0u8; 4096];
774        fdt_page[0..4].copy_from_slice(&8u32.to_le_bytes()); // max_fds = 8
775        fdt_page[8..16].copy_from_slice(&fd_arr_vaddr.to_le_bytes());
776
777        let mut fd_arr_page = [0u8; 4096];
778        fd_arr_page[0..8].copy_from_slice(&file_vaddr.to_le_bytes());
779
780        // file page: dentry = 0 (null)
781        let mut file_page = [0u8; 4096];
782        file_page[path_dentry_offset as usize..path_dentry_offset as usize + 8]
783            .copy_from_slice(&0u64.to_le_bytes());
784
785        let isf = IsfBuilder::new()
786            .add_struct("task_struct", 256)
787            .add_field("task_struct", "pid", 0u64, "unsigned int")
788            .add_field("task_struct", "tasks", tasks_offset, "list_head")
789            .add_field("task_struct", "comm", 24u64, "char")
790            .add_field("task_struct", "files", task_files_offset, "pointer")
791            .add_struct("list_head", 16)
792            .add_field("list_head", "next", 0u64, "pointer")
793            .add_field("list_head", "prev", 8u64, "pointer")
794            .add_struct("files_struct", 64)
795            .add_field("files_struct", "fdt", 0u64, "pointer")
796            .add_struct("fdtable", 64)
797            .add_field("fdtable", "max_fds", 0u64, "unsigned int")
798            .add_field("fdtable", "fd", 8u64, "pointer")
799            .add_struct("file", 256)
800            .add_field("file", "f_path", 0u64, "path")
801            .add_struct("path", 16)
802            .add_field("path", "dentry", path_dentry_offset, "pointer")
803            .add_struct("dentry", 256)
804            .add_field("dentry", "d_inode", 0u64, "pointer")
805            .add_field("dentry", "d_name", 16u64, "qstr")
806            .add_struct("qstr", 16)
807            .add_field("qstr", "name", 0u64, "pointer")
808            .add_struct("inode", 256)
809            .add_field("inode", "i_nlink", 0u64, "unsigned int")
810            .add_field("inode", "i_size", 8u64, "long")
811            .add_field("inode", "i_ino", 16u64, "unsigned long")
812            .add_symbol("init_task", task_vaddr)
813            .build_json();
814
815        let resolver = IsfResolver::from_value(&isf).unwrap();
816        let (cr3, mem) = PageTableBuilder::new()
817            .map_4k(task_vaddr, task_paddr, flags::WRITABLE)
818            .write_phys(task_paddr, &task_page)
819            .map_4k(files_vaddr, files_paddr, flags::WRITABLE)
820            .write_phys(files_paddr, &files_page)
821            .map_4k(fdt_vaddr, fdt_paddr, flags::WRITABLE)
822            .write_phys(fdt_paddr, &fdt_page)
823            .map_4k(fd_arr_vaddr, fd_arr_paddr, flags::WRITABLE)
824            .write_phys(fd_arr_paddr, &fd_arr_page)
825            .map_4k(file_vaddr, file_paddr, flags::WRITABLE)
826            .write_phys(file_paddr, &file_page)
827            .build();
828
829        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
830        let reader = ObjectReader::new(vas, Box::new(resolver));
831
832        let results = walk_dentry_cache(&reader).expect("walk should succeed");
833        assert!(
834            results.is_empty(),
835            "null dentry ptr should produce no results"
836        );
837    }
838
839    // -----------------------------------------------------------------------
840    // try_read_hidden_dentry: nlink > 0 and no suspicious extension → skipped
841    // -----------------------------------------------------------------------
842    #[test]
843    fn walk_dentry_linked_benign_file_skipped() {
844        // Same layout as walk_dentry_unlinked_file_detected but nlink=2 and
845        // filename is "data.txt" (no suspicious extension) → skipped.
846        let task_vaddr: u64 = 0xFFFF_CA00_0100_0000;
847        let files_vaddr: u64 = 0xFFFF_CA00_0101_0000;
848        let fdt_vaddr: u64 = 0xFFFF_CA00_0102_0000;
849        let fd_arr_vaddr: u64 = 0xFFFF_CA00_0103_0000;
850        let file_vaddr: u64 = 0xFFFF_CA00_0104_0000;
851        let dentry_vaddr: u64 = 0xFFFF_CA00_0105_0000;
852        let inode_vaddr: u64 = 0xFFFF_CA00_0106_0000;
853        let name_vaddr: u64 = 0xFFFF_CA00_0107_0000;
854
855        let task_paddr: u64 = 0x01D_000;
856        let files_paddr: u64 = 0x01E_000;
857        let fdt_paddr: u64 = 0x01F_000;
858        let fd_arr_paddr: u64 = 0x020_000;
859        let file_paddr: u64 = 0x021_000;
860        let dentry_paddr: u64 = 0x022_000;
861        let inode_paddr: u64 = 0x023_000;
862        let name_paddr: u64 = 0x024_000;
863
864        let tasks_offset: u64 = 8;
865        let task_files_offset: u64 = 40;
866        let path_dentry_offset: u64 = 8;
867
868        let mut task_page = [0u8; 4096];
869        task_page[0..4].copy_from_slice(&1002u32.to_le_bytes());
870        let list_self = task_vaddr + tasks_offset;
871        task_page[tasks_offset as usize..tasks_offset as usize + 8]
872            .copy_from_slice(&list_self.to_le_bytes());
873        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
874            .copy_from_slice(&list_self.to_le_bytes());
875        task_page[24..31].copy_from_slice(b"benign\0");
876        task_page[task_files_offset as usize..task_files_offset as usize + 8]
877            .copy_from_slice(&files_vaddr.to_le_bytes());
878
879        let mut files_page = [0u8; 4096];
880        files_page[0..8].copy_from_slice(&fdt_vaddr.to_le_bytes());
881
882        // fdtable page: max_fds at offset 0 (u32 = 8 slots), fd array ptr at offset 8
883        let mut fdt_page = [0u8; 4096];
884        fdt_page[0..4].copy_from_slice(&8u32.to_le_bytes()); // max_fds = 8
885        fdt_page[8..16].copy_from_slice(&fd_arr_vaddr.to_le_bytes());
886
887        let mut fd_arr_page = [0u8; 4096];
888        fd_arr_page[0..8].copy_from_slice(&file_vaddr.to_le_bytes());
889
890        let mut file_page = [0u8; 4096];
891        file_page[path_dentry_offset as usize..path_dentry_offset as usize + 8]
892            .copy_from_slice(&dentry_vaddr.to_le_bytes());
893
894        let mut dentry_page = [0u8; 4096];
895        dentry_page[0..8].copy_from_slice(&inode_vaddr.to_le_bytes());
896        // d_name at offset 16, qstr.name at offset 0 inside qstr
897        dentry_page[16..24].copy_from_slice(&name_vaddr.to_le_bytes());
898
899        // nlink = 2 (linked, not unlinked)
900        let mut inode_page = [0u8; 4096];
901        inode_page[0..4].copy_from_slice(&2u32.to_le_bytes());
902        inode_page[8..16].copy_from_slice(&1024u64.to_le_bytes());
903        inode_page[16..24].copy_from_slice(&99u64.to_le_bytes());
904
905        // filename = "data.txt" (benign extension)
906        let mut name_page = [0u8; 4096];
907        name_page[..9].copy_from_slice(b"data.txt\0");
908
909        let isf = IsfBuilder::new()
910            .add_struct("task_struct", 256)
911            .add_field("task_struct", "pid", 0u64, "unsigned int")
912            .add_field("task_struct", "tasks", tasks_offset, "list_head")
913            .add_field("task_struct", "comm", 24u64, "char")
914            .add_field("task_struct", "files", task_files_offset, "pointer")
915            .add_struct("list_head", 16)
916            .add_field("list_head", "next", 0u64, "pointer")
917            .add_field("list_head", "prev", 8u64, "pointer")
918            .add_struct("files_struct", 64)
919            .add_field("files_struct", "fdt", 0u64, "pointer")
920            .add_struct("fdtable", 64)
921            .add_field("fdtable", "max_fds", 0u64, "unsigned int")
922            .add_field("fdtable", "fd", 8u64, "pointer")
923            .add_struct("file", 256)
924            .add_field("file", "f_path", 0u64, "path")
925            .add_struct("path", 16)
926            .add_field("path", "dentry", path_dentry_offset, "pointer")
927            .add_struct("dentry", 256)
928            .add_field("dentry", "d_inode", 0u64, "pointer")
929            .add_field("dentry", "d_name", 16u64, "qstr")
930            .add_struct("qstr", 16)
931            .add_field("qstr", "name", 0u64, "pointer")
932            .add_struct("inode", 256)
933            .add_field("inode", "i_nlink", 0u64, "unsigned int")
934            .add_field("inode", "i_size", 8u64, "long")
935            .add_field("inode", "i_ino", 16u64, "unsigned long")
936            .add_symbol("init_task", task_vaddr)
937            .build_json();
938
939        let resolver = IsfResolver::from_value(&isf).unwrap();
940        let (cr3, mem) = PageTableBuilder::new()
941            .map_4k(task_vaddr, task_paddr, flags::WRITABLE)
942            .write_phys(task_paddr, &task_page)
943            .map_4k(files_vaddr, files_paddr, flags::WRITABLE)
944            .write_phys(files_paddr, &files_page)
945            .map_4k(fdt_vaddr, fdt_paddr, flags::WRITABLE)
946            .write_phys(fdt_paddr, &fdt_page)
947            .map_4k(fd_arr_vaddr, fd_arr_paddr, flags::WRITABLE)
948            .write_phys(fd_arr_paddr, &fd_arr_page)
949            .map_4k(file_vaddr, file_paddr, flags::WRITABLE)
950            .write_phys(file_paddr, &file_page)
951            .map_4k(dentry_vaddr, dentry_paddr, flags::WRITABLE)
952            .write_phys(dentry_paddr, &dentry_page)
953            .map_4k(inode_vaddr, inode_paddr, flags::WRITABLE)
954            .write_phys(inode_paddr, &inode_page)
955            .map_4k(name_vaddr, name_paddr, flags::WRITABLE)
956            .write_phys(name_paddr, &name_page)
957            .build();
958
959        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
960        let reader = ObjectReader::new(vas, Box::new(resolver));
961
962        let results = walk_dentry_cache(&reader).expect("walk should succeed");
963        assert!(
964            results.is_empty(),
965            "benign linked file should not appear in results"
966        );
967    }
968}