Skip to main content

memf_linux/
modxview.rs

1//! Cross-view kernel module detection for Linux.
2//!
3//! Detects hidden kernel modules by cross-referencing multiple views of
4//! loaded modules: the kernel module list (`modules` symbol), kobj/sysfs
5//! entries, and memory-mapped regions. Rootkits that unlink from one list
6//! but not others can be detected by discrepancies between views.
7//! Equivalent to Volatility's `linux.check_modules` cross-view approach.
8
9use std::collections::HashSet;
10
11use memf_core::object_reader::ObjectReader;
12use memf_format::PhysicalMemoryProvider;
13
14use crate::Result;
15
16/// Maximum number of modules to enumerate before stopping (cycle guard).
17const MAX_MODULES: usize = 4096;
18
19/// Cross-view module visibility entry.
20///
21/// Each entry represents a kernel module found in at least one view,
22/// with flags indicating which views contain it. A module missing from
23/// any view but present in others is classified as hidden/suspicious.
24#[allow(clippy::struct_excessive_bools)]
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct ModXviewEntry {
27    /// Module name from the kernel `module.name` field.
28    pub name: String,
29    /// Base virtual address of the module's core section.
30    pub base_addr: u64,
31    /// Size of the module's core section in bytes.
32    pub size: u32,
33    /// Whether the module was found in the `modules` linked list.
34    pub in_module_list: bool,
35    /// Whether the module was found in the kobj/sysfs entries.
36    pub in_kobj_list: bool,
37    /// Whether the module's memory range is mapped and valid.
38    pub in_memory_map: bool,
39    /// Whether this module is hidden/suspicious (missing from at least
40    /// one view while present in another).
41    pub is_hidden: bool,
42}
43
44/// Classify module visibility across three kernel views.
45///
46/// Returns `true` (hidden/suspicious) if the module is missing from any
47/// view but present in at least one. All-false means the module was not
48/// found at all (not suspicious — just absent). All-true means benign.
49pub use crate::heuristics::classify_module_visibility;
50
51/// Walk and cross-reference kernel module views for hidden module detection.
52///
53/// Collects modules from three views:
54/// 1. **Module list** — the `modules` linked list (`LIST_HEAD`)
55/// 2. **Kobj list** — `mkobj.kobj.entry` linkage in sysfs
56/// 3. **Memory map** — `module_core`/`module_init` address range validity
57///
58/// Each unique module is checked against all views and classified.
59/// Returns `Ok(Vec::new())` if the `modules` symbol is not found
60/// (graceful degradation).
61pub fn walk_modxview<P: PhysicalMemoryProvider>(
62    reader: &ObjectReader<P>,
63) -> Result<Vec<ModXviewEntry>> {
64    // Graceful degradation: if `modules` symbol is missing, return empty.
65    let modules_addr = match reader.symbols().symbol_address("modules") {
66        Some(addr) => addr,
67        None => return Ok(Vec::new()),
68    };
69
70    // View 1: Walk the modules linked list.
71    let module_addrs = reader.walk_list(modules_addr, "module", "list")?;
72
73    let mut seen = HashSet::new();
74    let mut entries = Vec::new();
75
76    for &mod_addr in module_addrs.iter().take(MAX_MODULES) {
77        if !seen.insert(mod_addr) {
78            break; // Cycle detected
79        }
80
81        let name = reader
82            .read_field_string(mod_addr, "module", "name", 56)
83            .unwrap_or_else(|_| "<unknown>".to_string());
84
85        let base_addr: u64 = reader
86            .read_field(mod_addr, "module", "module_core")
87            .unwrap_or(0);
88
89        let size: u32 = reader
90            .read_field(mod_addr, "module", "core_size")
91            .unwrap_or(0);
92
93        // View 1: Present in module list by definition (found it there).
94        let in_module_list = true;
95
96        // View 2: Check kobj linkage.
97        // If mkobj/kobj fields are not resolvable, assume present (can't verify).
98        let in_kobj_list = check_kobj_linkage(reader, mod_addr);
99
100        // View 3: Check memory mapping validity.
101        // If module_core is non-zero and we can read from it, it's mapped.
102        let in_memory_map = check_memory_mapped(reader, base_addr, size);
103
104        let is_hidden = classify_module_visibility(in_module_list, in_kobj_list, in_memory_map);
105
106        entries.push(ModXviewEntry {
107            name,
108            base_addr,
109            size,
110            in_module_list,
111            in_kobj_list,
112            in_memory_map,
113            is_hidden,
114        });
115    }
116
117    Ok(entries)
118}
119
120/// Check whether a module's kobj entry is properly linked.
121///
122/// Verifies that `module.mkobj.kobj.entry.next` is a valid (non-null)
123/// pointer, indicating the module is linked into the sysfs kobj tree.
124/// Returns `true` (assume present) if the required field offsets are
125/// unavailable.
126fn check_kobj_linkage<P: PhysicalMemoryProvider>(reader: &ObjectReader<P>, mod_addr: u64) -> bool {
127    // Resolve mkobj offset within module struct
128    let mkobj_offset = match reader.symbols().field_offset("module", "mkobj") {
129        Some(off) => off,
130        None => return true, // Can't verify — assume present
131    };
132
133    // Resolve kobj offset within module_kobject
134    let kobj_offset = match reader.symbols().field_offset("module_kobject", "kobj") {
135        Some(off) => off,
136        None => return true,
137    };
138
139    // Resolve entry offset within kobject (list_head)
140    let entry_offset = match reader.symbols().field_offset("kobject", "entry") {
141        Some(off) => off,
142        None => return true,
143    };
144
145    // Read the entry.next pointer
146    let entry_addr = mod_addr + mkobj_offset + kobj_offset + entry_offset;
147    let next_ptr: u64 = match reader.read_field(entry_addr, "list_head", "next") {
148        Ok(v) => v,
149        Err(_) => return true, // Can't read — assume present
150    };
151
152    // A null or zero next pointer means unlinked from kobj tree
153    next_ptr != 0
154}
155
156/// Check whether a module's core memory range is mapped and readable.
157///
158/// Attempts to read a small probe from the module's base address.
159/// Returns `true` if the base is zero (can't verify) or the memory is
160/// readable. Returns `false` only when the address is non-zero but
161/// unreadable.
162fn check_memory_mapped<P: PhysicalMemoryProvider>(
163    reader: &ObjectReader<P>,
164    base_addr: u64,
165    size: u32,
166) -> bool {
167    if base_addr == 0 || size == 0 {
168        return true; // Can't verify — assume present
169    }
170
171    // Probe: try to read 1 byte from the module base address
172    reader.read_bytes(base_addr, 1).is_ok()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn classify_all_visible_benign() {
181        assert!(!classify_module_visibility(true, true, true));
182    }
183
184    #[test]
185    fn classify_missing_from_list_suspicious() {
186        // Present in kobj and memory, but missing from module list
187        assert!(classify_module_visibility(false, true, true));
188    }
189
190    #[test]
191    fn classify_missing_from_kobj_suspicious() {
192        // Present in module list and memory, but missing from kobj
193        assert!(classify_module_visibility(true, false, true));
194    }
195
196    #[test]
197    fn classify_all_missing_not_suspicious() {
198        // Not found anywhere — not suspicious, just absent
199        assert!(!classify_module_visibility(false, false, false));
200    }
201
202    #[test]
203    fn walk_no_symbol_returns_empty() {
204        use memf_core::test_builders::PageTableBuilder;
205        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
206        use memf_symbols::isf::IsfResolver;
207        use memf_symbols::test_builders::IsfBuilder;
208
209        // Build a reader with no `modules` symbol
210        let isf = IsfBuilder::new()
211            .add_struct("module", 64)
212            .add_field("module", "name", 0, "char")
213            .add_field("module", "list", 8, "list_head")
214            .add_struct("list_head", 16)
215            .add_field("list_head", "next", 0, "pointer")
216            .add_field("list_head", "prev", 8, "pointer")
217            .build_json();
218
219        let resolver = IsfResolver::from_value(&isf).unwrap();
220        let (cr3, mem) = PageTableBuilder::new().build();
221        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
222        let reader = ObjectReader::new(vas, Box::new(resolver));
223
224        let result = walk_modxview(&reader);
225        assert!(result.is_ok());
226        assert!(result.unwrap().is_empty());
227    }
228
229    #[test]
230    fn modxview_entry_serializes() {
231        let entry = ModXviewEntry {
232            name: "test_module".to_string(),
233            base_addr: 0xFFFF_8000_0000_1000,
234            size: 4096,
235            in_module_list: true,
236            in_kobj_list: true,
237            in_memory_map: false,
238            is_hidden: true,
239        };
240        let json = serde_json::to_string(&entry).unwrap();
241        assert!(json.contains("test_module"));
242        assert!(json.contains("\"is_hidden\":true"));
243    }
244
245    #[test]
246    fn classify_missing_from_memory_suspicious() {
247        // Present in module list and kobj, but memory is not mapped
248        assert!(classify_module_visibility(true, true, false));
249    }
250
251    #[test]
252    fn classify_only_in_memory_suspicious() {
253        // Only found in memory map, missing from both lists
254        assert!(classify_module_visibility(false, false, true));
255    }
256
257    #[test]
258    fn classify_only_in_module_list_suspicious() {
259        // Only found in module list, missing from kobj and memory
260        assert!(classify_module_visibility(true, false, false));
261    }
262
263    #[test]
264    fn classify_only_in_kobj_suspicious() {
265        // Only found in kobj, missing from module list and memory
266        assert!(classify_module_visibility(false, true, false));
267    }
268
269    #[test]
270    fn check_memory_mapped_zero_base_returns_true() {
271        // base_addr == 0 → can't verify, assume present
272        use memf_core::test_builders::PageTableBuilder;
273        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
274        use memf_symbols::isf::IsfResolver;
275        use memf_symbols::test_builders::IsfBuilder;
276
277        let isf = IsfBuilder::new().build_json();
278        let resolver = IsfResolver::from_value(&isf).unwrap();
279        let (cr3, mem) = PageTableBuilder::new().build();
280        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
281        let reader = ObjectReader::new(vas, Box::new(resolver));
282
283        assert!(check_memory_mapped(&reader, 0, 4096));
284    }
285
286    #[test]
287    fn check_memory_mapped_zero_size_returns_true() {
288        // size == 0 → can't verify, assume present
289        use memf_core::test_builders::PageTableBuilder;
290        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
291        use memf_symbols::isf::IsfResolver;
292        use memf_symbols::test_builders::IsfBuilder;
293
294        let isf = IsfBuilder::new().build_json();
295        let resolver = IsfResolver::from_value(&isf).unwrap();
296        let (cr3, mem) = PageTableBuilder::new().build();
297        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
298        let reader = ObjectReader::new(vas, Box::new(resolver));
299
300        assert!(check_memory_mapped(&reader, 0xFFFF_8000_0000_1000, 0));
301    }
302
303    #[test]
304    fn check_memory_mapped_unreadable_returns_false() {
305        // base_addr non-zero, size non-zero, but memory not mapped → unreadable → false
306        use memf_core::test_builders::PageTableBuilder;
307        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
308        use memf_symbols::isf::IsfResolver;
309        use memf_symbols::test_builders::IsfBuilder;
310
311        let isf = IsfBuilder::new().build_json();
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        let reader = ObjectReader::new(vas, Box::new(resolver));
316
317        // Address not mapped → read_bytes returns Err → false
318        assert!(!check_memory_mapped(&reader, 0xDEAD_BEEF_0000_1000, 4096));
319    }
320
321    #[test]
322    fn check_kobj_linkage_missing_mkobj_offset_returns_true() {
323        // If mkobj field is not in the ISF, assume linked (return true)
324        use memf_core::test_builders::PageTableBuilder;
325        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
326        use memf_symbols::isf::IsfResolver;
327        use memf_symbols::test_builders::IsfBuilder;
328
329        // No "module" struct defined → field_offset("module", "mkobj") returns None
330        let isf = IsfBuilder::new().build_json();
331        let resolver = IsfResolver::from_value(&isf).unwrap();
332        let (cr3, mem) = PageTableBuilder::new().build();
333        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
334        let reader = ObjectReader::new(vas, Box::new(resolver));
335
336        assert!(check_kobj_linkage(&reader, 0xFFFF_8000_0000_0000));
337    }
338
339    #[test]
340    fn check_kobj_linkage_missing_kobj_offset_returns_true() {
341        // module struct present but module_kobject not defined
342        use memf_core::test_builders::PageTableBuilder;
343        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
344        use memf_symbols::isf::IsfResolver;
345        use memf_symbols::test_builders::IsfBuilder;
346
347        let isf = IsfBuilder::new()
348            .add_struct("module", 128)
349            .add_field("module", "mkobj", 0, "pointer")
350            // module_kobject not defined → field_offset("module_kobject", "kobj") returns None
351            .build_json();
352
353        let resolver = IsfResolver::from_value(&isf).unwrap();
354        let (cr3, mem) = PageTableBuilder::new().build();
355        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
356        let reader = ObjectReader::new(vas, Box::new(resolver));
357
358        assert!(check_kobj_linkage(&reader, 0xFFFF_8000_0000_0000));
359    }
360
361    #[test]
362    fn check_kobj_linkage_missing_entry_offset_returns_true() {
363        // module and module_kobject defined but kobject.entry missing
364        use memf_core::test_builders::PageTableBuilder;
365        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
366        use memf_symbols::isf::IsfResolver;
367        use memf_symbols::test_builders::IsfBuilder;
368
369        let isf = IsfBuilder::new()
370            .add_struct("module", 128)
371            .add_field("module", "mkobj", 0, "pointer")
372            .add_struct("module_kobject", 64)
373            .add_field("module_kobject", "kobj", 0, "pointer")
374            // kobject not defined → field_offset("kobject", "entry") returns None
375            .build_json();
376
377        let resolver = IsfResolver::from_value(&isf).unwrap();
378        let (cr3, mem) = PageTableBuilder::new().build();
379        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
380        let reader = ObjectReader::new(vas, Box::new(resolver));
381
382        assert!(check_kobj_linkage(&reader, 0xFFFF_8000_0000_0000));
383    }
384
385    #[test]
386    fn walk_modxview_with_one_module_entry() {
387        // symbol present + one module in the list → exercises walk body
388        use memf_core::test_builders::{flags as ptf, PageTableBuilder};
389        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
390        use memf_symbols::isf::IsfResolver;
391        use memf_symbols::test_builders::IsfBuilder;
392
393        // Layout:
394        //   modules_vaddr  = LIST_HEAD (head of modules list)
395        //   mod_a_vaddr    = module A, list.next points back to head (circular)
396        //
397        // module struct layout used:
398        //   +0x00: list.next (pointer)
399        //   +0x08: list.prev (pointer)
400        //   +0x10: name (char[56])
401        //   +0x48: module_core (pointer) — base addr
402        //   +0x50: core_size (u32)
403        let head_vaddr: u64 = 0xFFFF_8800_00E0_0000;
404        let head_paddr: u64 = 0x00E0_0000;
405        let mod_a_vaddr: u64 = 0xFFFF_8800_00E1_0000;
406        let mod_a_paddr: u64 = 0x00E1_0000;
407
408        let mut head_page = [0u8; 4096];
409        // head.next → mod_a list node (start of module A)
410        head_page[0..8].copy_from_slice(&mod_a_vaddr.to_le_bytes());
411        head_page[8..16].copy_from_slice(&mod_a_vaddr.to_le_bytes());
412
413        let mut mod_a_page = [0u8; 4096];
414        // list.next → head (so walk terminates after mod_a)
415        mod_a_page[0..8].copy_from_slice(&head_vaddr.to_le_bytes());
416        mod_a_page[8..16].copy_from_slice(&head_vaddr.to_le_bytes());
417        // name at +0x10
418        mod_a_page[0x10..0x15].copy_from_slice(b"dummy");
419        // module_core at +0x48
420        mod_a_page[0x48..0x50].copy_from_slice(&0xFFFF_A000_0000u64.to_le_bytes());
421        // core_size at +0x50
422        mod_a_page[0x50..0x54].copy_from_slice(&0x4000u32.to_le_bytes());
423
424        let isf = IsfBuilder::new()
425            .add_struct("module", 256)
426            .add_field("module", "list", 0x00u64, "list_head")
427            .add_field("module", "name", 0x10u64, "char")
428            .add_field("module", "module_core", 0x48u64, "pointer")
429            .add_field("module", "core_size", 0x50u64, "unsigned int")
430            .add_struct("list_head", 16)
431            .add_field("list_head", "next", 0x00u64, "pointer")
432            .add_field("list_head", "prev", 0x08u64, "pointer")
433            .add_symbol("modules", head_vaddr)
434            .build_json();
435        let resolver = IsfResolver::from_value(&isf).unwrap();
436
437        let (cr3, mem) = PageTableBuilder::new()
438            .map_4k(head_vaddr, head_paddr, ptf::WRITABLE)
439            .write_phys(head_paddr, &head_page)
440            .map_4k(mod_a_vaddr, mod_a_paddr, ptf::WRITABLE)
441            .write_phys(mod_a_paddr, &mod_a_page)
442            .build();
443        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
444        let reader = ObjectReader::new(vas, Box::new(resolver));
445
446        let result = walk_modxview(&reader).unwrap_or_default();
447        assert_eq!(result.len(), 1, "should find exactly one module entry");
448        assert_eq!(result[0].name, "dummy");
449        assert_eq!(result[0].base_addr, 0xFFFF_A000_0000);
450        assert_eq!(result[0].size, 0x4000);
451        assert!(result[0].in_module_list, "module found in list");
452    }
453
454    #[test]
455    fn check_kobj_linkage_unreadable_memory_returns_true() {
456        // All offsets available but memory not mapped → read fails → assume present
457        use memf_core::test_builders::PageTableBuilder;
458        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
459        use memf_symbols::isf::IsfResolver;
460        use memf_symbols::test_builders::IsfBuilder;
461
462        let isf = IsfBuilder::new()
463            .add_struct("module", 128)
464            .add_field("module", "mkobj", 0, "pointer")
465            .add_struct("module_kobject", 64)
466            .add_field("module_kobject", "kobj", 0, "pointer")
467            .add_struct("kobject", 64)
468            .add_field("kobject", "entry", 0, "pointer")
469            .add_struct("list_head", 16)
470            .add_field("list_head", "next", 0, "pointer")
471            .add_field("list_head", "prev", 8, "pointer")
472            .build_json();
473
474        let resolver = IsfResolver::from_value(&isf).unwrap();
475        let (cr3, mem) = PageTableBuilder::new().build();
476        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
477        let reader = ObjectReader::new(vas, Box::new(resolver));
478
479        // Address not mapped → read_field returns Err → assume present (true)
480        assert!(check_kobj_linkage(&reader, 0xDEAD_BEEF_0000_0000));
481    }
482}