Skip to main content

memf_linux/
crontab.rs

1//! Linux crontab entry recovery from cron process memory.
2//!
3//! Scans memory regions of cron-related processes (cron, crond, anacron, atd)
4//! for lines matching crontab format: five time fields followed by a command.
5
6use memf_core::object_reader::ObjectReader;
7use memf_format::PhysicalMemoryProvider;
8
9use crate::{vma_walker::for_each_task_vma, CrontabEntry, Error, Result};
10
11/// Cron-related process names to scan.
12const CRON_PROCS: &[&str] = &["cron", "crond", "anacron", "atd"];
13
14/// Maximum readable region size to scan (4 MiB safety limit).
15const MAX_REGION_SCAN: u64 = 4 * 1024 * 1024;
16
17/// Walk all cron-related processes and recover crontab entries from memory.
18///
19/// Finds processes with `comm` matching known cron daemon names, then scans
20/// their readable anonymous VMAs for lines matching crontab format (five
21/// time fields followed by a command).
22pub fn walk_crontab_entries<P: PhysicalMemoryProvider>(
23    reader: &ObjectReader<P>,
24) -> Result<Vec<CrontabEntry>> {
25    let init_task_addr = reader
26        .symbols()
27        .symbol_address("init_task")
28        .ok_or_else(|| Error::MissingKernelSymbol {
29            name: "init_task".into(),
30        })?;
31
32    let tasks_offset = reader
33        .symbols()
34        .field_offset("task_struct", "tasks")
35        .ok_or_else(|| Error::MissingField {
36            struct_name: "task_struct".into(),
37            field_name: "tasks".into(),
38        })?;
39
40    let head_vaddr = init_task_addr + tasks_offset;
41    let task_addrs = reader.walk_list(head_vaddr, "task_struct", "tasks")?;
42
43    let mut results = Vec::new();
44
45    // Include init_task itself
46    scan_process_crontab(reader, init_task_addr, &mut results);
47
48    for &task_addr in &task_addrs {
49        scan_process_crontab(reader, task_addr, &mut results);
50    }
51
52    Ok(results)
53}
54
55/// Scan a single process for crontab entries in its memory.
56fn scan_process_crontab<P: PhysicalMemoryProvider>(
57    reader: &ObjectReader<P>,
58    task_addr: u64,
59    out: &mut Vec<CrontabEntry>,
60) {
61    let Ok(comm) = reader.read_field_string(task_addr, "task_struct", "comm", 16) else {
62        return;
63    };
64
65    if !CRON_PROCS.iter().any(|name| comm == *name) {
66        return;
67    }
68
69    let pid: u32 = match reader.read_field(task_addr, "task_struct", "pid") {
70        Ok(v) => v,
71        Err(_) => return,
72    };
73
74    // Collect readable VMA regions via the shared walker.
75    let mut readable_regions: Vec<(u64, u64)> = Vec::new();
76
77    for_each_task_vma(reader, task_addr, &mut |e| {
78        if e.flags.read {
79            readable_regions.push((e.start, e.end));
80        }
81    });
82
83    // Scan each readable region for crontab-format lines
84    for &(start, end) in &readable_regions {
85        let size = end.saturating_sub(start);
86        if size == 0 || size > MAX_REGION_SCAN {
87            continue;
88        }
89        let Ok(data) = reader.read_bytes(start, size as usize) else {
90            continue;
91        };
92        // Split on null bytes to handle C-string boundaries in heap memory,
93        // then scan each segment for crontab lines.
94        for chunk in data.split(|&b| b == 0) {
95            if chunk.is_empty() {
96                continue;
97            }
98            let text = String::from_utf8_lossy(chunk);
99            for line in text.lines() {
100                let trimmed = line.trim();
101                if is_crontab_line(trimmed) {
102                    out.push(CrontabEntry {
103                        pid: u64::from(pid),
104                        comm: comm.clone(),
105                        line: trimmed.to_string(),
106                    });
107                }
108            }
109        }
110    }
111}
112
113/// Check if a string looks like a crontab entry.
114///
115/// Matches: five whitespace-separated time fields (digits, `*`, `/`, `-`, comma)
116/// followed by at least one command character.
117fn is_crontab_line(line: &str) -> bool {
118    // Skip empty, comments, variable assignments
119    if line.is_empty() || line.starts_with('#') {
120        return false;
121    }
122    // Bare variable assignments like PATH=/usr/bin (no spaces before '=')
123    if let Some(eq_pos) = line.find('=') {
124        if !line[..eq_pos].contains(' ') {
125            return false;
126        }
127    }
128
129    let parts: Vec<&str> = line.split_whitespace().collect();
130    if parts.len() < 6 {
131        return false;
132    }
133
134    // First 5 fields must be valid cron time fields
135    for field in &parts[..5] {
136        if !is_cron_time_field(field) {
137            return false;
138        }
139    }
140
141    // 6th field (command) must start with / or a letter
142    let cmd = parts[5];
143    cmd.starts_with('/') || cmd.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
144}
145
146/// Check if a string is a valid cron time field.
147///
148/// Valid characters: digits, `*`, `/`, `-`, `,`.
149fn is_cron_time_field(field: &str) -> bool {
150    if field == "*" {
151        return true;
152    }
153    !field.is_empty()
154        && field
155            .chars()
156            .all(|c| c.is_ascii_digit() || c == '*' || c == '/' || c == '-' || c == ',')
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn is_crontab_line_valid() {
165        assert!(is_crontab_line("0 * * * * /usr/bin/backup.sh"));
166        assert!(is_crontab_line("*/5 * * * * curl http://example.com"));
167        assert!(is_crontab_line("0 0 1 * * /bin/monthly_report"));
168        assert!(is_crontab_line("30 2 * * 1-5 /opt/weekday_job"));
169    }
170
171    #[test]
172    fn is_crontab_line_invalid() {
173        assert!(!is_crontab_line(""));
174        assert!(!is_crontab_line("# This is a comment"));
175        assert!(!is_crontab_line("PATH=/usr/bin"));
176        assert!(!is_crontab_line("hello world"));
177        assert!(!is_crontab_line("abc def ghi jkl mno pqr")); // non-cron fields
178    }
179
180    #[test]
181    fn is_cron_time_field_valid() {
182        assert!(is_cron_time_field("*"));
183        assert!(is_cron_time_field("0"));
184        assert!(is_cron_time_field("*/5"));
185        assert!(is_cron_time_field("1-5"));
186        assert!(is_cron_time_field("0,15,30,45"));
187    }
188
189    #[test]
190    fn is_cron_time_field_invalid() {
191        assert!(!is_cron_time_field(""));
192        assert!(!is_cron_time_field("abc"));
193        assert!(!is_cron_time_field("hello"));
194    }
195
196    // --- Integration test with synthetic memory ---
197
198    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
199    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
200    use memf_symbols::isf::IsfResolver;
201    use memf_symbols::test_builders::IsfBuilder;
202
203    fn make_test_reader(
204        data: &[u8],
205        vaddr: u64,
206        paddr: u64,
207        extra_mappings: &[(u64, u64, &[u8])],
208    ) -> ObjectReader<SyntheticPhysMem> {
209        let isf = IsfBuilder::new()
210            .add_struct("task_struct", 128)
211            .add_field("task_struct", "pid", 0, "int")
212            .add_field("task_struct", "state", 4, "long")
213            .add_field("task_struct", "tasks", 16, "list_head")
214            .add_field("task_struct", "comm", 32, "char")
215            .add_field("task_struct", "mm", 48, "pointer")
216            .add_struct("list_head", 16)
217            .add_field("list_head", "next", 0, "pointer")
218            .add_field("list_head", "prev", 8, "pointer")
219            .add_struct("mm_struct", 128)
220            .add_field("mm_struct", "pgd", 0, "pointer")
221            .add_field("mm_struct", "mmap", 8, "pointer")
222            .add_struct("vm_area_struct", 64)
223            .add_field("vm_area_struct", "vm_start", 0, "unsigned long")
224            .add_field("vm_area_struct", "vm_end", 8, "unsigned long")
225            .add_field("vm_area_struct", "vm_next", 16, "pointer")
226            .add_field("vm_area_struct", "vm_flags", 24, "unsigned long")
227            .add_field("vm_area_struct", "vm_pgoff", 32, "unsigned long")
228            .add_field("vm_area_struct", "vm_file", 40, "pointer")
229            .add_symbol("init_task", vaddr)
230            .build_json();
231
232        let resolver = IsfResolver::from_value(&isf).unwrap();
233        let mut builder = PageTableBuilder::new()
234            .map_4k(vaddr, paddr, ptflags::WRITABLE)
235            .write_phys(paddr, data);
236
237        for &(ev, ep, edata) in extra_mappings {
238            builder = builder
239                .map_4k(ev, ep, ptflags::WRITABLE)
240                .write_phys(ep, edata);
241        }
242
243        let (cr3, mem) = builder.build();
244        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
245        ObjectReader::new(vas, Box::new(resolver))
246    }
247
248    /// Build a synthetic heap page containing crontab entries as text.
249    fn build_heap_with_crontab(entries: &[&str]) -> Vec<u8> {
250        let mut heap = vec![0u8; 4096];
251        let text = entries.join("\n");
252        let bytes = text.as_bytes();
253        let len = bytes.len().min(4096);
254        heap[..len].copy_from_slice(&bytes[..len]);
255        heap
256    }
257
258    // regression guard: readable VMA region scanned for crontab entries
259    #[test]
260    fn recovers_crontab_from_crond_heap() {
261        let vaddr: u64 = 0xFFFF_8000_0010_0000;
262        let paddr: u64 = 0x0080_0000;
263        let mut data = vec![0u8; 4096];
264
265        // init_task (PID 100, comm "crond")
266        data[0..4].copy_from_slice(&100u32.to_le_bytes());
267        let tasks_addr = vaddr + 16;
268        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.next = self
269        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes()); // tasks.prev = self
270        data[32..37].copy_from_slice(b"crond");
271        let mm_addr = vaddr + 0x200;
272        data[48..56].copy_from_slice(&mm_addr.to_le_bytes()); // mm
273
274        // mm_struct at +0x200
275        data[0x200..0x208].copy_from_slice(&0x1000u64.to_le_bytes()); // pgd
276        let vma_addr = vaddr + 0x300;
277        data[0x208..0x210].copy_from_slice(&vma_addr.to_le_bytes()); // mmap
278
279        // VMA: readable region in userspace
280        let heap_vaddr: u64 = 0x0000_5555_0000_0000;
281        let heap_paddr: u64 = 0x0090_0000;
282        data[0x300..0x308].copy_from_slice(&heap_vaddr.to_le_bytes()); // vm_start
283        data[0x308..0x310].copy_from_slice(&(heap_vaddr + 0x1000).to_le_bytes()); // vm_end
284        data[0x310..0x318].copy_from_slice(&0u64.to_le_bytes()); // vm_next = NULL
285        data[0x318..0x320].copy_from_slice(&0x1u64.to_le_bytes()); // vm_flags: read
286        data[0x320..0x328].copy_from_slice(&0u64.to_le_bytes()); // vm_pgoff
287        data[0x328..0x330].copy_from_slice(&0u64.to_le_bytes()); // vm_file = NULL
288
289        let heap = build_heap_with_crontab(&[
290            "0 * * * * /usr/bin/backup.sh",
291            "*/5 * * * * curl http://example.com",
292            "# this is a comment",
293            "30 2 * * 1-5 /opt/weekday_job",
294        ]);
295
296        let reader = make_test_reader(&data, vaddr, paddr, &[(heap_vaddr, heap_paddr, &heap)]);
297        let results = walk_crontab_entries(&reader).unwrap();
298
299        assert_eq!(results.len(), 3);
300        assert_eq!(results[0].pid, 100);
301        assert_eq!(results[0].comm, "crond");
302        assert_eq!(results[0].line, "0 * * * * /usr/bin/backup.sh");
303        assert_eq!(results[1].line, "*/5 * * * * curl http://example.com");
304        assert_eq!(results[2].line, "30 2 * * 1-5 /opt/weekday_job");
305    }
306
307    #[test]
308    fn skips_non_cron_processes() {
309        let vaddr: u64 = 0xFFFF_8000_0010_0000;
310        let paddr: u64 = 0x0080_0000;
311        let mut data = vec![0u8; 4096];
312
313        // init_task (PID 1, comm "nginx") — not a cron process
314        data[0..4].copy_from_slice(&1u32.to_le_bytes());
315        let tasks_addr = vaddr + 16;
316        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
317        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
318        data[32..37].copy_from_slice(b"nginx");
319        let mm_addr = vaddr + 0x200;
320        data[48..56].copy_from_slice(&mm_addr.to_le_bytes());
321
322        data[0x200..0x208].copy_from_slice(&0x1000u64.to_le_bytes());
323        data[0x208..0x210].copy_from_slice(&0u64.to_le_bytes()); // mmap = NULL
324
325        let reader = make_test_reader(&data, vaddr, paddr, &[]);
326        let results = walk_crontab_entries(&reader).unwrap();
327
328        assert!(results.is_empty());
329    }
330
331    // regression guard: mm==0 kernel thread produces no crontab entries
332    #[test]
333    fn skips_kernel_threads() {
334        let vaddr: u64 = 0xFFFF_8000_0010_0000;
335        let paddr: u64 = 0x0080_0000;
336        let mut data = vec![0u8; 4096];
337
338        // comm is "cron" but mm = NULL (kernel thread)
339        data[0..4].copy_from_slice(&0u32.to_le_bytes());
340        let tasks_addr = vaddr + 16;
341        data[16..24].copy_from_slice(&tasks_addr.to_le_bytes());
342        data[24..32].copy_from_slice(&tasks_addr.to_le_bytes());
343        data[32..36].copy_from_slice(b"cron");
344        data[48..56].copy_from_slice(&0u64.to_le_bytes()); // mm = NULL
345
346        let reader = make_test_reader(&data, vaddr, paddr, &[]);
347        let results = walk_crontab_entries(&reader).unwrap();
348
349        assert!(results.is_empty());
350    }
351
352    #[test]
353    fn missing_init_task_symbol() {
354        let isf = IsfBuilder::new()
355            .add_struct("task_struct", 64)
356            .add_field("task_struct", "pid", 0, "int")
357            .add_field("task_struct", "tasks", 8, "list_head")
358            .add_struct("list_head", 16)
359            .add_field("list_head", "next", 0, "pointer")
360            .add_field("list_head", "prev", 8, "pointer")
361            .build_json();
362
363        let resolver = IsfResolver::from_value(&isf).unwrap();
364        let (cr3, mem) = PageTableBuilder::new().build();
365        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
366        let reader = ObjectReader::new(vas, Box::new(resolver));
367
368        let result = walk_crontab_entries(&reader);
369        assert!(
370            matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "init_task"),
371            "expected MissingKernelSymbol {{name: \"init_task\"}}, got {result:?}"
372        );
373    }
374
375    #[test]
376    fn missing_tasks_field_returns_missing_field() {
377        let isf = IsfBuilder::new()
378            .add_struct("task_struct", 64)
379            .add_field("task_struct", "pid", 0, "int")
380            // tasks intentionally omitted
381            .add_struct("list_head", 16)
382            .add_field("list_head", "next", 0, "pointer")
383            .add_field("list_head", "prev", 8, "pointer")
384            .add_symbol("init_task", 0xFFFF_8000_0010_0000)
385            .build_json();
386
387        let resolver = IsfResolver::from_value(&isf).unwrap();
388        let (cr3, mem) = PageTableBuilder::new().build();
389        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
390        let reader = ObjectReader::new(vas, Box::new(resolver));
391
392        let result = walk_crontab_entries(&reader);
393        assert!(
394            matches!(result, Err(crate::Error::MissingField { ref struct_name, ref field_name }) if struct_name == "task_struct" && field_name == "tasks"),
395            "expected MissingField task_struct.tasks, got {result:?}"
396        );
397    }
398
399    #[test]
400    fn recognizes_all_cron_daemon_names() {
401        // Verify CRON_PROCS contains expected entries
402        assert!(CRON_PROCS.contains(&"cron"));
403        assert!(CRON_PROCS.contains(&"crond"));
404        assert!(CRON_PROCS.contains(&"anacron"));
405        assert!(CRON_PROCS.contains(&"atd"));
406        assert!(!CRON_PROCS.contains(&"bash"));
407    }
408}