Skip to main content

memf_linux/
container_escape.rs

1//! Container escape artifact detection.
2//!
3//! Detects processes that may have escaped container namespace isolation by
4//! comparing mount namespace pointers against the init task's namespace
5//! (MITRE ATT&CK T1611 — Escape to Host).
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Information about a process exhibiting container escape indicators.
13#[derive(Debug, Clone)]
14pub struct ContainerEscapeInfo {
15    /// Process ID.
16    pub pid: u32,
17    /// Process command name.
18    pub comm: String,
19    /// Indicator type: "namespace_mismatch", "host_mount_access", "pivot_root_anomaly".
20    pub indicator: String,
21    /// PID in the host namespace if detectable.
22    pub host_pid: Option<u32>,
23    /// True if the process is considered suspicious.
24    pub is_suspicious: bool,
25}
26
27/// Classify whether a process's indicator is suspicious.
28///
29/// Returns `false` for kernel threads regardless of indicator.
30pub use crate::heuristics::classify_container_escape;
31
32/// Walk all tasks and report container escape indicators.
33///
34/// On missing `init_task` symbol, returns `Ok(vec![])`.
35pub fn walk_container_escape<P: PhysicalMemoryProvider>(
36    reader: &ObjectReader<P>,
37) -> Result<Vec<ContainerEscapeInfo>> {
38    let init_task_addr = match reader.symbols().symbol_address("init_task") {
39        Some(a) => a,
40        None => return Ok(vec![]),
41    };
42
43    let tasks_offset = match reader.symbols().field_offset("task_struct", "tasks") {
44        Some(o) => o,
45        None => return Ok(vec![]),
46    };
47
48    // Read init_task's nsproxy and its mnt_ns to use as the host reference.
49    let init_nsproxy: u64 = match reader.read_field(init_task_addr, "task_struct", "nsproxy") {
50        Ok(v) => v,
51        Err(_) => return Ok(vec![]),
52    };
53    let init_mnt_ns: u64 = if init_nsproxy != 0 {
54        reader
55            .read_field(init_nsproxy, "nsproxy", "mnt_ns")
56            .unwrap_or(0)
57    } else {
58        0
59    };
60
61    let head_vaddr = init_task_addr + tasks_offset;
62    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
63
64    let mut findings = Vec::new();
65
66    for &task_addr in &task_addrs {
67        if let Some(info) = check_task_namespace(reader, task_addr, init_mnt_ns) {
68            findings.push(info);
69        }
70    }
71
72    Ok(findings)
73}
74
75/// Check a single task for namespace escape indicators.
76fn check_task_namespace<P: PhysicalMemoryProvider>(
77    reader: &ObjectReader<P>,
78    task_addr: u64,
79    init_mnt_ns: u64,
80) -> Option<ContainerEscapeInfo> {
81    let pid: u32 = reader.read_field(task_addr, "task_struct", "pid").ok()?;
82    let comm = reader
83        .read_field_string(task_addr, "task_struct", "comm", 16)
84        .unwrap_or_default();
85
86    let nsproxy: u64 = reader
87        .read_field(task_addr, "task_struct", "nsproxy")
88        .ok()?;
89
90    if nsproxy == 0 || init_mnt_ns == 0 {
91        return None;
92    }
93
94    let mnt_ns: u64 = reader.read_field(nsproxy, "nsproxy", "mnt_ns").unwrap_or(0);
95
96    // Processes in a different mount namespace from init are in a container.
97    if mnt_ns != init_mnt_ns && mnt_ns != 0 {
98        let indicator = "namespace_mismatch".to_string();
99        let is_suspicious = classify_container_escape(&comm, &indicator);
100        return Some(ContainerEscapeInfo {
101            pid,
102            comm,
103            indicator,
104            host_pid: None,
105            is_suspicious,
106        });
107    }
108
109    None
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
116    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
117    use memf_symbols::isf::IsfResolver;
118    use memf_symbols::test_builders::IsfBuilder;
119
120    // ---------------------------------------------------------------------------
121    // Unit tests for classify_container_escape
122    // ---------------------------------------------------------------------------
123
124    #[test]
125    fn classify_container_escape_namespace_mismatch_suspicious() {
126        assert!(classify_container_escape("bash", "namespace_mismatch"));
127    }
128
129    #[test]
130    fn classify_container_escape_kworker_not_suspicious() {
131        assert!(!classify_container_escape(
132            "kworker/0:0",
133            "namespace_mismatch"
134        ));
135    }
136
137    #[test]
138    fn classify_container_escape_host_mount_suspicious() {
139        assert!(classify_container_escape("python3", "host_mount_access"));
140    }
141
142    #[test]
143    fn classify_container_escape_migration_not_suspicious() {
144        assert!(!classify_container_escape(
145            "migration/0",
146            "host_mount_access"
147        ));
148    }
149
150    #[test]
151    fn classify_container_escape_unknown_indicator_not_suspicious() {
152        assert!(!classify_container_escape("bash", "pivot_root_anomaly"));
153    }
154
155    // ---------------------------------------------------------------------------
156    // Walker tests
157    // ---------------------------------------------------------------------------
158
159    fn make_minimal_reader_no_init_task() -> ObjectReader<SyntheticPhysMem> {
160        let isf = IsfBuilder::new()
161            .add_struct("task_struct", 64)
162            .add_field("task_struct", "pid", 0, "int")
163            .add_field("task_struct", "tasks", 8, "list_head")
164            .add_struct("list_head", 16)
165            .add_field("list_head", "next", 0, "pointer")
166            .add_field("list_head", "prev", 8, "pointer")
167            .build_json();
168
169        let resolver = IsfResolver::from_value(&isf).unwrap();
170        let (cr3, mem) = PageTableBuilder::new().build();
171        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
172        ObjectReader::new(vas, Box::new(resolver))
173    }
174
175    #[test]
176    fn walk_container_escape_missing_init_task_returns_empty() {
177        let reader = make_minimal_reader_no_init_task();
178        let result = walk_container_escape(&reader).unwrap();
179        assert!(result.is_empty());
180    }
181
182    /// Build a reader where init_task and one other task share the same
183    /// mount namespace — no escape detected.
184    ///
185    /// Each object lives at a distinct 4K-aligned virtual address so that
186    /// `PageTableBuilder::map_4k` can map them independently.
187    fn make_same_namespace_reader() -> ObjectReader<SyntheticPhysMem> {
188        // All virtual addresses are 4K-aligned and on distinct pages.
189        const INIT_VADDR: u64 = 0xFFFF_8000_0010_0000;
190        const NSP_VADDR: u64 = 0xFFFF_8000_0011_0000;
191        const TASK2_VADDR: u64 = 0xFFFF_8000_0012_0000;
192
193        let init_paddr: u64 = 0x0080_0000;
194        let nsp_paddr: u64 = 0x0081_0000;
195        let task2_paddr: u64 = 0x0082_0000;
196
197        // init_task: pid=1, tasks.next → task2.tasks, nsproxy → NSP_VADDR
198        let mut init_data = vec![0u8; 4096];
199        init_data[0..4].copy_from_slice(&1u32.to_le_bytes());
200        init_data[16..24].copy_from_slice(&(TASK2_VADDR + 16).to_le_bytes()); // tasks.next
201        init_data[24..32].copy_from_slice(&(TASK2_VADDR + 16).to_le_bytes()); // tasks.prev
202        init_data[32..39].copy_from_slice(b"systemd");
203        init_data[48..56].copy_from_slice(&NSP_VADDR.to_le_bytes()); // nsproxy
204
205        // nsproxy: mnt_ns = 0xAAAA_0000 (same for both tasks)
206        let mut nsp_data = vec![0u8; 4096];
207        nsp_data[0..8].copy_from_slice(&0xAAAA_0000u64.to_le_bytes());
208
209        // task2: pid=2, tasks.next → init.tasks (circular), same nsproxy
210        let mut task2_data = vec![0u8; 4096];
211        task2_data[0..4].copy_from_slice(&2u32.to_le_bytes());
212        task2_data[16..24].copy_from_slice(&(INIT_VADDR + 16).to_le_bytes()); // tasks.next
213        task2_data[24..32].copy_from_slice(&(INIT_VADDR + 16).to_le_bytes()); // tasks.prev
214        task2_data[32..36].copy_from_slice(b"bash");
215        task2_data[48..56].copy_from_slice(&NSP_VADDR.to_le_bytes()); // same nsproxy
216
217        let isf = IsfBuilder::new()
218            .add_struct("task_struct", 128)
219            .add_field("task_struct", "pid", 0, "int")
220            .add_field("task_struct", "tasks", 16, "list_head")
221            .add_field("task_struct", "comm", 32, "char")
222            .add_field("task_struct", "nsproxy", 48, "pointer")
223            .add_struct("list_head", 16)
224            .add_field("list_head", "next", 0, "pointer")
225            .add_field("list_head", "prev", 8, "pointer")
226            .add_struct("nsproxy", 64)
227            .add_field("nsproxy", "mnt_ns", 0, "pointer")
228            .add_symbol("init_task", INIT_VADDR)
229            .build_json();
230
231        let resolver = IsfResolver::from_value(&isf).unwrap();
232        let (cr3, mem) = PageTableBuilder::new()
233            .map_4k(INIT_VADDR, init_paddr, ptflags::WRITABLE)
234            .write_phys(init_paddr, &init_data)
235            .map_4k(NSP_VADDR, nsp_paddr, ptflags::WRITABLE)
236            .write_phys(nsp_paddr, &nsp_data)
237            .map_4k(TASK2_VADDR, task2_paddr, ptflags::WRITABLE)
238            .write_phys(task2_paddr, &task2_data)
239            .build();
240        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
241        ObjectReader::new(vas, Box::new(resolver))
242    }
243
244    #[test]
245    fn walk_container_escape_missing_tasks_field_returns_empty() {
246        // Covers line 56: init_task present but task_struct.tasks field absent → Ok(vec![])
247        let isf = IsfBuilder::new()
248            .add_struct("task_struct", 128)
249            .add_field("task_struct", "pid", 0, "int")
250            // tasks field absent
251            .add_symbol("init_task", 0xFFFF_8000_0020_0000)
252            .build_json();
253
254        let resolver = IsfResolver::from_value(&isf).unwrap();
255        let (cr3, mem) = PageTableBuilder::new().build();
256        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
257        let reader = ObjectReader::new(vas, Box::new(resolver));
258
259        let result = walk_container_escape(&reader).unwrap();
260        assert!(result.is_empty(), "missing tasks field → graceful empty");
261    }
262
263    #[test]
264    fn walk_container_escape_nsproxy_read_fails_returns_empty() {
265        // Covers line 62: nsproxy field missing in ISF → read_field returns Err → Ok(vec![])
266        // We have init_task, tasks field, but no nsproxy field → read_field fails → Ok([])
267        let isf = IsfBuilder::new()
268            .add_struct("list_head", 16)
269            .add_field("list_head", "next", 0, "pointer")
270            .add_field("list_head", "prev", 8, "pointer")
271            .add_struct("task_struct", 128)
272            .add_field("task_struct", "pid", 0, "int")
273            .add_field("task_struct", "tasks", 16, "list_head")
274            // nsproxy field intentionally absent → read_field("task_struct", "nsproxy") fails
275            .add_symbol("init_task", 0xFFFF_8000_0025_0000)
276            .build_json();
277
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        let result = walk_container_escape(&reader).unwrap();
284        assert!(result.is_empty(), "missing nsproxy field → graceful empty");
285    }
286
287    #[test]
288    fn walk_container_escape_init_nsproxy_zero_empty_list() {
289        // Covers lines 69 (init_nsproxy == 0 → init_mnt_ns = 0) and
290        // line 102 in check_task_namespace (init_mnt_ns == 0 → None).
291        let init_vaddr: u64 = 0xFFFF_8000_0030_0000;
292        let init_paddr: u64 = 0x0092_0000;
293
294        let mut page = [0u8; 4096];
295        // pid = 1
296        page[0..4].copy_from_slice(&1u32.to_le_bytes());
297        // tasks self-pointing
298        let tasks_self = init_vaddr + 16;
299        page[16..24].copy_from_slice(&tasks_self.to_le_bytes());
300        page[24..32].copy_from_slice(&tasks_self.to_le_bytes());
301        page[32..36].copy_from_slice(b"init");
302        // nsproxy = 0 → init_mnt_ns will be 0
303        page[48..56].copy_from_slice(&0u64.to_le_bytes());
304
305        let isf = IsfBuilder::new()
306            .add_struct("list_head", 16)
307            .add_field("list_head", "next", 0, "pointer")
308            .add_field("list_head", "prev", 8, "pointer")
309            .add_struct("task_struct", 128)
310            .add_field("task_struct", "pid", 0, "int")
311            .add_field("task_struct", "tasks", 16, "list_head")
312            .add_field("task_struct", "comm", 32, "char")
313            .add_field("task_struct", "nsproxy", 48, "pointer")
314            .add_struct("nsproxy", 64)
315            .add_field("nsproxy", "mnt_ns", 0, "pointer")
316            .add_symbol("init_task", init_vaddr)
317            .build_json();
318
319        let resolver = IsfResolver::from_value(&isf).unwrap();
320        let (cr3, mem) = PageTableBuilder::new()
321            .map_4k(init_vaddr, init_paddr, ptflags::WRITABLE)
322            .write_phys(init_paddr, &page)
323            .build();
324        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
325        let reader = ObjectReader::new(vas, Box::new(resolver));
326
327        let result = walk_container_escape(&reader).unwrap();
328        assert!(
329            result.is_empty(),
330            "init_nsproxy == 0 → init_mnt_ns = 0 → no findings"
331        );
332    }
333
334    #[test]
335    fn walk_container_escape_namespace_mismatch_detected() {
336        // Covers lines 79, 102-117: a task with a different mnt_ns than init is detected.
337        const INIT_VADDR: u64 = 0xFFFF_8000_0040_0000;
338        const NSP_INIT_VADDR: u64 = 0xFFFF_8000_0041_0000;
339        const TASK2_VADDR: u64 = 0xFFFF_8000_0042_0000;
340        const NSP_TASK2_VADDR: u64 = 0xFFFF_8000_0043_0000;
341
342        let init_paddr: u64 = 0x0093_0000;
343        let nsp_init_paddr: u64 = 0x0094_0000;
344        let task2_paddr: u64 = 0x0095_0000;
345        let nsp_task2_paddr: u64 = 0x0096_0000;
346
347        // init_task: nsproxy → NSP_INIT_VADDR, tasks → task2
348        let mut init_data = vec![0u8; 4096];
349        init_data[0..4].copy_from_slice(&1u32.to_le_bytes());
350        init_data[16..24].copy_from_slice(&(TASK2_VADDR + 16).to_le_bytes());
351        init_data[24..32].copy_from_slice(&(TASK2_VADDR + 16).to_le_bytes());
352        init_data[32..39].copy_from_slice(b"systemd");
353        init_data[48..56].copy_from_slice(&NSP_INIT_VADDR.to_le_bytes());
354
355        // nsproxy for init: mnt_ns = 0xAAAA_0000 (host namespace)
356        let mut nsp_init = vec![0u8; 4096];
357        nsp_init[0..8].copy_from_slice(&0xAAAA_0000u64.to_le_bytes());
358
359        // task2: nsproxy → NSP_TASK2_VADDR, different mnt_ns → detected
360        let mut task2_data = vec![0u8; 4096];
361        task2_data[0..4].copy_from_slice(&2u32.to_le_bytes());
362        task2_data[16..24].copy_from_slice(&(INIT_VADDR + 16).to_le_bytes());
363        task2_data[24..32].copy_from_slice(&(INIT_VADDR + 16).to_le_bytes());
364        task2_data[32..37].copy_from_slice(b"bash\0");
365        task2_data[48..56].copy_from_slice(&NSP_TASK2_VADDR.to_le_bytes());
366
367        // nsproxy for task2: mnt_ns = 0xBBBB_0000 (different → container escape)
368        let mut nsp_task2 = vec![0u8; 4096];
369        nsp_task2[0..8].copy_from_slice(&0xBBBB_0000u64.to_le_bytes());
370
371        let isf = IsfBuilder::new()
372            .add_struct("list_head", 16)
373            .add_field("list_head", "next", 0, "pointer")
374            .add_field("list_head", "prev", 8, "pointer")
375            .add_struct("task_struct", 128)
376            .add_field("task_struct", "pid", 0, "int")
377            .add_field("task_struct", "tasks", 16, "list_head")
378            .add_field("task_struct", "comm", 32, "char")
379            .add_field("task_struct", "nsproxy", 48, "pointer")
380            .add_struct("nsproxy", 64)
381            .add_field("nsproxy", "mnt_ns", 0, "pointer")
382            .add_symbol("init_task", INIT_VADDR)
383            .build_json();
384
385        let resolver = IsfResolver::from_value(&isf).unwrap();
386        let (cr3, mem) = PageTableBuilder::new()
387            .map_4k(INIT_VADDR, init_paddr, ptflags::WRITABLE)
388            .write_phys(init_paddr, &init_data)
389            .map_4k(NSP_INIT_VADDR, nsp_init_paddr, ptflags::WRITABLE)
390            .write_phys(nsp_init_paddr, &nsp_init)
391            .map_4k(TASK2_VADDR, task2_paddr, ptflags::WRITABLE)
392            .write_phys(task2_paddr, &task2_data)
393            .map_4k(NSP_TASK2_VADDR, nsp_task2_paddr, ptflags::WRITABLE)
394            .write_phys(nsp_task2_paddr, &nsp_task2)
395            .build();
396        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
397        let reader = ObjectReader::new(vas, Box::new(resolver));
398
399        let result = walk_container_escape(&reader).unwrap();
400        assert_eq!(result.len(), 1, "exactly one namespace mismatch expected");
401        assert_eq!(result[0].pid, 2);
402        assert_eq!(result[0].comm, "bash");
403        assert_eq!(result[0].indicator, "namespace_mismatch");
404        assert!(result[0].is_suspicious);
405    }
406
407    #[test]
408    fn classify_container_escape_kthread_prefix_not_suspicious() {
409        // Covers: kthread prefix in KERNEL_THREAD_COMMS
410        assert!(!classify_container_escape(
411            "kthread_worker",
412            "namespace_mismatch"
413        ));
414        assert!(!classify_container_escape(
415            "ksoftirqd/0",
416            "namespace_mismatch"
417        ));
418        assert!(!classify_container_escape(
419            "rcu_sched",
420            "namespace_mismatch"
421        ));
422    }
423
424    #[test]
425    fn walk_container_escape_single_namespace_returns_empty() {
426        let reader = make_same_namespace_reader();
427        let result = walk_container_escape(&reader).unwrap();
428        assert!(result.is_empty());
429    }
430}