Skip to main content

memf_linux/
proc_hidden.rs

1//! Hidden process detection via PID namespace vs task list discrepancy.
2
3use memf_core::object_reader::ObjectReader;
4use memf_format::PhysicalMemoryProvider;
5
6use crate::types::HiddenProcessInfo;
7use crate::Result;
8
9/// Classify whether a process is DKOM-hidden based on its visibility in the
10/// task list and PID hash table.
11///
12/// Returns `true` if the process is absent from either the task list or the
13/// PID hash table — both must be present for a process to be considered
14/// visible by the kernel.
15pub fn is_dkom_hidden(in_task_list: bool, in_pid_hash: bool) -> bool {
16    !in_task_list || !in_pid_hash
17}
18
19/// Scan for processes hidden by DKOM or PID namespace tricks.
20///
21/// Delegates to [`crate::psxview::walk_psxview`] for the cross-view enumeration,
22/// then returns only entries where [`is_dkom_hidden`] is `true`.
23pub fn find_hidden_processes<P: PhysicalMemoryProvider>(
24    reader: &ObjectReader<P>,
25) -> Result<Vec<HiddenProcessInfo>> {
26    let entries = match crate::psxview::walk_psxview(reader) {
27        Ok(v) => v,
28        Err(crate::Error::MissingKernelSymbol { .. } | crate::Error::MissingField { .. }) => {
29            return Ok(vec![]);
30        }
31        Err(e) => return Err(e),
32    };
33    Ok(entries
34        .into_iter()
35        .filter(|e| is_dkom_hidden(e.in_task_list, e.in_pid_hash))
36        .map(|e| HiddenProcessInfo {
37            pid: e.pid,
38            comm: e.comm,
39            present_in_pid_ns: false,
40            present_in_task_list: e.in_task_list,
41            present_in_pid_hash: e.in_pid_hash,
42        })
43        .collect())
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use memf_core::test_builders::PageTableBuilder;
50    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
51    use memf_symbols::isf::IsfResolver;
52    use memf_symbols::test_builders::IsfBuilder;
53
54    fn make_minimal_reader() -> ObjectReader<memf_core::test_builders::SyntheticPhysMem> {
55        let isf = IsfBuilder::new().build_json();
56        let resolver = IsfResolver::from_value(&isf).unwrap();
57        let (cr3, mem) = PageTableBuilder::new().build();
58        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
59        ObjectReader::new(vas, Box::new(resolver))
60    }
61
62    #[test]
63    fn empty_memory_returns_ok_empty() {
64        let reader = make_minimal_reader();
65        let result = find_hidden_processes(&reader);
66        assert!(result.is_ok(), "should succeed with minimal reader");
67        assert!(
68            result.unwrap().is_empty(),
69            "empty memory → no hidden processes"
70        );
71    }
72
73    #[test]
74    fn result_is_vec_of_hidden_process_info() {
75        let reader = make_minimal_reader();
76        let result: Result<Vec<HiddenProcessInfo>> = find_hidden_processes(&reader);
77        assert!(result.is_ok());
78    }
79
80    #[test]
81    fn hidden_process_info_fields_constructible() {
82        let info = HiddenProcessInfo {
83            pid: 1234,
84            comm: "evil".to_string(),
85            present_in_pid_ns: false,
86            present_in_task_list: true,
87            present_in_pid_hash: true,
88        };
89        assert_eq!(info.pid, 1234);
90        assert_eq!(info.comm, "evil");
91        assert!(!info.present_in_pid_ns);
92        assert!(info.present_in_task_list);
93        assert!(info.present_in_pid_hash);
94    }
95
96    #[test]
97    fn hidden_process_info_serializes() {
98        let info = HiddenProcessInfo {
99            pid: 42,
100            comm: "rootkit".to_string(),
101            present_in_pid_ns: false,
102            present_in_task_list: false,
103            present_in_pid_hash: true,
104        };
105        let json = serde_json::to_string(&info).unwrap();
106        assert!(json.contains("\"pid\":42"));
107        assert!(json.contains("rootkit"));
108        assert!(json.contains("\"present_in_pid_hash\":true"));
109    }
110
111    // --- classifier helper tests ---
112
113    #[test]
114    fn process_missing_from_task_list_is_hidden() {
115        assert!(is_dkom_hidden(false, true));
116    }
117
118    #[test]
119    fn process_missing_from_pid_hash_is_hidden() {
120        assert!(is_dkom_hidden(true, false));
121    }
122
123    #[test]
124    fn process_in_all_sources_is_not_hidden() {
125        assert!(!is_dkom_hidden(true, true));
126    }
127
128    // --- integration tests: find_hidden_processes must delegate to walk_psxview ---
129    //
130    // Layout used in the two tests below:
131    //   vaddr = 0xFFFF_8000_0010_0000  (init_task, one 4KB page)
132    //   paddr = 0x0080_0000
133    //   offset  0: pid         (u32) = 1
134    //   offset  4: state       (long) = 0
135    //   offset 16: tasks.next  (u64) = vaddr+16  ← self-loop (only process)
136    //   offset 24: tasks.prev  (u64) = vaddr+16
137    //   offset 32: comm        (15 bytes) = "init"
138    //   offset 48: mm          (u64) = 0
139    //   offset 56: pid_links.next  (u64)  ← set differently per test
140    //   offset 64: pid_links.pprev (u64)
141    //   offset 0x800: first pid_hash bucket ← set differently per test
142
143    use memf_core::test_builders::{flags as ptflags, SyntheticPhysMem};
144
145    fn make_proc_hidden_reader(
146        page_data: &[u8; 4096],
147        vaddr: u64,
148        paddr: u64,
149    ) -> ObjectReader<SyntheticPhysMem> {
150        let isf = IsfBuilder::new()
151            .add_struct("task_struct", 128)
152            .add_field("task_struct", "pid", 0, "int")
153            .add_field("task_struct", "state", 4, "long")
154            .add_field("task_struct", "tasks", 16, "list_head")
155            .add_field("task_struct", "comm", 32, "char")
156            .add_field("task_struct", "mm", 48, "pointer")
157            .add_field("task_struct", "pid_links", 56, "hlist_node")
158            .add_struct("list_head", 16)
159            .add_field("list_head", "next", 0, "pointer")
160            .add_field("list_head", "prev", 8, "pointer")
161            .add_struct("hlist_node", 16)
162            .add_field("hlist_node", "next", 0, "pointer")
163            .add_field("hlist_node", "pprev", 8, "pointer")
164            .add_symbol("init_task", vaddr)
165            .add_symbol("pid_hash", vaddr + 0x800)
166            .build_json();
167        let resolver = IsfResolver::from_value(&isf).unwrap();
168        let (cr3, mem) = PageTableBuilder::new()
169            .map_4k(vaddr, paddr, ptflags::WRITABLE)
170            .write_phys(paddr, page_data)
171            .build();
172        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
173        ObjectReader::new(vas, Box::new(resolver))
174    }
175
176    fn base_task_page(vaddr: u64) -> [u8; 4096] {
177        let mut page = [0u8; 4096];
178        page[0..4].copy_from_slice(&1u32.to_le_bytes()); // pid = 1
179        let tasks_va = vaddr + 16;
180        page[16..24].copy_from_slice(&tasks_va.to_le_bytes()); // tasks.next
181        page[24..32].copy_from_slice(&tasks_va.to_le_bytes()); // tasks.prev
182        page[32..36].copy_from_slice(b"init"); // comm
183        page
184    }
185
186    /// RED: process in task list but pid_hash has no entry for it → must be flagged.
187    #[test]
188    fn find_hidden_processes_detects_task_list_only_process() {
189        let vaddr: u64 = 0xFFFF_8000_0010_0000;
190        let paddr: u64 = 0x0080_0000;
191        // Seed bucket 0 with a *different* PID (99) so the hash set is non-empty.
192        // Without this, collect_pid_hash_pids returns an empty set which is
193        // filtered to None (fallback in_pid_hash=true), masking the detection.
194        //
195        // Layout on the page:
196        //   0x000: init_task (pid=1, tasks self-loop, NOT linked into hash)
197        //   0x200: fake_task (pid=99, NOT in task list, IS in hash)
198        //   0x238: fake_task.pid_links (hlist_node) — pid_links_offset = 56 = 0x38
199        //   0x800: pid_hash bucket 0 → points to fake_task.pid_links
200        let mut page = base_task_page(vaddr);
201        page[0x200..0x204].copy_from_slice(&99u32.to_le_bytes()); // fake pid
202        let fake_pid_links_va = vaddr + 0x238;
203        page[0x800..0x808].copy_from_slice(&fake_pid_links_va.to_le_bytes()); // bucket 0 → hlist_node
204                                                                              // fake_pid_links.next = NULL (end of chain)
205        page[0x238..0x240].copy_from_slice(&0u64.to_le_bytes());
206        // fake_pid_links.pprev → points back to bucket head (standard hlist bookkeeping)
207        page[0x240..0x248].copy_from_slice(&(vaddr + 0x800).to_le_bytes());
208        let reader = make_proc_hidden_reader(&page, vaddr, paddr);
209
210        let hidden = find_hidden_processes(&reader).unwrap();
211        assert_eq!(hidden.len(), 1, "init absent from pid_hash must be flagged");
212        assert_eq!(hidden[0].pid, 1);
213        assert!(hidden[0].present_in_task_list);
214        assert!(!hidden[0].present_in_pid_hash);
215    }
216
217    /// RED: same process appears in pid_hash → must NOT be flagged.
218    #[test]
219    fn find_hidden_processes_clean_image_all_visible_returns_empty() {
220        let vaddr: u64 = 0xFFFF_8000_0010_0000;
221        let paddr: u64 = 0x0080_0000;
222        let mut page = base_task_page(vaddr);
223        // pid_hash bucket at 0x800 → pid_links field at vaddr+56
224        let pid_links_va = vaddr + 56;
225        page[0x800..0x808].copy_from_slice(&pid_links_va.to_le_bytes());
226        // pid_links.next = 0 (end of chain); .pprev points back to bucket
227        page[0x808..0x810].copy_from_slice(&(vaddr + 0x800).to_le_bytes());
228        let reader = make_proc_hidden_reader(&page, vaddr, paddr);
229
230        let hidden = find_hidden_processes(&reader).unwrap();
231        assert!(
232            hidden.is_empty(),
233            "process visible in all sources must not be flagged"
234        );
235    }
236}