Skip to main content

memf_linux/
check_fops.rs

1//! Linux file_operations table hook detector.
2//!
3//! Rootkits often replace function pointers in `file_operations` structs
4//! (read, write, open, etc.) for /proc entries or device files. By comparing
5//! these pointers against the kernel text range (`_stext`..`_etext`), we can
6//! detect hooks pointing to non-kernel code (loaded module code or injected
7//! memory).
8
9use memf_core::object_reader::ObjectReader;
10use memf_format::PhysicalMemoryProvider;
11
12use crate::Result;
13
14/// Function pointer field names within the `file_operations` struct.
15const FOP_FIELDS: &[&str] = &[
16    "read",
17    "write",
18    "open",
19    "release",
20    "unlocked_ioctl",
21    "llseek",
22    "mmap",
23    "poll",
24    "read_iter",
25    "write_iter",
26];
27
28/// Information about a file_operations struct with potential hooks.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct FopsHookInfo {
31    /// Path of the /proc or device entry, e.g. "/proc/modules".
32    pub path: String,
33    /// Virtual address of the file_operations struct.
34    pub struct_address: u64,
35    /// List of function pointers that were checked.
36    pub hooked_functions: Vec<HookedFop>,
37    /// Whether any function pointer targets outside kernel text.
38    pub is_suspicious: bool,
39}
40
41/// A single function pointer from a file_operations struct.
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct HookedFop {
44    /// Name of the function pointer field, e.g. "read", "write".
45    pub function_name: String,
46    /// Virtual address the function pointer targets.
47    pub target_address: u64,
48    /// Whether the target falls within the kernel text section.
49    pub is_in_kernel_text: bool,
50}
51
52/// Check whether an address falls within the kernel text section.
53///
54/// Returns `true` if `addr` is in `[kernel_start, kernel_end)`.
55/// `_etext` is the exclusive upper bound of the kernel text section.
56pub fn is_kernel_text_address(addr: u64, kernel_start: u64, kernel_end: u64) -> bool {
57    addr >= kernel_start && addr < kernel_end
58}
59
60/// Read function pointers from a `file_operations` struct and classify each.
61///
62/// For each known field in [`FOP_FIELDS`], reads the pointer value. Non-null
63/// pointers are checked against the kernel text range.
64pub fn check_fops_entry<P: PhysicalMemoryProvider>(
65    reader: &ObjectReader<P>,
66    fops_addr: u64,
67    kernel_start: u64,
68    kernel_end: u64,
69) -> Vec<HookedFop> {
70    let mut results = Vec::new();
71
72    for &field_name in FOP_FIELDS {
73        let ptr: u64 = match reader.read_pointer(fops_addr, "file_operations", field_name) {
74            Ok(p) => p,
75            Err(_) => continue, // Field not in symbol table, skip
76        };
77
78        // Skip null pointers — they mean the operation is not implemented
79        if ptr == 0 {
80            continue;
81        }
82
83        results.push(HookedFop {
84            function_name: field_name.to_string(),
85            target_address: ptr,
86            is_in_kernel_text: is_kernel_text_address(ptr, kernel_start, kernel_end),
87        });
88    }
89
90    results
91}
92
93/// Maximum number of /proc entries to walk (cycle protection).
94const MAX_PROC_ENTRIES: usize = 10_000;
95
96/// Scan key /proc entries for file_operations hooks.
97///
98/// Looks up `proc_root` (the root /proc directory entry), walks the
99/// `proc_dir_entry` tree via `subdir`/`next`, and for each entry
100/// with a non-null `proc_fops` pointer, reads the `file_operations` struct
101/// and checks function pointers against the kernel text range.
102///
103/// Returns `Ok(Vec::new())` if required symbols (`proc_root`, `_stext`,
104/// `_etext`) are missing.
105pub fn scan_proc_fops<P: PhysicalMemoryProvider>(
106    reader: &ObjectReader<P>,
107) -> Result<Vec<FopsHookInfo>> {
108    // Look up required symbols; return empty if missing (graceful degradation)
109    let Some(proc_root) = reader.symbols().symbol_address("proc_root") else {
110        return Ok(Vec::new());
111    };
112    let Some(kernel_start) = reader.symbols().symbol_address("_stext") else {
113        return Ok(Vec::new());
114    };
115    let Some(kernel_end) = reader.symbols().symbol_address("_etext") else {
116        return Ok(Vec::new());
117    };
118
119    let mut results = Vec::new();
120
121    // Walk the proc_dir_entry tree starting from proc_root's subdir
122    let mut stack = Vec::new();
123    let subdir: u64 = reader
124        .read_pointer(proc_root, "proc_dir_entry", "subdir")
125        .unwrap_or(0);
126    if subdir != 0 {
127        stack.push((subdir, "/proc".to_string()));
128    }
129
130    let mut visited = 0usize;
131    while let Some((entry_addr, parent_path)) = stack.pop() {
132        if visited >= MAX_PROC_ENTRIES {
133            break;
134        }
135        visited += 1;
136
137        // Read the entry name
138        let name = reader
139            .read_field_string(entry_addr, "proc_dir_entry", "name", 128)
140            .unwrap_or_else(|_| "<unknown>".to_string());
141        let path = format!("{parent_path}/{name}");
142
143        // Check if this entry has a proc_fops pointer
144        let fops_addr: u64 = reader
145            .read_pointer(entry_addr, "proc_dir_entry", "proc_fops")
146            .unwrap_or(0);
147
148        if fops_addr != 0 {
149            let hooked_functions = check_fops_entry(reader, fops_addr, kernel_start, kernel_end);
150            let is_suspicious = hooked_functions.iter().any(|f| !f.is_in_kernel_text);
151
152            results.push(FopsHookInfo {
153                path: path.clone(),
154                struct_address: fops_addr,
155                hooked_functions,
156                is_suspicious,
157            });
158        }
159
160        // Recurse into subdirectories
161        let child: u64 = reader
162            .read_pointer(entry_addr, "proc_dir_entry", "subdir")
163            .unwrap_or(0);
164        if child != 0 {
165            stack.push((child, path));
166        }
167
168        // Follow the next sibling
169        let next: u64 = reader
170            .read_pointer(entry_addr, "proc_dir_entry", "next")
171            .unwrap_or(0);
172        if next != 0 {
173            stack.push((next, parent_path));
174        }
175    }
176
177    Ok(results)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
184    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
185    use memf_symbols::isf::IsfResolver;
186    use memf_symbols::test_builders::IsfBuilder;
187
188    // -----------------------------------------------------------------------
189    // is_kernel_text_address tests
190    // -----------------------------------------------------------------------
191
192    #[test]
193    fn is_kernel_text_address_inside() {
194        let start = 0xFFFF_8000_0000_0000u64;
195        let end = 0xFFFF_8000_00FF_FFFFu64;
196
197        // Exactly at start
198        assert!(is_kernel_text_address(start, start, end));
199        // In the middle
200        assert!(is_kernel_text_address(start + 0x1000, start, end));
201        // One below end (last valid address, since end is exclusive)
202        assert!(is_kernel_text_address(end - 1, start, end));
203    }
204
205    #[test]
206    fn is_kernel_text_address_outside() {
207        let start = 0xFFFF_8000_0000_0000u64;
208        let end = 0xFFFF_8000_00FF_FFFFu64;
209
210        // One below start
211        assert!(!is_kernel_text_address(start - 1, start, end));
212        // Exactly at end (exclusive upper bound — not inside)
213        assert!(!is_kernel_text_address(end, start, end));
214        // One above end
215        assert!(!is_kernel_text_address(end + 1, start, end));
216        // Way outside (module space)
217        assert!(!is_kernel_text_address(0xFFFF_C900_DEAD_BEEF, start, end));
218        // Zero address
219        assert!(!is_kernel_text_address(0, start, end));
220    }
221
222    // -----------------------------------------------------------------------
223    // check_fops_entry tests
224    // -----------------------------------------------------------------------
225
226    /// Helper: build a test reader with a file_operations struct in memory.
227    fn make_fops_reader(
228        fops_data: &[u8],
229        fops_vaddr: u64,
230        fops_paddr: u64,
231        kernel_start: u64,
232        kernel_end: u64,
233    ) -> ObjectReader<SyntheticPhysMem> {
234        let isf = IsfBuilder::new()
235            .add_struct("file_operations", 256)
236            .add_field("file_operations", "read", 0, "pointer")
237            .add_field("file_operations", "write", 8, "pointer")
238            .add_field("file_operations", "open", 16, "pointer")
239            .add_field("file_operations", "release", 24, "pointer")
240            .add_field("file_operations", "unlocked_ioctl", 32, "pointer")
241            .add_field("file_operations", "llseek", 40, "pointer")
242            .add_field("file_operations", "mmap", 48, "pointer")
243            .add_field("file_operations", "poll", 56, "pointer")
244            .add_field("file_operations", "read_iter", 64, "pointer")
245            .add_field("file_operations", "write_iter", 72, "pointer")
246            .add_symbol("_stext", kernel_start)
247            .add_symbol("_etext", kernel_end)
248            .build_json();
249
250        let resolver = IsfResolver::from_value(&isf).unwrap();
251        let (cr3, mem) = PageTableBuilder::new()
252            .map_4k(fops_vaddr, fops_paddr, ptflags::WRITABLE)
253            .write_phys(fops_paddr, fops_data)
254            .build();
255        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
256        ObjectReader::new(vas, Box::new(resolver))
257    }
258
259    #[test]
260    fn classify_fops_all_kernel() {
261        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
262        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
263        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
264        let fops_paddr: u64 = 0x0080_0000;
265
266        // Build a file_operations struct where all pointers are in kernel text
267        let mut fops_data = vec![0u8; 4096];
268        let kernel_func = kernel_start + 0x1000; // Solidly inside kernel text
269        for i in 0..FOP_FIELDS.len() {
270            let offset = i * 8;
271            fops_data[offset..offset + 8].copy_from_slice(&kernel_func.to_le_bytes());
272        }
273
274        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);
275
276        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);
277
278        // All function pointers should be classified as in-kernel
279        assert!(!results.is_empty());
280        for fop in &results {
281            assert!(
282                fop.is_in_kernel_text,
283                "function {} at {:#x} should be in kernel text",
284                fop.function_name, fop.target_address,
285            );
286        }
287    }
288
289    #[test]
290    fn classify_fops_hooked_pointer() {
291        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
292        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
293        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
294        let fops_paddr: u64 = 0x0080_0000;
295
296        // Build file_operations: read points to module space, rest are kernel
297        let mut fops_data = vec![0u8; 4096];
298        let kernel_func = kernel_start + 0x1000;
299        let hooked_addr: u64 = 0xFFFF_C900_DEAD_BEEF; // Outside kernel text (module space)
300
301        // read (offset 0) is hooked
302        fops_data[0..8].copy_from_slice(&hooked_addr.to_le_bytes());
303        // write through write_iter are kernel
304        for i in 1..FOP_FIELDS.len() {
305            let offset = i * 8;
306            fops_data[offset..offset + 8].copy_from_slice(&kernel_func.to_le_bytes());
307        }
308
309        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);
310
311        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);
312
313        // Find the "read" entry
314        let read_fop = results.iter().find(|f| f.function_name == "read").unwrap();
315        assert!(!read_fop.is_in_kernel_text);
316        assert_eq!(read_fop.target_address, hooked_addr);
317
318        // All others should be in-kernel
319        for fop in results.iter().filter(|f| f.function_name != "read") {
320            assert!(
321                fop.is_in_kernel_text,
322                "function {} should be in kernel text",
323                fop.function_name,
324            );
325        }
326    }
327
328    // -----------------------------------------------------------------------
329    // scan_proc_fops tests
330    // -----------------------------------------------------------------------
331
332    #[test]
333    fn scan_proc_fops_no_symbol() {
334        // No proc_root symbol → should return Ok(empty vec), not an error
335        let isf = IsfBuilder::new()
336            .add_struct("file_operations", 256)
337            .add_field("file_operations", "read", 0, "pointer")
338            .build_json();
339
340        let resolver = IsfResolver::from_value(&isf).unwrap();
341        let (cr3, mem) = PageTableBuilder::new().build();
342        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
343        let reader = ObjectReader::new(vas, Box::new(resolver));
344
345        let results = scan_proc_fops(&reader).unwrap();
346        assert!(results.is_empty());
347    }
348
349    #[test]
350    fn scan_proc_fops_missing_stext_returns_empty() {
351        // proc_root present but _stext absent → graceful empty
352        let isf = IsfBuilder::new()
353            .add_struct("file_operations", 256)
354            .add_field("file_operations", "read", 0, "pointer")
355            .add_symbol("proc_root", 0xFFFF_8000_0010_0000)
356            // _stext intentionally omitted
357            .add_symbol("_etext", 0xFFFF_8000_00FF_FFFF)
358            .build_json();
359
360        let resolver = IsfResolver::from_value(&isf).unwrap();
361        let (cr3, mem) = PageTableBuilder::new().build();
362        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
363        let reader = ObjectReader::new(vas, Box::new(resolver));
364
365        let results = scan_proc_fops(&reader).unwrap();
366        assert!(results.is_empty(), "missing _stext should yield empty vec");
367    }
368
369    #[test]
370    fn scan_proc_fops_missing_etext_returns_empty() {
371        // proc_root + _stext present but _etext absent → graceful empty
372        let isf = IsfBuilder::new()
373            .add_struct("file_operations", 256)
374            .add_field("file_operations", "read", 0, "pointer")
375            .add_symbol("proc_root", 0xFFFF_8000_0010_0000)
376            .add_symbol("_stext", 0xFFFF_8000_0000_0000)
377            // _etext intentionally omitted
378            .build_json();
379
380        let resolver = IsfResolver::from_value(&isf).unwrap();
381        let (cr3, mem) = PageTableBuilder::new().build();
382        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
383        let reader = ObjectReader::new(vas, Box::new(resolver));
384
385        let results = scan_proc_fops(&reader).unwrap();
386        assert!(results.is_empty(), "missing _etext should yield empty vec");
387    }
388
389    // -----------------------------------------------------------------------
390    // scan_proc_fops: all symbols present, proc_root.subdir == 0 → empty
391    // -----------------------------------------------------------------------
392
393    #[test]
394    fn scan_proc_fops_symbol_present_empty_proc_tree() {
395        // proc_root, _stext, _etext all present. proc_root memory is all
396        // zeros so proc_dir_entry.subdir == 0 → stack stays empty → no results.
397        let proc_root_vaddr: u64 = 0xFFFF_8800_0060_0000;
398        let proc_root_paddr: u64 = 0x0070_0000;
399        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
400        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
401
402        // All zeros: proc_dir_entry.subdir pointer at offset 0 = 0
403        let page = [0u8; 4096];
404
405        let isf = IsfBuilder::new()
406            .add_struct("proc_dir_entry", 256)
407            .add_field("proc_dir_entry", "subdir", 0, "pointer")
408            .add_field("proc_dir_entry", "next", 8, "pointer")
409            .add_field("proc_dir_entry", "proc_fops", 16, "pointer")
410            .add_field("proc_dir_entry", "name", 24, "char")
411            .add_struct("file_operations", 256)
412            .add_field("file_operations", "read", 0, "pointer")
413            .add_symbol("proc_root", proc_root_vaddr)
414            .add_symbol("_stext", kernel_start)
415            .add_symbol("_etext", kernel_end)
416            .build_json();
417
418        let resolver = IsfResolver::from_value(&isf).unwrap();
419        let (cr3, mem) = PageTableBuilder::new()
420            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
421            .write_phys(proc_root_paddr, &page)
422            .build();
423        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
424        let reader = ObjectReader::new(vas, Box::new(resolver));
425
426        let results = scan_proc_fops(&reader).unwrap_or_default();
427        assert!(
428            results.is_empty(),
429            "empty proc tree should produce no fops hook entries"
430        );
431    }
432
433    #[test]
434    fn scan_proc_fops_with_entry_no_proc_fops() {
435        // proc_root → subdir → one entry with proc_fops == 0 → no hook info recorded
436        let proc_root_vaddr: u64 = 0xFFFF_8800_0070_0000;
437        let proc_root_paddr: u64 = 0x0040_0000;
438        let entry_vaddr: u64 = 0xFFFF_8800_0071_0000;
439        let entry_paddr: u64 = 0x0041_0000;
440        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
441        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
442
443        // proc_root page: subdir at offset 0 → entry_vaddr
444        let mut root_page = [0u8; 4096];
445        root_page[0..8].copy_from_slice(&entry_vaddr.to_le_bytes()); // subdir
446                                                                     // next at 8 = 0, proc_fops at 16 = 0
447
448        // entry page: subdir=0, next=0, proc_fops=0, name="modules"
449        let mut entry_page = [0u8; 4096];
450        // subdir at 0 = 0, next at 8 = 0, proc_fops at 16 = 0
451        entry_page[24..31].copy_from_slice(b"modules"); // name
452
453        let isf = IsfBuilder::new()
454            .add_struct("proc_dir_entry", 256)
455            .add_field("proc_dir_entry", "subdir", 0x00u64, "pointer")
456            .add_field("proc_dir_entry", "next", 0x08u64, "pointer")
457            .add_field("proc_dir_entry", "proc_fops", 0x10u64, "pointer")
458            .add_field("proc_dir_entry", "name", 0x18u64, "char")
459            .add_struct("file_operations", 256)
460            .add_field("file_operations", "read", 0x00u64, "pointer")
461            .add_symbol("proc_root", proc_root_vaddr)
462            .add_symbol("_stext", kernel_start)
463            .add_symbol("_etext", kernel_end)
464            .build_json();
465        let resolver = IsfResolver::from_value(&isf).unwrap();
466
467        let (cr3, mem) = PageTableBuilder::new()
468            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
469            .write_phys(proc_root_paddr, &root_page)
470            .map_4k(entry_vaddr, entry_paddr, ptflags::WRITABLE)
471            .write_phys(entry_paddr, &entry_page)
472            .build();
473        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
474        let reader = ObjectReader::new(vas, Box::new(resolver));
475
476        let results = scan_proc_fops(&reader).unwrap_or_default();
477        // proc_fops == 0 → no FopsHookInfo pushed
478        assert!(
479            results.is_empty(),
480            "entry with proc_fops==0 should produce no hook entries"
481        );
482    }
483
484    #[test]
485    fn scan_proc_fops_with_entry_and_proc_fops_in_kernel() {
486        // proc_root → subdir → entry with proc_fops pointing to a mapped fops struct
487        // fops.read is inside kernel text → is_suspicious = false
488        let proc_root_vaddr: u64 = 0xFFFF_8800_0080_0000;
489        let proc_root_paddr: u64 = 0x0042_0000;
490        let entry_vaddr: u64 = 0xFFFF_8800_0081_0000;
491        let entry_paddr: u64 = 0x0043_0000;
492        let fops_vaddr: u64 = 0xFFFF_8800_0082_0000;
493        let fops_paddr: u64 = 0x0044_0000;
494        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
495        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
496        let kernel_func: u64 = kernel_start + 0x5000;
497
498        // proc_root: subdir → entry
499        let mut root_page = [0u8; 4096];
500        root_page[0..8].copy_from_slice(&entry_vaddr.to_le_bytes());
501
502        // entry: subdir=0, next=0, proc_fops=fops_vaddr, name="net"
503        let mut entry_page = [0u8; 4096];
504        // subdir at 0 = 0
505        // next at 8 = 0
506        entry_page[0x10..0x18].copy_from_slice(&fops_vaddr.to_le_bytes()); // proc_fops
507        entry_page[0x18..0x1b].copy_from_slice(b"net"); // name
508
509        // fops: read at offset 0 = kernel_func (inside kernel text)
510        let mut fops_page = [0u8; 4096];
511        fops_page[0..8].copy_from_slice(&kernel_func.to_le_bytes()); // read
512
513        let isf = IsfBuilder::new()
514            .add_struct("proc_dir_entry", 256)
515            .add_field("proc_dir_entry", "subdir", 0x00u64, "pointer")
516            .add_field("proc_dir_entry", "next", 0x08u64, "pointer")
517            .add_field("proc_dir_entry", "proc_fops", 0x10u64, "pointer")
518            .add_field("proc_dir_entry", "name", 0x18u64, "char")
519            .add_struct("file_operations", 256)
520            .add_field("file_operations", "read", 0x00u64, "pointer")
521            .add_field("file_operations", "write", 0x08u64, "pointer")
522            .add_field("file_operations", "open", 0x10u64, "pointer")
523            .add_field("file_operations", "release", 0x18u64, "pointer")
524            .add_field("file_operations", "unlocked_ioctl", 0x20u64, "pointer")
525            .add_field("file_operations", "llseek", 0x28u64, "pointer")
526            .add_field("file_operations", "mmap", 0x30u64, "pointer")
527            .add_field("file_operations", "poll", 0x38u64, "pointer")
528            .add_field("file_operations", "read_iter", 0x40u64, "pointer")
529            .add_field("file_operations", "write_iter", 0x48u64, "pointer")
530            .add_symbol("proc_root", proc_root_vaddr)
531            .add_symbol("_stext", kernel_start)
532            .add_symbol("_etext", kernel_end)
533            .build_json();
534        let resolver = IsfResolver::from_value(&isf).unwrap();
535
536        let (cr3, mem) = PageTableBuilder::new()
537            .map_4k(proc_root_vaddr, proc_root_paddr, ptflags::WRITABLE)
538            .write_phys(proc_root_paddr, &root_page)
539            .map_4k(entry_vaddr, entry_paddr, ptflags::WRITABLE)
540            .write_phys(entry_paddr, &entry_page)
541            .map_4k(fops_vaddr, fops_paddr, ptflags::WRITABLE)
542            .write_phys(fops_paddr, &fops_page)
543            .build();
544        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
545        let reader = ObjectReader::new(vas, Box::new(resolver));
546
547        let results = scan_proc_fops(&reader).unwrap_or_default();
548        assert_eq!(
549            results.len(),
550            1,
551            "should find exactly one entry with proc_fops"
552        );
553        let entry = &results[0];
554        assert!(
555            !entry.is_suspicious,
556            "kernel-text pointer should not be suspicious"
557        );
558        assert!(
559            entry.path.contains("net") || entry.path.contains("/proc"),
560            "path should contain entry name"
561        );
562    }
563
564    #[test]
565    fn check_fops_entry_null_pointer_skipped() {
566        // file_operations struct where all pointers are NULL → no results
567        let kernel_start: u64 = 0xFFFF_8000_0000_0000;
568        let kernel_end: u64 = 0xFFFF_8000_00FF_FFFF;
569        let fops_vaddr: u64 = 0xFFFF_8000_0010_0000;
570        let fops_paddr: u64 = 0x0080_0000;
571
572        // All zeros (null pointers) in the fops struct
573        let fops_data = vec![0u8; 4096];
574
575        let reader = make_fops_reader(&fops_data, fops_vaddr, fops_paddr, kernel_start, kernel_end);
576        let results = check_fops_entry(&reader, fops_vaddr, kernel_start, kernel_end);
577
578        assert!(
579            results.is_empty(),
580            "all-null fops struct should produce no HookedFop entries"
581        );
582    }
583}