Skip to main content

memf_linux/
pam_hooks.rs

1//! PAM library hook detection.
2//!
3//! Detects processes that have loaded a PAM-related shared library
4//! (`libpam*.so`) from non-standard system paths, which is a strong
5//! indicator of credential theft (MITRE ATT&CK T1556.003).
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Information about a suspicious PAM library loaded by a process.
13#[derive(Debug, Clone)]
14pub struct PamHookInfo {
15    /// Process ID.
16    pub pid: u32,
17    /// Process command name.
18    pub comm: String,
19    /// Full path of the loaded PAM library (dentry name component).
20    pub library_path: String,
21    /// True if the library originates from a standard system lib directory.
22    pub is_system_path: bool,
23    /// True if the library is considered suspicious.
24    pub is_suspicious: bool,
25}
26
27/// Standard system library path prefixes that are NOT suspicious.
28const SYSTEM_LIB_PREFIXES: &[&str] =
29    &["/lib", "/usr/lib", "/usr/lib64", "/lib64", "/usr/local/lib"];
30
31/// Classify whether a PAM library path is suspicious.
32///
33/// Returns `true` if the path contains "pam" (case-insensitive) AND does
34/// not start with a known system library directory.
35pub use crate::heuristics::classify_pam_hook;
36
37/// Walk all process VMAs and report PAM libraries loaded from non-system paths.
38///
39/// On missing `init_task` symbol, returns `Ok(vec![])` rather than an error
40/// so callers can treat a missing symbol table as a no-op.
41pub fn walk_pam_hooks<P: PhysicalMemoryProvider>(
42    reader: &ObjectReader<P>,
43) -> Result<Vec<PamHookInfo>> {
44    let init_task_addr = match reader.symbols().symbol_address("init_task") {
45        Some(a) => a,
46        None => return Ok(vec![]),
47    };
48
49    let tasks_offset = match reader.symbols().field_offset("task_struct", "tasks") {
50        Some(o) => o,
51        None => return Ok(vec![]),
52    };
53
54    let head_vaddr = init_task_addr + tasks_offset;
55    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
56
57    let mut findings = Vec::new();
58    scan_process_pam(reader, init_task_addr, &mut findings);
59    for &task_addr in &task_addrs {
60        scan_process_pam(reader, task_addr, &mut findings);
61    }
62
63    Ok(findings)
64}
65
66/// Scan a single process's VMAs for PAM-related file-backed mappings.
67fn scan_process_pam<P: PhysicalMemoryProvider>(
68    reader: &ObjectReader<P>,
69    task_addr: u64,
70    out: &mut Vec<PamHookInfo>,
71) {
72    let mm_ptr: u64 = match reader.read_field(task_addr, "task_struct", "mm") {
73        Ok(v) => v,
74        Err(_) => return,
75    };
76    if mm_ptr == 0 {
77        return; // kernel thread
78    }
79
80    let pid: u32 = match reader.read_field(task_addr, "task_struct", "pid") {
81        Ok(v) => v,
82        Err(_) => return,
83    };
84    let comm = reader
85        .read_field_string(task_addr, "task_struct", "comm", 16)
86        .unwrap_or_default();
87
88    let mmap_ptr: u64 = match reader.read_field(mm_ptr, "mm_struct", "mmap") {
89        Ok(v) => v,
90        Err(_) => return,
91    };
92
93    let mut vma_addr = mmap_ptr;
94    while vma_addr != 0 {
95        let vm_file: u64 = if let Ok(v) = reader.read_field(vma_addr, "vm_area_struct", "vm_file") {
96            v
97        } else {
98            vma_addr = reader
99                .read_field(vma_addr, "vm_area_struct", "vm_next")
100                .unwrap_or(0);
101            continue;
102        };
103
104        if vm_file != 0 {
105            // Read dentry name via vm_file -> f_path -> dentry -> d_name -> name
106            if let Some(library_path) = read_dentry_name(reader, vm_file) {
107                if library_path.to_lowercase().contains("pam") {
108                    let is_system_path = SYSTEM_LIB_PREFIXES
109                        .iter()
110                        .any(|prefix| library_path.starts_with(prefix));
111                    let is_suspicious = classify_pam_hook(&library_path);
112                    out.push(PamHookInfo {
113                        pid,
114                        comm: comm.clone(),
115                        library_path,
116                        is_system_path,
117                        is_suspicious,
118                    });
119                }
120            }
121        }
122
123        vma_addr = reader
124            .read_field(vma_addr, "vm_area_struct", "vm_next")
125            .unwrap_or(0);
126    }
127}
128
129/// Attempt to read the dentry name from a `struct file *`.
130///
131/// Follows: `file.f_path.dentry -> dentry.d_name.name` (pointer to C string).
132fn read_dentry_name<P: PhysicalMemoryProvider>(
133    reader: &ObjectReader<P>,
134    file_ptr: u64,
135) -> Option<String> {
136    // f_path is embedded in file at field "f_path"; dentry is inside path
137    let f_path_dentry: u64 = reader.read_field(file_ptr, "file", "f_path").ok()?;
138    if f_path_dentry == 0 {
139        return None;
140    }
141    // dentry -> d_name (qstr) -> name (pointer to char)
142    let name_ptr: u64 = reader.read_field(f_path_dentry, "dentry", "d_name").ok()?;
143    if name_ptr == 0 {
144        return None;
145    }
146    // Read null-terminated string from name_ptr (up to 256 bytes)
147    let bytes = reader.read_bytes(name_ptr, 256).ok()?;
148    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
149    String::from_utf8(bytes[..end].to_vec()).ok()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
156    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
157    use memf_symbols::isf::IsfResolver;
158    use memf_symbols::test_builders::IsfBuilder;
159
160    // ---------------------------------------------------------------------------
161    // Unit tests for classify_pam_hook (no memory reader needed)
162    // ---------------------------------------------------------------------------
163
164    #[test]
165    fn classify_pam_hook_tmp_path_suspicious() {
166        assert!(classify_pam_hook("/tmp/libpam_evil.so"));
167    }
168
169    #[test]
170    fn classify_pam_hook_home_path_suspicious() {
171        assert!(classify_pam_hook(
172            "/home/attacker/.local/libpam_backdoor.so"
173        ));
174    }
175
176    #[test]
177    fn classify_pam_hook_system_lib_not_suspicious() {
178        assert!(!classify_pam_hook("/lib/x86_64-linux-gnu/libpam.so.0"));
179        assert!(!classify_pam_hook("/usr/lib/libpam.so.0"));
180        assert!(!classify_pam_hook("/usr/lib64/libpam.so.0"));
181        assert!(!classify_pam_hook("/lib64/libpam.so.0"));
182        assert!(!classify_pam_hook("/usr/local/lib/libpam.so.0"));
183    }
184
185    #[test]
186    fn classify_pam_hook_empty_path_not_suspicious() {
187        assert!(!classify_pam_hook(""));
188    }
189
190    #[test]
191    fn classify_pam_hook_devshm_suspicious() {
192        assert!(classify_pam_hook("/dev/shm/libpam_hook.so"));
193    }
194
195    // ---------------------------------------------------------------------------
196    // Walker tests — missing symbol → Ok(empty)
197    // ---------------------------------------------------------------------------
198
199    fn make_minimal_reader_no_init_task() -> ObjectReader<SyntheticPhysMem> {
200        let isf = IsfBuilder::new()
201            .add_struct("task_struct", 64)
202            .add_field("task_struct", "pid", 0, "int")
203            .add_field("task_struct", "tasks", 8, "list_head")
204            .add_struct("list_head", 16)
205            .add_field("list_head", "next", 0, "pointer")
206            .add_field("list_head", "prev", 8, "pointer")
207            // No "init_task" symbol registered
208            .build_json();
209
210        let resolver = IsfResolver::from_value(&isf).unwrap();
211        let (cr3, mem) = PageTableBuilder::new().build();
212        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
213        ObjectReader::new(vas, Box::new(resolver))
214    }
215
216    #[test]
217    fn walk_pam_hooks_missing_init_task_returns_empty() {
218        let reader = make_minimal_reader_no_init_task();
219        let result = walk_pam_hooks(&reader).unwrap();
220        assert!(result.is_empty());
221    }
222
223    // ---------------------------------------------------------------------------
224    // Integration: kernel thread (mm == 0) produces no output
225    // ---------------------------------------------------------------------------
226
227    fn make_kernel_thread_reader() -> ObjectReader<SyntheticPhysMem> {
228        let vaddr: u64 = 0xFFFF_8000_0010_0000;
229        let paddr: u64 = 0x0080_0000;
230        let mut data = vec![0u8; 4096];
231
232        // init_task: pid=0, tasks list → self, mm=NULL
233        data[0..4].copy_from_slice(&0u32.to_le_bytes());
234        let tasks_addr = vaddr + 16;
235        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
236        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
237        data[32..41].copy_from_slice(b"swapper/0");
238        data[48..56].copy_from_slice(&0u64.to_le_bytes()); // mm = NULL
239
240        let isf = IsfBuilder::new()
241            .add_struct("task_struct", 128)
242            .add_field("task_struct", "pid", 0, "int")
243            .add_field("task_struct", "tasks", 16, "list_head")
244            .add_field("task_struct", "comm", 32, "char")
245            .add_field("task_struct", "mm", 48, "pointer")
246            .add_struct("list_head", 16)
247            .add_field("list_head", "next", 0, "pointer")
248            .add_field("list_head", "prev", 8, "pointer")
249            .add_struct("mm_struct", 64)
250            .add_field("mm_struct", "mmap", 8, "pointer")
251            .add_struct("vm_area_struct", 64)
252            .add_field("vm_area_struct", "vm_next", 16, "pointer")
253            .add_field("vm_area_struct", "vm_file", 40, "pointer")
254            .add_symbol("init_task", vaddr)
255            .build_json();
256
257        let resolver = IsfResolver::from_value(&isf).unwrap();
258        let (cr3, mem) = PageTableBuilder::new()
259            .map_4k(vaddr, paddr, ptflags::WRITABLE)
260            .write_phys(paddr, &data)
261            .build();
262        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
263        ObjectReader::new(vas, Box::new(resolver))
264    }
265
266    #[test]
267    fn walk_pam_hooks_kernel_thread_returns_empty() {
268        let reader = make_kernel_thread_reader();
269        let result = walk_pam_hooks(&reader).unwrap();
270        assert!(result.is_empty());
271    }
272
273    // ---------------------------------------------------------------------------
274    // Additional classify_pam_hook edge cases
275    // ---------------------------------------------------------------------------
276
277    #[test]
278    fn classify_pam_hook_no_pam_in_path_not_suspicious() {
279        // Path that is not a system path but also doesn't contain "pam"
280        assert!(!classify_pam_hook("/tmp/libssl.so"));
281        assert!(!classify_pam_hook("/home/user/.local/libfoo.so"));
282    }
283
284    #[test]
285    fn classify_pam_hook_uppercase_pam_suspicious() {
286        // Classification is case-insensitive; "PAM" should be detected
287        assert!(classify_pam_hook("/tmp/libPAM_evil.so"));
288    }
289
290    #[test]
291    fn classify_pam_hook_mixed_case_pam_suspicious() {
292        assert!(classify_pam_hook("/opt/libPam.so"));
293    }
294
295    #[test]
296    fn classify_pam_hook_system_lib64_not_suspicious() {
297        // /usr/lib64 prefix — must not be flagged
298        assert!(!classify_pam_hook("/usr/lib64/security/libpam_unix.so"));
299    }
300
301    // ---------------------------------------------------------------------------
302    // walk_pam_hooks: symbol present + self-pointing list (walk body runs)
303    // ---------------------------------------------------------------------------
304
305    #[test]
306    fn walk_pam_hooks_symbol_present_empty_list() {
307        // init_task present with self-pointing tasks list and mm==NULL.
308        // walk body runs but scan_process_pam returns early on mm==0.
309        let sym_vaddr: u64 = 0xFFFF_8800_0040_0000;
310        let sym_paddr: u64 = 0x0050_0000;
311        let tasks_offset = 16u64;
312
313        let mut page = [0u8; 4096];
314        // pid = 0 (swapper)
315        page[0..4].copy_from_slice(&0u32.to_le_bytes());
316        // tasks: self-pointing
317        let list_self = sym_vaddr + tasks_offset;
318        page[tasks_offset as usize..tasks_offset as usize + 8]
319            .copy_from_slice(&list_self.to_le_bytes());
320        page[tasks_offset as usize + 8..tasks_offset as usize + 16]
321            .copy_from_slice(&list_self.to_le_bytes());
322        // comm = "swapper"
323        page[32..39].copy_from_slice(b"swapper");
324        // mm = 0 (kernel thread)
325        page[48..56].copy_from_slice(&0u64.to_le_bytes());
326
327        let isf = IsfBuilder::new()
328            .add_struct("task_struct", 128)
329            .add_field("task_struct", "pid", 0, "unsigned int")
330            .add_field("task_struct", "tasks", 16, "pointer")
331            .add_field("task_struct", "comm", 32, "char")
332            .add_field("task_struct", "mm", 48, "pointer")
333            .add_symbol("init_task", sym_vaddr)
334            .build_json();
335
336        let resolver = IsfResolver::from_value(&isf).unwrap();
337        let (cr3, mem) = PageTableBuilder::new()
338            .map_4k(sym_vaddr, sym_paddr, ptflags::WRITABLE)
339            .write_phys(sym_paddr, &page)
340            .build();
341        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
342        let reader = ObjectReader::new(vas, Box::new(resolver));
343
344        let result = walk_pam_hooks(&reader).unwrap_or_default();
345        assert!(
346            result.is_empty(),
347            "kernel thread with mm==NULL should produce no PAM findings"
348        );
349    }
350
351    #[test]
352    fn walk_pam_hooks_missing_tasks_field_returns_empty() {
353        // init_task is present but "tasks" field offset is absent.
354        // walk_list will not find the list offset so we expect graceful return.
355        let isf = IsfBuilder::new()
356            .add_struct("task_struct", 64)
357            .add_field("task_struct", "pid", 0, "int")
358            // tasks field intentionally omitted
359            .add_struct("list_head", 16)
360            .add_field("list_head", "next", 0, "pointer")
361            .add_field("list_head", "prev", 8, "pointer")
362            .add_symbol("init_task", 0xFFFF_8000_0010_0000)
363            .build_json();
364
365        let resolver = IsfResolver::from_value(&isf).unwrap();
366        let (cr3, mem) = PageTableBuilder::new().build();
367        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
368        let reader = ObjectReader::new(vas, Box::new(resolver));
369
370        // Missing tasks offset → Ok(empty) per graceful degradation
371        let result = walk_pam_hooks(&reader).unwrap();
372        assert!(result.is_empty());
373    }
374
375    // ---------------------------------------------------------------------------
376    // scan_process_pam: non-null mm, VMA list with vm_file pointing to a PAM lib
377    // from a non-system path → triggers read_dentry_name and classify_pam_hook.
378    //
379    // Memory layout (all physical addresses < 16 MB):
380    //   task page @ paddr 0x0200_0000 (vaddr 0xFFFF_D800_0200_0000)
381    //   mm page   @ paddr 0x0201_0000
382    //   vma page  @ paddr 0x0202_0000
383    //   file page @ paddr 0x0203_0000
384    //   dentry page @ paddr 0x0204_0000  (pointed to by f_path field directly)
385    //   name page @ paddr 0x0205_0000
386    // ---------------------------------------------------------------------------
387    #[test]
388    fn walk_pam_hooks_detects_suspicious_pam_lib() {
389        use memf_core::object_reader::ObjectReader;
390
391        let task_vaddr: u64 = 0xFFFF_D800_0200_0000;
392        let mm_vaddr: u64 = 0xFFFF_D800_0201_0000;
393        let vma_vaddr: u64 = 0xFFFF_D800_0202_0000;
394        let file_vaddr: u64 = 0xFFFF_D800_0203_0000;
395        let dentry_vaddr: u64 = 0xFFFF_D800_0204_0000;
396        let name_vaddr: u64 = 0xFFFF_D800_0205_0000;
397
398        let task_paddr: u64 = 0x030_000;
399        let mm_paddr: u64 = 0x031_000;
400        let vma_paddr: u64 = 0x032_000;
401        let file_paddr: u64 = 0x033_000;
402        let dentry_paddr: u64 = 0x034_000;
403        let name_paddr: u64 = 0x035_000;
404
405        // Offsets
406        let tasks_offset: u64 = 8;
407        let task_comm_offset: u64 = 24;
408        let task_mm_offset: u64 = 40;
409        let task_pid_offset: u64 = 0;
410
411        // mm_struct.mmap at offset 0
412        let mm_mmap_offset: u64 = 0;
413        // vm_area_struct: vm_next@0, vm_file@16
414        let vma_vm_next_offset: u64 = 0;
415        let vma_vm_file_offset: u64 = 16;
416        // file.f_path at offset 0; read_dentry_name reads file.f_path (pointer) → dentry
417        // pam_hooks' read_dentry_name: reads file.f_path (via read_field → u64 pointer to dentry),
418        // then dentry.d_name (via read_field → u64 pointer to char string).
419        let file_fpath_offset: u64 = 0;
420        let dentry_dname_offset: u64 = 0;
421
422        // Build task page
423        let mut task_page = [0u8; 4096];
424        task_page[task_pid_offset as usize..task_pid_offset as usize + 4]
425            .copy_from_slice(&5000u32.to_le_bytes());
426        let list_self = task_vaddr + tasks_offset;
427        task_page[tasks_offset as usize..tasks_offset as usize + 8]
428            .copy_from_slice(&list_self.to_le_bytes());
429        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
430            .copy_from_slice(&list_self.to_le_bytes());
431        task_page[task_comm_offset as usize..task_comm_offset as usize + 4]
432            .copy_from_slice(b"sshd");
433        task_page[task_mm_offset as usize..task_mm_offset as usize + 8]
434            .copy_from_slice(&mm_vaddr.to_le_bytes());
435
436        // Build mm_struct: mmap = vma_vaddr
437        let mut mm_page = [0u8; 4096];
438        mm_page[mm_mmap_offset as usize..mm_mmap_offset as usize + 8]
439            .copy_from_slice(&vma_vaddr.to_le_bytes());
440
441        // Build VMA: vm_next=0 (end of list), vm_file=file_vaddr
442        let mut vma_page = [0u8; 4096];
443        vma_page[vma_vm_next_offset as usize..vma_vm_next_offset as usize + 8]
444            .copy_from_slice(&0u64.to_le_bytes()); // no next VMA
445        vma_page[vma_vm_file_offset as usize..vma_vm_file_offset as usize + 8]
446            .copy_from_slice(&file_vaddr.to_le_bytes());
447
448        // Build file page: f_path (offset 0) = dentry_vaddr
449        // pam_hooks::read_dentry_name reads:
450        //   f_path_dentry = reader.read_field(file_ptr, "file", "f_path") → dentry_vaddr
451        //   name_ptr = reader.read_field(f_path_dentry, "dentry", "d_name") → name_vaddr
452        let mut file_page = [0u8; 4096];
453        file_page[file_fpath_offset as usize..file_fpath_offset as usize + 8]
454            .copy_from_slice(&dentry_vaddr.to_le_bytes());
455
456        // Build dentry page: d_name (offset 0) = name_vaddr
457        let mut dentry_page = [0u8; 4096];
458        dentry_page[dentry_dname_offset as usize..dentry_dname_offset as usize + 8]
459            .copy_from_slice(&name_vaddr.to_le_bytes());
460
461        // Build name page: "/tmp/libpam_rootkit.so\0"
462        let libname = b"/tmp/libpam_rootkit.so\0";
463        let mut name_page = [0u8; 4096];
464        name_page[..libname.len()].copy_from_slice(libname);
465
466        let isf = IsfBuilder::new()
467            .add_struct("task_struct", 256)
468            .add_field("task_struct", "pid", task_pid_offset, "unsigned int")
469            .add_field("task_struct", "tasks", tasks_offset, "list_head")
470            .add_field("task_struct", "comm", task_comm_offset, "char")
471            .add_field("task_struct", "mm", task_mm_offset, "pointer")
472            .add_struct("list_head", 16)
473            .add_field("list_head", "next", 0u64, "pointer")
474            .add_field("list_head", "prev", 8u64, "pointer")
475            .add_struct("mm_struct", 256)
476            .add_field("mm_struct", "mmap", mm_mmap_offset, "pointer")
477            .add_struct("vm_area_struct", 256)
478            .add_field("vm_area_struct", "vm_next", vma_vm_next_offset, "pointer")
479            .add_field("vm_area_struct", "vm_file", vma_vm_file_offset, "pointer")
480            .add_struct("file", 256)
481            .add_field("file", "f_path", file_fpath_offset, "pointer")
482            .add_struct("dentry", 256)
483            .add_field("dentry", "d_name", dentry_dname_offset, "pointer")
484            .add_symbol("init_task", task_vaddr)
485            .build_json();
486
487        let resolver = IsfResolver::from_value(&isf).unwrap();
488        let (cr3, mem) = PageTableBuilder::new()
489            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
490            .write_phys(task_paddr, &task_page)
491            .map_4k(mm_vaddr, mm_paddr, ptflags::WRITABLE)
492            .write_phys(mm_paddr, &mm_page)
493            .map_4k(vma_vaddr, vma_paddr, ptflags::WRITABLE)
494            .write_phys(vma_paddr, &vma_page)
495            .map_4k(file_vaddr, file_paddr, ptflags::WRITABLE)
496            .write_phys(file_paddr, &file_page)
497            .map_4k(dentry_vaddr, dentry_paddr, ptflags::WRITABLE)
498            .write_phys(dentry_paddr, &dentry_page)
499            .map_4k(name_vaddr, name_paddr, ptflags::WRITABLE)
500            .write_phys(name_paddr, &name_page)
501            .build();
502
503        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
504        let reader: ObjectReader<memf_core::test_builders::SyntheticPhysMem> =
505            ObjectReader::new(vas, Box::new(resolver));
506
507        let result = walk_pam_hooks(&reader).expect("walk should not error");
508        assert_eq!(
509            result.len(),
510            1,
511            "should detect exactly one suspicious PAM entry"
512        );
513        let entry = &result[0];
514        assert_eq!(entry.pid, 5000);
515        assert!(
516            entry.is_suspicious,
517            "non-system PAM path must be suspicious"
518        );
519        assert!(
520            !entry.is_system_path,
521            "path must not be considered a system path"
522        );
523        assert!(
524            entry.library_path.contains("pam"),
525            "library_path should contain 'pam'"
526        );
527    }
528
529    // ---------------------------------------------------------------------------
530    // scan_process_pam: vm_file == 0 → VMA skipped (covers the vm_file==0 branch)
531    // ---------------------------------------------------------------------------
532    #[test]
533    fn walk_pam_hooks_null_vm_file_skipped() {
534        use memf_core::object_reader::ObjectReader;
535
536        let task_vaddr: u64 = 0xFFFF_D900_0200_0000;
537        let mm_vaddr: u64 = 0xFFFF_D900_0201_0000;
538        let vma_vaddr: u64 = 0xFFFF_D900_0202_0000;
539
540        let task_paddr: u64 = 0x036_000;
541        let mm_paddr: u64 = 0x037_000;
542        let vma_paddr: u64 = 0x038_000;
543
544        let tasks_offset: u64 = 8;
545        let task_mm_offset: u64 = 40;
546
547        let mut task_page = [0u8; 4096];
548        task_page[0..4].copy_from_slice(&6000u32.to_le_bytes());
549        let list_self = task_vaddr + tasks_offset;
550        task_page[tasks_offset as usize..tasks_offset as usize + 8]
551            .copy_from_slice(&list_self.to_le_bytes());
552        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
553            .copy_from_slice(&list_self.to_le_bytes());
554        task_page[task_mm_offset as usize..task_mm_offset as usize + 8]
555            .copy_from_slice(&mm_vaddr.to_le_bytes());
556
557        let mut mm_page = [0u8; 4096];
558        mm_page[0..8].copy_from_slice(&vma_vaddr.to_le_bytes());
559
560        // VMA with vm_file = 0 (anonymous mapping)
561        let mut vma_page = [0u8; 4096];
562        vma_page[0..8].copy_from_slice(&0u64.to_le_bytes()); // vm_next = 0
563        vma_page[16..24].copy_from_slice(&0u64.to_le_bytes()); // vm_file = 0
564
565        let isf = IsfBuilder::new()
566            .add_struct("task_struct", 256)
567            .add_field("task_struct", "pid", 0u64, "unsigned int")
568            .add_field("task_struct", "tasks", tasks_offset, "list_head")
569            .add_field("task_struct", "comm", 24u64, "char")
570            .add_field("task_struct", "mm", task_mm_offset, "pointer")
571            .add_struct("list_head", 16)
572            .add_field("list_head", "next", 0u64, "pointer")
573            .add_field("list_head", "prev", 8u64, "pointer")
574            .add_struct("mm_struct", 256)
575            .add_field("mm_struct", "mmap", 0u64, "pointer")
576            .add_struct("vm_area_struct", 256)
577            .add_field("vm_area_struct", "vm_next", 0u64, "pointer")
578            .add_field("vm_area_struct", "vm_file", 16u64, "pointer")
579            .add_struct("file", 256)
580            .add_field("file", "f_path", 0u64, "pointer")
581            .add_struct("dentry", 256)
582            .add_field("dentry", "d_name", 0u64, "pointer")
583            .add_symbol("init_task", task_vaddr)
584            .build_json();
585
586        let resolver = IsfResolver::from_value(&isf).unwrap();
587        let (cr3, mem) = PageTableBuilder::new()
588            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
589            .write_phys(task_paddr, &task_page)
590            .map_4k(mm_vaddr, mm_paddr, ptflags::WRITABLE)
591            .write_phys(mm_paddr, &mm_page)
592            .map_4k(vma_vaddr, vma_paddr, ptflags::WRITABLE)
593            .write_phys(vma_paddr, &vma_page)
594            .build();
595
596        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
597        let reader: ObjectReader<memf_core::test_builders::SyntheticPhysMem> =
598            ObjectReader::new(vas, Box::new(resolver));
599
600        let result = walk_pam_hooks(&reader).expect("walk should not error");
601        assert!(
602            result.is_empty(),
603            "anonymous VMA (vm_file==0) should produce no PAM findings"
604        );
605    }
606}