memf-linux 0.2.1

Linux kernel memory forensic walkers (processes, connections, modules)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
//! Linux file_operations table hook detector.
//!
//! Rootkits often replace function pointers in `file_operations` structs
//! (read, write, open, etc.) for /proc entries or device files. By comparing
//! these pointers against the kernel text range (`_stext`..`_etext`), we can
//! detect hooks pointing to non-kernel code (loaded module code or injected
//! memory).

use memf_core::object_reader::ObjectReader;
use memf_format::PhysicalMemoryProvider;

use crate::Result;

/// Function pointer field names within the `file_operations` struct.
const FOP_FIELDS: &[&str] = &[
    "read",
    "write",
    "open",
    "release",
    "unlocked_ioctl",
    "llseek",
    "mmap",
    "poll",
    "read_iter",
    "write_iter",
];

/// Information about a file_operations struct with potential hooks.
#[derive(Debug, Clone, serde::Serialize)]
pub struct FopsHookInfo {
    /// Path of the /proc or device entry, e.g. "/proc/modules".
    pub path: String,
    /// Virtual address of the file_operations struct.
    pub struct_address: u64,
    /// List of function pointers that were checked.
    pub hooked_functions: Vec<HookedFop>,
    /// Whether any function pointer targets outside kernel text.
    pub is_suspicious: bool,
}

/// A single function pointer from a file_operations struct.
#[derive(Debug, Clone, serde::Serialize)]
pub struct HookedFop {
    /// Name of the function pointer field, e.g. "read", "write".
    pub function_name: String,
    /// Virtual address the function pointer targets.
    pub target_address: u64,
    /// Whether the target falls within the kernel text section.
    pub is_in_kernel_text: bool,
}

/// Check whether an address falls within the kernel text section.
///
/// Returns `true` if `addr` is in `[kernel_start, kernel_end)`.
/// `_etext` is the exclusive upper bound of the kernel text section.
pub fn is_kernel_text_address(addr: u64, kernel_start: u64, kernel_end: u64) -> bool {
    addr >= kernel_start && addr < kernel_end
}

/// Read function pointers from a `file_operations` struct and classify each.
///
/// For each known field in [`FOP_FIELDS`], reads the pointer value. Non-null
/// pointers are checked against the kernel text range.
pub fn check_fops_entry<P: PhysicalMemoryProvider>(
    reader: &ObjectReader<P>,
    fops_addr: u64,
    kernel_start: u64,
    kernel_end: u64,
) -> Vec<HookedFop> {
    let mut results = Vec::new();

    for &field_name in FOP_FIELDS {
        let ptr: u64 = match reader.read_pointer(fops_addr, "file_operations", field_name) {
            Ok(p) => p,
            Err(_) => continue, // Field not in symbol table, skip
        };

        // Skip null pointers — they mean the operation is not implemented
        if ptr == 0 {
            continue;
        }

        results.push(HookedFop {
            function_name: field_name.to_string(),
            target_address: ptr,
            is_in_kernel_text: is_kernel_text_address(ptr, kernel_start, kernel_end),
        });
    }

    results
}

/// Maximum number of /proc entries to walk (cycle protection).
const MAX_PROC_ENTRIES: usize = 10_000;

