Skip to main content

memf_linux/
malfind.rs

1//! Linux suspicious memory region detector (malfind).
2//!
3//! Scans process VMAs for regions that have suspicious permission
4//! combinations — primarily anonymous (non-file-backed) regions with
5//! both write and execute permissions, which often indicate injected code.
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::{vma_walker::for_each_task_vma, Error, MalfindInfo, Result};
11
12/// Number of header bytes to capture from suspicious regions.
13const HEADER_SIZE: usize = 64;
14
15/// Scan all process VMAs for suspicious memory regions.
16///
17/// Walks the task list, then for each process walks its VMAs via
18/// `mm_struct.mmap`. Flags anonymous regions with write+execute
19/// permissions.
20pub fn scan_malfind<P: PhysicalMemoryProvider>(
21    reader: &ObjectReader<P>,
22) -> Result<Vec<MalfindInfo>> {
23    let init_task_addr = reader
24        .symbols()
25        .symbol_address("init_task")
26        .ok_or_else(|| Error::MissingKernelSymbol {
27            name: "init_task".into(),
28        })?;
29
30    let tasks_offset = reader
31        .symbols()
32        .field_offset("task_struct", "tasks")
33        .ok_or_else(|| Error::MissingField {
34            struct_name: "task_struct".into(),
35            field_name: "tasks".into(),
36        })?;
37
38    let head_vaddr = init_task_addr + tasks_offset;
39    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
40
41    let mut findings = Vec::new();
42
43    // Include init_task itself
44    scan_process_vmas(reader, init_task_addr, &mut findings);
45
46    for &task_addr in &task_addrs {
47        scan_process_vmas(reader, task_addr, &mut findings);
48    }
49
50    Ok(findings)
51}
52
53/// Scan a single process's VMAs for suspicious regions.
54fn scan_process_vmas<P: PhysicalMemoryProvider>(
55    reader: &ObjectReader<P>,
56    task_addr: u64,
57    out: &mut Vec<MalfindInfo>,
58) {
59    let pid: u32 = match reader.read_field(task_addr, "task_struct", "pid") {
60        Ok(v) => v,
61        Err(_) => return,
62    };
63    let comm = reader
64        .read_field_string(task_addr, "task_struct", "comm", 16)
65        .unwrap_or_default();
66
67    for_each_task_vma(reader, task_addr, &mut |e| {
68        if let Some(f) = check_vma(reader, &e, u64::from(pid), &comm) {
69            out.push(f);
70        }
71    });
72}
73
74/// Check a single VMA for suspicious characteristics.
75/// Returns `Some(finding)` if suspicious, `None` if clean.
76fn check_vma<P: PhysicalMemoryProvider>(
77    reader: &ObjectReader<P>,
78    entry: &crate::vma_walker::VmaEntry,
79    pid: u64,
80    comm: &str,
81) -> Option<MalfindInfo> {
82    let file_backed = entry.file_ptr != 0;
83
84    // Suspicious: write+exec AND anonymous (not file-backed)
85    if !(entry.flags.write && entry.flags.exec && !file_backed) {
86        return None;
87    }
88
89    // Try to read header bytes from the region
90    let read_size = HEADER_SIZE.min((entry.end - entry.start) as usize);
91    let header_bytes = reader
92        .read_bytes(entry.start, read_size)
93        .unwrap_or_default();
94
95    let reason = format!(
96        "anonymous rwx region ({} flags, {} bytes)",
97        entry.flags,
98        entry.end - entry.start
99    );
100
101    Some(MalfindInfo {
102        pid,
103        comm: comm.to_string(),
104        start: entry.start,
105        end: entry.end,
106        flags: entry.flags,
107        reason,
108        header_bytes,
109    })
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    fn make_test_reader(
121        data: &[u8],
122        vaddr: u64,
123        paddr: u64,
124        extra_mappings: &[(u64, u64, &[u8])],
125    ) -> ObjectReader<SyntheticPhysMem> {
126        let isf = IsfBuilder::new()
127            .add_struct("task_struct", 128)
128            .add_field("task_struct", "pid", 0, "int")
129            .add_field("task_struct", "state", 4, "long")
130            .add_field("task_struct", "tasks", 16, "list_head")
131            .add_field("task_struct", "comm", 32, "char")
132            .add_field("task_struct", "mm", 48, "pointer")
133            .add_struct("list_head", 16)
134            .add_field("list_head", "next", 0, "pointer")
135            .add_field("list_head", "prev", 8, "pointer")
136            .add_struct("mm_struct", 128)
137            .add_field("mm_struct", "pgd", 0, "pointer")
138            .add_field("mm_struct", "mmap", 8, "pointer")
139            .add_struct("vm_area_struct", 64)
140            .add_field("vm_area_struct", "vm_start", 0, "unsigned long")
141            .add_field("vm_area_struct", "vm_end", 8, "unsigned long")
142            .add_field("vm_area_struct", "vm_next", 16, "pointer")
143            .add_field("vm_area_struct", "vm_flags", 24, "unsigned long")
144            .add_field("vm_area_struct", "vm_pgoff", 32, "unsigned long")
145            .add_field("vm_area_struct", "vm_file", 40, "pointer")
146            .add_symbol("init_task", vaddr)
147            .build_json();
148
149        let resolver = IsfResolver::from_value(&isf).unwrap();
150        let mut builder = PageTableBuilder::new()
151            .map_4k(vaddr, paddr, ptflags::WRITABLE)
152            .write_phys(paddr, data);
153
154        for &(ev, ep, edata) in extra_mappings {
155            builder = builder
156                .map_4k(ev, ep, ptflags::WRITABLE)
157                .write_phys(ep, edata);
158        }
159
160        let (cr3, mem) = builder.build();
161        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
162        ObjectReader::new(vas, Box::new(resolver))
163    }
164
165    // regression guard: anonymous rwx VMA flagged as suspicious
166    #[test]
167    fn detects_rwx_anonymous_region() {
168        let vaddr: u64 = 0xFFFF_8000_0010_0000;
169        let paddr: u64 = 0x0080_0000;
170        let mut data = vec![0u8; 4096];
171
172        // init_task (PID 1, "victim")
173        data[0..4].copy_from_slice(&1u32.to_le_bytes());
174        let tasks_addr = vaddr + 16;
175        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
176        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
177        data[32..38].copy_from_slice(b"victim");
178        let mm_addr = vaddr + 0x200;
179        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
180
181        // mm_struct
182        data[0x200..0x208].copy_from_slice(&0x1000u64.to_le_bytes());
183        let vma1_addr = vaddr + 0x300;
184        data[0x208..0x210].copy_from_slice(&vma1_addr.to_le_bytes());
185
186        // VMA #1: normal code segment r-x, file-backed — NOT suspicious
187        let code_start: u64 = 0xFFFF_8000_0020_0000;
188        data[0x300..0x308].copy_from_slice(&code_start.to_le_bytes());
189        data[0x308..0x310].copy_from_slice(&(code_start + 0x1000).to_le_bytes());
190        let vma2_addr = vaddr + 0x400;
191        data[0x310..0x318].copy_from_slice(&vma2_addr.to_le_bytes());
192        data[0x318..0x320].copy_from_slice(&0x5u64.to_le_bytes()); // r-x
193        data[0x328..0x330].copy_from_slice(&0x9999u64.to_le_bytes()); // vm_file non-null
194
195        // VMA #2: suspicious! anonymous rwx — injected shellcode
196        let suspect_vaddr: u64 = 0xFFFF_8000_0030_0000;
197        let suspect_paddr: u64 = 0x0090_0000;
198        data[0x400..0x408].copy_from_slice(&suspect_vaddr.to_le_bytes());
199        data[0x408..0x410].copy_from_slice(&(suspect_vaddr + 0x1000).to_le_bytes());
200        data[0x410..0x418].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
201        data[0x418..0x420].copy_from_slice(&0x7u64.to_le_bytes()); // rwx
202        data[0x420..0x428].copy_from_slice(&0u64.to_le_bytes()); // vm_pgoff
203        data[0x428..0x430].copy_from_slice(&0u64.to_le_bytes()); // vm_file = NULL (anon)
204
205        // Write MZ header into the suspicious region
206        let mut suspect_data = vec![0u8; 4096];
207        suspect_data[0] = b'M';
208        suspect_data[1] = b'Z';
209        suspect_data[2..64].fill(0x90); // NOP sled
210
211        let reader = make_test_reader(
212            &data,
213            vaddr,
214            paddr,
215            &[(suspect_vaddr, suspect_paddr, &suspect_data)],
216        );
217        let findings = scan_malfind(&reader).unwrap();
218
219        assert_eq!(findings.len(), 1);
220        assert_eq!(findings[0].pid, 1);
221        assert_eq!(findings[0].comm, "victim");
222        assert_eq!(findings[0].start, suspect_vaddr);
223        assert!(findings[0].flags.write);
224        assert!(findings[0].flags.exec);
225        assert_eq!(findings[0].header_bytes[0], b'M');
226        assert_eq!(findings[0].header_bytes[1], b'Z');
227        assert!(findings[0].reason.contains("anonymous"));
228    }
229
230    // regression guard: file-backed rwx VMA not flagged (file_ptr != 0 means benign)
231    #[test]
232    fn ignores_file_backed_rwx() {
233        let vaddr: u64 = 0xFFFF_8000_0010_0000;
234        let paddr: u64 = 0x0080_0000;
235        let mut data = vec![0u8; 4096];
236
237        data[0..4].copy_from_slice(&1u32.to_le_bytes());
238        let tasks_addr = vaddr + 16;
239        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
240        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
241        data[32..36].copy_from_slice(b"test");
242        let mm_addr = vaddr + 0x200;
243        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
244
245        data[0x200..0x208].copy_from_slice(&0x1000u64.to_le_bytes());
246        let vma_addr = vaddr + 0x300;
247        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes());
248
249        // rwx but FILE-BACKED — not suspicious (e.g. JIT region from mapped file)
250        data[0x300..0x308].copy_from_slice(&0x0040_0000u64.to_le_bytes());
251        data[0x308..0x310].copy_from_slice(&0x0040_1000u64.to_le_bytes());
252        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes());
253        data[0x318..0x320].copy_from_slice(&0x7u64.to_le_bytes()); // rwx
254        data[0x328..0x330].copy_from_slice(&0xABCDu64.to_le_bytes()); // vm_file non-null
255
256        let reader = make_test_reader(&data, vaddr, paddr, &[]);
257        let findings = scan_malfind(&reader).unwrap();
258
259        assert!(findings.is_empty());
260    }
261
262    #[test]
263    fn skips_kernel_threads() {
264        let vaddr: u64 = 0xFFFF_8000_0010_0000;
265        let paddr: u64 = 0x0080_0000;
266        let mut data = vec![0u8; 4096];
267
268        data[0..4].copy_from_slice(&0u32.to_le_bytes());
269        let tasks_addr = vaddr + 16;
270        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
271        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
272        data[32..41].copy_from_slice(b"swapper/0");
273        data[48..56].copy_from_slice(&0u64.to_le_bytes()); // mm = NULL
274
275        let reader = make_test_reader(&data, vaddr, paddr, &[]);
276        let findings = scan_malfind(&reader).unwrap();
277
278        assert!(findings.is_empty());
279    }
280
281    #[test]
282    fn missing_init_task_symbol() {
283        let isf = IsfBuilder::new()
284            .add_struct("task_struct", 64)
285            .add_field("task_struct", "pid", 0, "int")
286            .add_field("task_struct", "tasks", 8, "list_head")
287            .add_struct("list_head", 16)
288            .add_field("list_head", "next", 0, "pointer")
289            .add_field("list_head", "prev", 8, "pointer")
290            .build_json();
291
292        let resolver = IsfResolver::from_value(&isf).unwrap();
293        let (cr3, mem) = PageTableBuilder::new().build();
294        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
295        let reader = ObjectReader::new(vas, Box::new(resolver));
296
297        let result = scan_malfind(&reader);
298        assert!(
299            matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "init_task"),
300            "expected MissingKernelSymbol {{name: \"init_task\"}}, got {result:?}"
301        );
302    }
303
304    #[test]
305    fn missing_tasks_field_returns_missing_field() {
306        let isf = IsfBuilder::new()
307            .add_struct("task_struct", 64)
308            .add_field("task_struct", "pid", 0, "int")
309            // tasks intentionally omitted
310            .add_struct("list_head", 16)
311            .add_field("list_head", "next", 0, "pointer")
312            .add_field("list_head", "prev", 8, "pointer")
313            .add_symbol("init_task", 0xFFFF_8000_0010_0000)
314            .build_json();
315        let resolver = IsfResolver::from_value(&isf).unwrap();
316        let (cr3, mem) = PageTableBuilder::new().build();
317        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
318        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
319        let result = scan_malfind(&reader);
320        assert!(
321            matches!(result, Err(crate::Error::MissingField { ref struct_name, ref field_name }) if struct_name == "task_struct" && field_name == "tasks"),
322            "expected MissingField task_struct.tasks, got {result:?}"
323        );
324    }
325}