Skip to main content

memf_linux/
zombie_orphan.rs

1//! Zombie and orphan process detection for Linux memory forensics.
2//!
3//! Detects zombie processes (exited but not reaped by their parent) and
4//! orphan processes (parent died, reparented to init/pid 1). These are
5//! forensically significant: malware that crashes leaves zombies; processes
6//! that survive their parent may indicate persistence or injection.
7//!
8//! MITRE ATT&CK T1036 (masquerading via orphan reparenting).
9
10use memf_core::object_reader::ObjectReader;
11use memf_format::PhysicalMemoryProvider;
12
13use crate::{ProcessState, Result};
14
15/// Information about a zombie or orphan process found in memory.
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct ZombieOrphanInfo {
18    /// Process ID.
19    pub pid: u32,
20    /// PID of the current parent (may be 1/init after reparenting).
21    pub ppid: u32,
22    /// Process name from `task_struct.comm`.
23    pub comm: String,
24    /// Human-readable process state string.
25    pub state: String,
26    /// Exit code stored in `task_struct.exit_code`.
27    pub exit_code: i32,
28    /// PID of the original parent before any reparenting.
29    pub original_ppid: u32,
30    /// True if the process is in zombie state (exited, not yet reaped).
31    pub is_zombie: bool,
32    /// True if the process was reparented to init (PID 1).
33    pub is_orphan: bool,
34    /// True if heuristics flag this entry as anomalous.
35    pub is_suspicious: bool,
36}
37
38/// Classify whether a zombie/orphan process is suspicious.
39pub use crate::heuristics::classify_zombie_orphan;
40
41/// Walk the Linux process list and detect zombie and orphan processes.
42pub fn walk_zombie_orphan<P: PhysicalMemoryProvider>(
43    reader: &ObjectReader<P>,
44) -> Result<Vec<ZombieOrphanInfo>> {
45    let init_task_addr = match reader.symbols().symbol_address("init_task") {
46        Some(addr) => addr,
47        None => return Ok(Vec::new()),
48    };
49
50    let tasks_offset = match reader.symbols().field_offset("task_struct", "tasks") {
51        Some(off) => off,
52        None => return Ok(Vec::new()),
53    };
54
55    let head_vaddr = init_task_addr + tasks_offset;
56    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
57
58    let mut results = Vec::new();
59
60    let read_task = |addr: u64| -> Option<ZombieOrphanInfo> {
61        let pid: u32 = reader.read_field(addr, "task_struct", "pid").ok()?;
62        let state_raw: i64 = reader.read_field(addr, "task_struct", "state").ok()?;
63        let exit_code: i32 = reader
64            .read_field(addr, "task_struct", "exit_code")
65            .unwrap_or(0);
66        let comm = reader
67            .read_field_string(addr, "task_struct", "comm", 16)
68            .unwrap_or_else(|_| "<unknown>".to_string());
69
70        let real_parent_ptr: u64 = reader.read_field(addr, "task_struct", "real_parent").ok()?;
71        let ppid: u32 = if real_parent_ptr != 0 {
72            reader
73                .read_field(real_parent_ptr, "task_struct", "pid")
74                .unwrap_or(0)
75        } else {
76            0
77        };
78
79        let parent_ptr: u64 = reader
80            .read_field(addr, "task_struct", "parent")
81            .unwrap_or(0);
82        let original_ppid: u32 = if parent_ptr != 0 {
83            reader
84                .read_field(parent_ptr, "task_struct", "pid")
85                .unwrap_or(0)
86        } else {
87            0
88        };
89
90        let state = ProcessState::from_raw(state_raw);
91        let is_zombie = matches!(state, ProcessState::Zombie);
92        let is_orphan = ppid == 1 && original_ppid != ppid && pid != 1;
93
94        if !is_zombie && !is_orphan {
95            return None;
96        }
97
98        let mut is_suspicious = classify_zombie_orphan(is_zombie, is_orphan, ppid, &comm);
99        if is_zombie && exit_code != 0 {
100            is_suspicious = true;
101        }
102
103        Some(ZombieOrphanInfo {
104            pid,
105            ppid,
106            comm,
107            state: state.to_string(),
108            exit_code,
109            original_ppid,
110            is_zombie,
111            is_orphan,
112            is_suspicious,
113        })
114    };
115
116    if let Some(info) = read_task(init_task_addr) {
117        results.push(info);
118    }
119
120    for &task_addr in &task_addrs {
121        if let Some(info) = read_task(task_addr) {
122            results.push(info);
123        }
124    }
125
126    results.sort_by_key(|r| r.pid);
127    Ok(results)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use memf_core::object_reader::ObjectReader;
134    use memf_core::test_builders::{flags, PageTableBuilder};
135    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
136    use memf_symbols::isf::IsfResolver;
137    use memf_symbols::test_builders::IsfBuilder;
138
139    #[test]
140    fn classify_reparented_zombie_suspicious() {
141        assert!(classify_zombie_orphan(true, false, 1, "evil_proc"));
142    }
143
144    #[test]
145    fn classify_orphan_daemon_suspicious() {
146        assert!(classify_zombie_orphan(false, true, 1, "sshd"));
147    }
148
149    #[test]
150    fn classify_normal_zombie_benign() {
151        assert!(!classify_zombie_orphan(true, false, 500, "worker"));
152    }
153
154    #[test]
155    fn classify_normal_process_benign() {
156        assert!(!classify_zombie_orphan(false, false, 500, "bash"));
157    }
158
159    #[test]
160    fn classify_crashed_zombie_suspicious() {
161        assert!(classify_zombie_orphan(true, false, 1, "payload"));
162    }
163
164    #[test]
165    fn classify_orphan_non_daemon_benign() {
166        assert!(!classify_zombie_orphan(false, true, 1, "my_script"));
167    }
168
169    #[test]
170    fn classify_orphan_daemon_case_insensitive() {
171        assert!(classify_zombie_orphan(false, true, 1, "NGINX"));
172    }
173
174    #[test]
175    fn walk_no_symbol_returns_empty() {
176        let isf = IsfBuilder::new()
177            .add_struct("task_struct", 256)
178            .add_field("task_struct", "pid", 0, "int")
179            .add_field("task_struct", "state", 8, "long")
180            .add_field("task_struct", "exit_code", 16, "int")
181            .add_field("task_struct", "tasks", 24, "list_head")
182            .add_field("task_struct", "comm", 40, "char")
183            .add_field("task_struct", "real_parent", 56, "pointer")
184            .add_field("task_struct", "parent", 64, "pointer")
185            .add_struct("list_head", 16)
186            .add_field("list_head", "next", 0, "pointer")
187            .add_field("list_head", "prev", 8, "pointer")
188            .build_json();
189
190        let resolver = IsfResolver::from_value(&isf).unwrap();
191        let (cr3, mem) = PageTableBuilder::new().build();
192        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
193        let reader = ObjectReader::new(vas, Box::new(resolver));
194
195        let result = walk_zombie_orphan(&reader);
196        assert!(result.is_ok());
197        assert!(result.unwrap().is_empty());
198    }
199
200    #[test]
201    fn walk_no_tasks_offset_returns_empty() {
202        let isf = IsfBuilder::new()
203            .add_struct("task_struct", 256)
204            .add_field("task_struct", "pid", 0, "int")
205            .add_symbol("init_task", 0xFFFF_8000_0000_0000)
206            .build_json();
207
208        let resolver = IsfResolver::from_value(&isf).unwrap();
209        let (cr3, mem) = PageTableBuilder::new().build();
210        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
211        let reader = ObjectReader::new(vas, Box::new(resolver));
212
213        let result = walk_zombie_orphan(&reader);
214        assert!(result.is_ok());
215        assert!(result.unwrap().is_empty());
216    }
217
218    #[test]
219    fn walk_zombie_orphan_symbol_present_empty_list() {
220        let sym_vaddr: u64 = 0xFFFF_8800_0030_0000;
221        let sym_paddr: u64 = 0x0040_0000;
222        let tasks_offset = 24u64;
223
224        let mut page = [0u8; 4096];
225        page[0..4].copy_from_slice(&1u32.to_le_bytes());
226        page[8..16].copy_from_slice(&0i64.to_le_bytes());
227        page[16..20].copy_from_slice(&0i32.to_le_bytes());
228        let list_self = sym_vaddr + tasks_offset;
229        page[tasks_offset as usize..tasks_offset as usize + 8]
230            .copy_from_slice(&list_self.to_le_bytes());
231        page[tasks_offset as usize + 8..tasks_offset as usize + 16]
232            .copy_from_slice(&list_self.to_le_bytes());
233        page[40..47].copy_from_slice(b"systemd");
234        page[56..64].copy_from_slice(&sym_vaddr.to_le_bytes());
235        page[64..72].copy_from_slice(&sym_vaddr.to_le_bytes());
236
237        let isf = IsfBuilder::new()
238            .add_struct("task_struct", 256)
239            .add_field("task_struct", "pid", 0, "unsigned int")
240            .add_field("task_struct", "state", 8, "unsigned long")
241            .add_field("task_struct", "exit_code", 16, "int")
242            .add_field("task_struct", "tasks", 24, "pointer")
243            .add_field("task_struct", "comm", 40, "char")
244            .add_field("task_struct", "real_parent", 56, "pointer")
245            .add_field("task_struct", "parent", 64, "pointer")
246            .add_symbol("init_task", sym_vaddr)
247            .build_json();
248
249        let resolver = IsfResolver::from_value(&isf).unwrap();
250        let (cr3, mem) = PageTableBuilder::new()
251            .map_4k(sym_vaddr, sym_paddr, flags::WRITABLE)
252            .write_phys(sym_paddr, &page)
253            .build();
254        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
255        let reader = ObjectReader::new(vas, Box::new(resolver));
256
257        let result = walk_zombie_orphan(&reader).unwrap_or_default();
258        assert!(
259            result.is_empty(),
260            "running init task is neither zombie nor orphan"
261        );
262    }
263
264    #[test]
265    fn walk_zombie_orphan_zombie_task_detected() {
266        let sym_vaddr: u64 = 0xFFFF_8800_0050_0000;
267        let sym_paddr: u64 = 0x0050_0000;
268        let tasks_offset: u64 = 24;
269
270        let mut page = [0u8; 4096];
271        page[0..4].copy_from_slice(&1u32.to_le_bytes());
272        let zombie_state: i64 = 0x20;
273        page[8..16].copy_from_slice(&zombie_state.to_le_bytes());
274        page[16..20].copy_from_slice(&139i32.to_le_bytes());
275        let self_ptr = sym_vaddr + tasks_offset;
276        page[tasks_offset as usize..tasks_offset as usize + 8]
277            .copy_from_slice(&self_ptr.to_le_bytes());
278        page[tasks_offset as usize + 8..tasks_offset as usize + 16]
279            .copy_from_slice(&self_ptr.to_le_bytes());
280        page[40..47].copy_from_slice(b"crashed");
281        page[56..64].copy_from_slice(&sym_vaddr.to_le_bytes());
282        page[64..72].copy_from_slice(&sym_vaddr.to_le_bytes());
283
284        let isf = IsfBuilder::new()
285            .add_struct("list_head", 0x10)
286            .add_field("list_head", "next", 0x00, "pointer")
287            .add_field("list_head", "prev", 0x08, "pointer")
288            .add_struct("task_struct", 256)
289            .add_field("task_struct", "pid", 0, "unsigned int")
290            .add_field("task_struct", "state", 8, "unsigned long")
291            .add_field("task_struct", "exit_code", 16, "int")
292            .add_field("task_struct", "tasks", 24, "pointer")
293            .add_field("task_struct", "comm", 40, "char")
294            .add_field("task_struct", "real_parent", 56, "pointer")
295            .add_field("task_struct", "parent", 64, "pointer")
296            .add_symbol("init_task", sym_vaddr)
297            .build_json();
298
299        let resolver = IsfResolver::from_value(&isf).unwrap();
300        let (cr3, mem) = PageTableBuilder::new()
301            .map_4k(sym_vaddr, sym_paddr, flags::WRITABLE)
302            .write_phys(sym_paddr, &page)
303            .build();
304        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
305        let reader = ObjectReader::new(vas, Box::new(resolver));
306
307        let result = walk_zombie_orphan(&reader).unwrap();
308        assert_eq!(result.len(), 1, "zombie task should appear in results");
309        assert!(result[0].is_zombie, "state=0x20 → zombie");
310        assert!(
311            result[0].is_suspicious,
312            "zombie with non-zero exit_code → suspicious"
313        );
314        assert_eq!(result[0].exit_code, 139);
315        assert_eq!(result[0].comm, "crashed");
316    }
317
318    #[test]
319    fn zombie_orphan_info_clone_and_debug() {
320        let info = ZombieOrphanInfo {
321            pid: 999,
322            ppid: 1,
323            comm: "ghost".to_string(),
324            state: "Z (zombie)".to_string(),
325            exit_code: 0,
326            original_ppid: 500,
327            is_zombie: true,
328            is_orphan: false,
329            is_suspicious: true,
330        };
331        let cloned = info.clone();
332        assert_eq!(cloned.pid, 999);
333        let dbg = format!("{cloned:?}");
334        assert!(dbg.contains("ghost"));
335    }
336
337    #[test]
338    fn classify_orphan_httpd_suspicious() {
339        assert!(classify_zombie_orphan(false, true, 1, "httpd"));
340    }
341
342    #[test]
343    fn classify_orphan_nginx_suspicious() {
344        assert!(classify_zombie_orphan(false, true, 1, "nginx"));
345    }
346
347    #[test]
348    fn classify_orphan_apache_suspicious() {
349        assert!(classify_zombie_orphan(false, true, 1, "apache2"));
350    }
351
352    #[test]
353    fn classify_orphan_mysqld_suspicious() {
354        assert!(classify_zombie_orphan(false, true, 1, "mysqld"));
355    }
356
357    #[test]
358    fn classify_orphan_postgres_suspicious() {
359        assert!(classify_zombie_orphan(false, true, 1, "postgres"));
360    }
361
362    #[test]
363    fn classify_orphan_redis_suspicious() {
364        assert!(classify_zombie_orphan(false, true, 1, "redis-server"));
365    }
366
367    #[test]
368    fn classify_orphan_memcached_suspicious() {
369        assert!(classify_zombie_orphan(false, true, 1, "memcached"));
370    }
371
372    #[test]
373    fn classify_orphan_mongod_suspicious() {
374        assert!(classify_zombie_orphan(false, true, 1, "mongod"));
375    }
376
377    #[test]
378    fn classify_orphan_named_suspicious() {
379        assert!(classify_zombie_orphan(false, true, 1, "named"));
380    }
381
382    #[test]
383    fn classify_orphan_bind_suspicious() {
384        assert!(classify_zombie_orphan(false, true, 1, "bind"));
385    }
386
387    #[test]
388    fn classify_orphan_cupsd_suspicious() {
389        assert!(classify_zombie_orphan(false, true, 1, "cupsd"));
390    }
391
392    #[test]
393    fn classify_orphan_cron_suspicious() {
394        assert!(classify_zombie_orphan(false, true, 1, "cron"));
395    }
396
397    #[test]
398    fn classify_orphan_atd_suspicious() {
399        assert!(classify_zombie_orphan(false, true, 1, "atd"));
400    }
401
402    #[test]
403    fn classify_zombie_non_init_parent_benign() {
404        assert!(!classify_zombie_orphan(true, false, 999, "worker"));
405    }
406
407    #[test]
408    fn zombie_orphan_serializes() {
409        let info = ZombieOrphanInfo {
410            pid: 1234,
411            ppid: 1,
412            comm: "evil_proc".to_string(),
413            state: "Z (zombie)".to_string(),
414            exit_code: 139,
415            original_ppid: 500,
416            is_zombie: true,
417            is_orphan: false,
418            is_suspicious: true,
419        };
420
421        let json = serde_json::to_value(&info).unwrap();
422        assert_eq!(json["pid"], 1234);
423        assert_eq!(json["ppid"], 1);
424        assert_eq!(json["comm"], "evil_proc");
425        assert_eq!(json["state"], "Z (zombie)");
426        assert_eq!(json["exit_code"], 139);
427        assert_eq!(json["original_ppid"], 500);
428        assert_eq!(json["is_zombie"], true);
429        assert_eq!(json["is_orphan"], false);
430        assert_eq!(json["is_suspicious"], true);
431    }
432}