/// Scan key /proc entries for file_operations hooks.
///
/// Looks up `proc_root` (the root /proc directory entry), walks the
/// `proc_dir_entry` tree via `subdir`/`next`, and for each entry
/// with a non-null `proc_fops` pointer, reads the `file_operations` struct
/// and checks function pointers against the kernel text range.
///
/// Returns `Ok(Vec::new())` if required symbols (`proc_root`, `_stext`,
/// `_etext`) are missing.
pub fn scan_proc_fops<P: PhysicalMemoryProvider>(
    reader: &ObjectReader<P>,
) -> Result<Vec<FopsHookInfo>> {
    // Look up required symbols; return empty if missing (graceful degradation)
    let Some(proc_root) = reader.symbols().symbol_address("proc_root") else {
        return Ok(Vec::new());
    };
    let Some(kernel_start) = reader.symbols().symbol_address("_stext") else {
        return Ok(Vec::new());
    };
    let Some(kernel_end) = reader.symbols().symbol_address("_etext") else {
        return Ok(Vec::new());
    };

    let mut results = Vec::new();

    // Walk the proc_dir_entry tree starting from proc_root's subdir
    let mut stack = Vec::new();
    let subdir: u64 = reader
        .read_pointer(proc_root, "proc_dir_entry", "subdir")
        .unwrap_or(0);
    if subdir != 0 {
        stack.push((subdir, "/proc".to_string()));
    }

    let mut visited = 0usize;
    while let Some((entry_addr, parent_path)) = stack.pop() {
        if visited >= MAX_PROC_ENTRIES {
            break;
        }
        visited += 1;

        // Read the entry name
        let name = reader
            .read_field_string(entry_addr, "proc_dir_entry", "name", 128)
            .unwrap_or_else(|_| "<unknown>".to_string());
        let path = format!("{parent_path}/{name}");

        // Check if this entry has a proc_fops pointer
        let fops_addr: u64 = reader
            .read_pointer(entry_addr, "proc_dir_entry", "proc_fops")
            .unwrap_or(0);

        if fops_addr != 0 {
            let hooked_functions = check_fops_entry(reader, fops_addr, kernel_start, kernel_end);
            let is_suspicious = hooked_functions.iter().any(|f| !f.is_in_kernel_text);

            results.push(FopsHookInfo {
                path: path.clone(),
                struct_address: fops_addr,
                hooked_functions,
                is_suspicious,
            });
        }

        // Recurse into subdirectories
        let child: u64 = reader
            .read_pointer(entry_addr, "proc_dir_entry", "subdir")
            .unwrap_or(0);
        if child != 0 {
            stack.push((child, path));
        }

        // Follow the next sibling
        let next: u64 = reader
            .read_pointer(entry_addr, "proc_dir_entry", "next")
            .unwrap_or(0);
        if next != 0 {
            stack.push((next, parent_path));
        }
    }

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;
    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
    use memf_symbols::isf::IsfResolver;
    use memf_symbols::test_builders::IsfBuilder;

    // -----------------------------------------------------------------------
    // is_kernel_text_address tests
    // -----------------------------------------------------------------------

    #[test]
    fn is_kernel_text_address_inside() {
        let start = 0xFFFF_8000_0000_0000u64;
        let end = 0xFFFF_8000_00FF_FFFFu64;

        // Exactly at start
        assert!(is_kernel_text_address(start, start, end));
        // In the middle
        assert!(is_kernel_text_address(start + 0x1000, start, end));
        // One below end (last valid address, since end is exclusive)
        assert!(is_kernel_text_address(end - 1, start, end));
    }

    #[test]
    fn is_kernel_text_address_outside() {
        let start = 0xFFFF_8000_0000_0000u64;
        let end = 0xFFFF_8000_00FF_FFFFu64;

        // One below start
        assert!(!is_kernel_text_address(start - 1, start, end));
        // Exactly at end (exclusive upper bound — not inside)
        assert!(!is_kernel_text_address(end, start, end));
        // One above end
        assert!(!is_kernel_text_address(end + 1, start, end));
        // Way outside (module space)
        assert!(!is_kernel_text_address(0xFFFF_C900_DEAD_BEEF, start, end));
        // Zero address
        assert!(!is_kernel_text_address(0, start, end));
    }

    // -----------------------------------------------------------------------
    // check_fops_entry tests
    // -----------------------------------------------------------------------

    /// Helper: build a test reader with a file_operations struct in memory.
    fn make_fops_reader(
        fops_data: &[u8],
        fops_vaddr: u64,
        fops_paddr: u64,
        kernel_start: u64,
        kernel_end: u64,
    ) -> ObjectReader<SyntheticPhysMem> {
        let isf = IsfBuilder::new()
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0, "pointer")
            .add_field("file_operations", "write", 8, "pointer")
            .add_field("file_operations", "open", 16, "pointer")
            .add_field("file_operations", "release", 24, "pointer")
            .add_field("file_operations", "unlocked_ioctl", 32, "pointer")
            .add_field("file_operations", "llseek", 40, "pointer")
            .add_field("file_operations", "mmap", 48, "pointer")
            .add_field("file_operations", "poll", 56, "pointer")
            .add_field("file_operations", "read_iter", 64, "pointer")
            .add_field("file_operations", "write_iter", 72, "pointer")
            .add_symbol("_stext", kernel_start)
            .add_symbol("_etext", kernel_end)
            .build_json();

        let resolver = IsfResolver::from_value(&isf).unwrap();
        let (cr3, mem) = PageTableBuilder::new()
            .map_4k(fops_vaddr, fops_paddr, ptflags::WRITABLE)
            .write_phys(fops_paddr, fops_data)
            .build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        ObjectReader::new(vas, Box::new(resolver))
    }

    #[test]
    fn classify_fops_all_kernel() {
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
        let fops_paddr: u64 = 0x0080_0000;

        // Build a file_operations struct where all pointers are in kernel text
        let mut fops_data = vec![0u8; 4096];
        let kernel_func = kernel_start + 0x1000; // Solidly inside kernel text
        for i in 0..FOP_FIELDS.len() {
            let offset = i * 8;
            fops_data[offset..offset + 8].copy_from_slice(&kernel_func.to_le_bytes());
        }

        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);

        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);

        // All function pointers should be classified as in-kernel
        assert!(!results.is_empty());
        for fop in &results {
            assert!(
                fop.is_in_kernel_text,
                "function {} at {:#x} should be in kernel text",
                fop.function_name, fop.target_address,
            );
        }
    }

    #[test]
    fn classify_fops_hooked_pointer() {
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
        let fops_paddr: u64 = 0x0080_0000;

        // Build file_operations: read points to module space, rest are kernel
        let mut fops_data = vec![0u8; 4096];
        let kernel_func = kernel_start + 0x1000;
        let hooked_addr: u64 = 0xFFFF_C900_DEAD_BEEF; // Outside kernel text (module space)

        // read (offset 0) is hooked
        fops_data[0..8].copy_from_slice(&hooked_addr.to_le_bytes());
        // write through write_iter are kernel
        for i in 1..FOP_FIELDS.len() {
            let offset = i * 8;
            fops_data[offset..offset + 8].copy_from_slice(&kernel_func.to_le_bytes());
        }

        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);

        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);

        // Find the "read" entry
        let read_fop = results.iter().find(|f| f.function_name == "read").unwrap();
        assert!(!read_fop.is_in_kernel_text);
        assert_eq!(read_fop.target_address, hooked_addr);

        // All others should be in-kernel
        for fop in results.iter().filter(|f| f.function_name != "read") {
            assert!(
                fop.is_in_kernel_text,
                "function {} should be in kernel text",
                fop.function_name,
            );
        }
    }

    // -----------------------------------------------------------------------
    // scan_proc_fops tests
    // -----------------------------------------------------------------------

    #[test]
    fn scan_proc_fops_no_symbol() {
        // No proc_root symbol → should return Ok(empty vec), not an error
        let isf = IsfBuilder::new()
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0, "pointer")
            .build_json();

        let resolver = IsfResolver::from_value(&isf).unwrap();
        let (cr3, mem) = PageTableBuilder::new().build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn scan_proc_fops_missing_stext_returns_empty() {
        // proc_root present but _stext absent → graceful empty
        let isf = IsfBuilder::new()
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0, "pointer")
            .add_symbol("proc_root", 0xFFFF_8000_0010_0000)
            // _stext intentionally omitted
            .add_symbol("_etext", 0xFFFF_8000_00FF_FFFF)
            .build_json();

        let resolver = IsfResolver::from_value(&isf).unwrap();
        let (cr3, mem) = PageTableBuilder::new().build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap();
        assert!(results.is_empty(), "missing _stext should yield empty vec");
    }

    #[test]
    fn scan_proc_fops_missing_etext_returns_empty() {
        // proc_root + _stext present but _etext absent → graceful empty
        let isf = IsfBuilder::new()
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0, "pointer")
            .add_symbol("proc_root", 0xFFFF_8000_0010_0000)
            .add_symbol("_stext", 0xFFFF_8000_0000_0000)
            // _etext intentionally omitted
            .build_json();

        let resolver = IsfResolver::from_value(&isf).unwrap();
        let (cr3, mem) = PageTableBuilder::new().build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap();
        assert!(results.is_empty(), "missing _etext should yield empty vec");
    }

    // -----------------------------------------------------------------------
    // scan_proc_fops: all symbols present, proc_root.subdir == 0 → empty
    // -----------------------------------------------------------------------

    #[test]
    fn scan_proc_fops_symbol_present_empty_proc_tree() {
        // proc_root, _stext, _etext all present. proc_root memory is all
        // zeros so proc_dir_entry.subdir == 0 → stack stays empty → no results.
        let proc_root_vaddr: u64 = 0xFFFF_8800_0060_0000;
        let proc_root_paddr: u64 = 0x0070_0000;
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;

        // All zeros: proc_dir_entry.subdir pointer at offset 0 = 0
        let page = [0u8; 4096];

        let isf = IsfBuilder::new()
            .add_struct("proc_dir_entry", 256)
            .add_field("proc_dir_entry", "subdir", 0, "pointer")
            .add_field("proc_dir_entry", "next", 8, "pointer")
            .add_field("proc_dir_entry", "proc_fops", 16, "pointer")
            .add_field("proc_dir_entry", "name", 24, "char")
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0, "pointer")
            .add_symbol("proc_root", proc_root_vaddr)
            .add_symbol("_stext", kernel_start)
            .add_symbol("_etext", kernel_end)
            .build_json();

        let resolver = IsfResolver::from_value(&isf).unwrap();
        let (cr3, mem) = PageTableBuilder::new()
            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
            .write_phys(proc_root_paddr, &page)
            .build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap_or_default();
        assert!(
            results.is_empty(),
            "empty proc tree should produce no fops hook entries"
        );
    }

    #[test]
    fn scan_proc_fops_with_entry_no_proc_fops() {
        // proc_root → subdir → one entry with proc_fops == 0 → no hook info recorded
        let proc_root_vaddr: u64 = 0xFFFF_8800_0070_0000;
        let proc_root_paddr: u64 = 0x0040_0000;
        let entry_vaddr: u64 = 0xFFFF_8800_0071_0000;
        let entry_paddr: u64 = 0x0041_0000;
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;

        // proc_root page: subdir at offset 0 → entry_vaddr
        let mut root_page = [0u8; 4096];
        root_page[0..8].copy_from_slice(&entry_vaddr.to_le_bytes()); // subdir
                                                                     // next at 8 = 0, proc_fops at 16 = 0

        // entry page: subdir=0, next=0, proc_fops=0, name="modules"
        let mut entry_page = [0u8; 4096];
        // subdir at 0 = 0, next at 8 = 0, proc_fops at 16 = 0
        entry_page[24..31].copy_from_slice(b"modules"); // name

        let isf = IsfBuilder::new()
            .add_struct("proc_dir_entry", 256)
            .add_field("proc_dir_entry", "subdir", 0x00u64, "pointer")
            .add_field("proc_dir_entry", "next", 0x08u64, "pointer")
            .add_field("proc_dir_entry", "proc_fops", 0x10u64, "pointer")
            .add_field("proc_dir_entry", "name", 0x18u64, "char")
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0x00u64, "pointer")
            .add_symbol("proc_root", proc_root_vaddr)
            .add_symbol("_stext", kernel_start)
            .add_symbol("_etext", kernel_end)
            .build_json();
        let resolver = IsfResolver::from_value(&isf).unwrap();

        let (cr3, mem) = PageTableBuilder::new()
            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
            .write_phys(proc_root_paddr, &root_page)
            .map_4k(entry_vaddr, entry_paddr, ptflags::WRITABLE)
            .write_phys(entry_paddr, &entry_page)
            .build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap_or_default();
        // proc_fops == 0 → no FopsHookInfo pushed
        assert!(
            results.is_empty(),
            "entry with proc_fops==0 should produce no hook entries"
        );
    }

    #[test]
    fn scan_proc_fops_with_entry_and_proc_fops_in_kernel() {
        // proc_root → subdir → entry with proc_fops pointing to a mapped fops struct
        // fops.read is inside kernel text → is_suspicious = false
        let proc_root_vaddr: u64 = 0xFFFF_8800_0080_0000;
        let proc_root_paddr: u64 = 0x0042_0000;
        let entry_vaddr: u64 = 0xFFFF_8800_0081_0000;
        let entry_paddr: u64 = 0x0043_0000;
        let fops_vaddr: u64 = 0xFFFF_8800_0082_0000;
        let fops_paddr: u64 = 0x0044_0000;
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
        let kernel_func: u64 = kernel_start + 0x5000;

        // proc_root: subdir → entry
        let mut root_page = [0u8; 4096];
        root_page[0..8].copy_from_slice(&entry_vaddr.to_le_bytes());

        // entry: subdir=0, next=0, proc_fops=fops_vaddr, name="net"
        let mut entry_page = [0u8; 4096];
        // subdir at 0 = 0
        // next at 8 = 0
        entry_page[0x10..0x18].copy_from_slice(&fops_vaddr.to_le_bytes()); // proc_fops
        entry_page[0x18..0x1b].copy_from_slice(b"net"); // name

        // fops: read at offset 0 = kernel_func (inside kernel text)
        let mut fops_page = [0u8; 4096];
        fops_page[0..8].copy_from_slice(&kernel_func.to_le_bytes()); // read

        let isf = IsfBuilder::new()
            .add_struct("proc_dir_entry", 256)
            .add_field("proc_dir_entry", "subdir", 0x00u64, "pointer")
            .add_field("proc_dir_entry", "next", 0x08u64, "pointer")
            .add_field("proc_dir_entry", "proc_fops", 0x10u64, "pointer")
            .add_field("proc_dir_entry", "name", 0x18u64, "char")
            .add_struct("file_operations", 256)
            .add_field("file_operations", "read", 0x00u64, "pointer")
            .add_field("file_operations", "write", 0x08u64, "pointer")
            .add_field("file_operations", "open", 0x10u64, "pointer")
            .add_field("file_operations", "release", 0x18u64, "pointer")
            .add_field("file_operations", "unlocked_ioctl", 0x20u64, "pointer")
            .add_field("file_operations", "llseek", 0x28u64, "pointer")
            .add_field("file_operations", "mmap", 0x30u64, "pointer")
            .add_field("file_operations", "poll", 0x38u64, "pointer")
            .add_field("file_operations", "read_iter", 0x40u64, "pointer")
            .add_field("file_operations", "write_iter", 0x48u64, "pointer")
            .add_symbol("proc_root", proc_root_vaddr)
            .add_symbol("_stext", kernel_start)
            .add_symbol("_etext", kernel_end)
            .build_json();
        let resolver = IsfResolver::from_value(&isf).unwrap();

        let (cr3, mem) = PageTableBuilder::new()
            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
            .write_phys(proc_root_paddr, &root_page)
            .map_4k(entry_vaddr, entry_paddr, ptflags::WRITABLE)
            .write_phys(entry_paddr, &entry_page)
            .map_4k(fops_vaddr, fops_paddr, ptflags::WRITABLE)
            .write_phys(fops_paddr, &fops_page)
            .build();
        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
        let reader = ObjectReader::new(vas, Box::new(resolver));

        let results = scan_proc_fops(&reader).unwrap_or_default();
        assert_eq!(
            results.len(),
            1,
            "should find exactly one entry with proc_fops"
        );
        let entry = &results[0];
        assert!(
            !entry.is_suspicious,
            "kernel-text pointer should not be suspicious"
        );
        assert!(
            entry.path.contains("net") || entry.path.contains("/proc"),
            "path should contain entry name"
        );
    }

    #[test]
    fn check_fops_entry_null_pointer_skipped() {
        // file_operations struct where all pointers are NULL → no results
        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
        let fops_paddr: u64 = 0x0080_0000;

        // All zeros (null pointers) in the fops struct
        let fops_data = vec![0u8; 4096];

        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);
        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);

        assert!(
            results.is_empty(),
            "all-null fops struct should produce no HookedFop entries"
        );
    }
}