1use forensicnomicon::heuristics::linux_rootkit::FATHER_CLASS_ELF_PATTERNS;
4use forensicnomicon::heuristics::linux_rootkit::ROOTKIT_HOOK_SYMBOLS;
5use forensicnomicon::threat_intel::signals as S;
6use goblin::elf::Elf;
7
8pub struct ElfCapabilityReport {
10 pub source: String,
12 pub matched_hooks: Vec<HookMatch>,
14 pub libc_shadow_exports: Vec<String>,
16 pub signals: Vec<&'static str>,
18 pub mitre_techniques: Vec<&'static str>,
20}
21
22impl std::fmt::Debug for ElfCapabilityReport {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 f.debug_struct("ElfCapabilityReport")
25 .field("source", &self.source)
26 .field("signals", &self.signals)
27 .finish_non_exhaustive()
28 }
29}
30
31impl Clone for ElfCapabilityReport {
32 fn clone(&self) -> Self {
33 Self {
34 source: self.source.clone(),
35 matched_hooks: self.matched_hooks.clone(),
36 libc_shadow_exports: self.libc_shadow_exports.clone(),
37 signals: self.signals.clone(),
38 mitre_techniques: self.mitre_techniques.clone(),
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct HookMatch {
46 pub symbol_name: String,
48 pub signal_id: &'static str,
50 pub mitre_technique: &'static str,
52}
53
54#[derive(Debug, Clone)]
56pub struct ElfStringArtifact {
57 pub matched_pattern: &'static str,
59 pub description: &'static str,
61 pub weight: u32,
63 pub context: String,
65}
66
67pub fn analyse_elf_capabilities(
72 bytes: &[u8],
73 source: impl Into<String>,
74) -> Option<ElfCapabilityReport> {
75 let elf = Elf::parse(bytes).ok()?;
76
77 let mut matched_hooks = Vec::new();
78 let mut libc_shadow_exports = Vec::new();
79
80 let hook_names: std::collections::HashSet<&str> =
81 ROOTKIT_HOOK_SYMBOLS.iter().map(|s| s.name).collect();
82
83 for sym in &elf.dynsyms {
84 if sym.st_name == 0 {
85 continue;
86 }
87 let name = match elf.dynstrtab.get_at(sym.st_name) {
88 Some(n) => n,
89 None => continue,
90 };
91
92 if let Some(hook) = ROOTKIT_HOOK_SYMBOLS.iter().find(|s| s.name == name) {
93 matched_hooks.push(HookMatch {
94 symbol_name: name.to_string(),
95 signal_id: hook.emits_signal,
96 mitre_technique: hook.mitre_technique,
97 });
98 }
99
100 if !sym.is_import() && hook_names.contains(name) {
101 libc_shadow_exports.push(name.to_string());
102 }
103 }
104
105 let mut seen_sig = std::collections::HashSet::new();
106 let mut signals: Vec<&'static str> = matched_hooks
107 .iter()
108 .filter_map(|h| seen_sig.insert(h.signal_id).then_some(h.signal_id))
109 .collect();
110
111 if !libc_shadow_exports.is_empty() && seen_sig.insert(S::ELF_LIBC_SHADOW_EXPORTS) {
112 signals.push(S::ELF_LIBC_SHADOW_EXPORTS);
113 }
114
115 let mut seen_tt = std::collections::HashSet::new();
116 let mitre_techniques: Vec<&'static str> = matched_hooks
117 .iter()
118 .filter_map(|h| {
119 seen_tt
120 .insert(h.mitre_technique)
121 .then_some(h.mitre_technique)
122 })
123 .collect();
124
125 Some(ElfCapabilityReport {
126 source: source.into(),
127 matched_hooks,
128 libc_shadow_exports,
129 signals,
130 mitre_techniques,
131 })
132}
133
134pub fn scan_elf_string_artifacts(bytes: &[u8]) -> Option<Vec<ElfStringArtifact>> {
139 let elf = Elf::parse(bytes).ok()?;
140 let mut results = Vec::new();
141
142 for section in &elf.section_headers {
143 let name = elf.shdr_strtab.get_at(section.sh_name).unwrap_or("");
144 let is_string_section = matches!(
145 name,
146 ".rodata" | ".rodata.str1.1" | ".rodata.str1.8" | ".data.rel.ro"
147 ) || section.sh_type == goblin::elf::section_header::SHT_PROGBITS;
148
149 if !is_string_section {
150 continue;
151 }
152 let start = section.sh_offset as usize;
153 let end = start.saturating_add(section.sh_size as usize);
154 let section_bytes = bytes.get(start..end).unwrap_or(&[]);
155 let section_str = String::from_utf8_lossy(section_bytes);
156
157 for pattern_def in FATHER_CLASS_ELF_PATTERNS {
158 if let Some(pos) = section_str.find(pattern_def.pattern) {
159 let ctx_start = pos.saturating_sub(20);
160 let ctx_end = (pos + pattern_def.pattern.len() + 20).min(section_str.len());
161 let context: String = section_str[ctx_start..ctx_end]
162 .chars()
163 .map(|c| {
164 if c.is_ascii_graphic() || c == ' ' {
165 c
166 } else {
167 '.'
168 }
169 })
170 .collect();
171 results.push(ElfStringArtifact {
172 matched_pattern: pattern_def.pattern,
173 description: pattern_def.description,
174 weight: pattern_def.weight,
175 context,
176 });
177 }
178 }
179 }
180 Some(results)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn minimal_elf() -> Vec<u8> {
191 let mut e = vec![0u8; 64];
192 e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
193 e[4] = 2; e[5] = 1; e[6] = 1; e[7] = 0; e[16] = 3;
198 e[17] = 0; e[18] = 62;
200 e[19] = 0; e[20] = 1; e
203 }
204
205 fn elf_with_dynamic_import(sym_name: &str) -> Vec<u8> {
211 const HASH_OFFSET: u64 = 0xB0; const DYNSTR_OFFSET: u64 = 0xC8;
224
225 let mut dynstr = vec![0u8];
227 let sym_name_idx = dynstr.len() as u32; dynstr.extend_from_slice(sym_name.as_bytes());
229 dynstr.push(0);
230 let dynstr_size = dynstr.len() as u64;
231
232 let dynsym_offset_raw = DYNSTR_OFFSET as usize + dynstr_size as usize;
234 let dynsym_offset = ((dynsym_offset_raw + 7) & !7) as u64;
235 let dynstr_pad = dynsym_offset as usize - dynsym_offset_raw;
236
237 let mut dynsym_bytes = vec![0u8; 24]; dynsym_bytes.extend_from_slice(&sym_name_idx.to_le_bytes()); dynsym_bytes.push(0x12); dynsym_bytes.push(0); dynsym_bytes.extend_from_slice(&0u16.to_le_bytes()); dynsym_bytes.extend_from_slice(&[0u8; 16]); let dynsym_size = dynsym_bytes.len() as u64;
244
245 let dynamic_offset_raw = dynsym_offset as usize + dynsym_size as usize;
247 let dynamic_offset = ((dynamic_offset_raw + 7) & !7) as u64;
248 let dynsym_pad = dynamic_offset as usize - dynamic_offset_raw;
249
250 let mut dyn_bytes = Vec::new();
251 let push_dyn = |tag: u64, val: u64, buf: &mut Vec<u8>| {
252 buf.extend_from_slice(&tag.to_le_bytes());
253 buf.extend_from_slice(&val.to_le_bytes());
254 };
255 push_dyn(4, HASH_OFFSET, &mut dyn_bytes); push_dyn(5, DYNSTR_OFFSET, &mut dyn_bytes); push_dyn(10, dynstr_size, &mut dyn_bytes); push_dyn(6, dynsym_offset, &mut dyn_bytes); push_dyn(11, 24, &mut dyn_bytes); push_dyn(0, 0, &mut dyn_bytes); let dynamic_size = dyn_bytes.len() as u64;
263
264 let shstrtab_offset_raw = dynamic_offset as usize + dynamic_size as usize;
266 let shstrtab_offset = ((shstrtab_offset_raw + 7) & !7) as u64;
267 let dynamic_pad = shstrtab_offset as usize - shstrtab_offset_raw;
268
269 let mut shstrtab = vec![0u8];
270 let idx_hash = shstrtab.len() as u32;
271 shstrtab.extend_from_slice(b".hash\0");
272 let idx_dynstr = shstrtab.len() as u32;
273 shstrtab.extend_from_slice(b".dynstr\0");
274 let idx_dynsym = shstrtab.len() as u32;
275 shstrtab.extend_from_slice(b".dynsym\0");
276 let idx_dynamic = shstrtab.len() as u32;
277 shstrtab.extend_from_slice(b".dynamic\0");
278 let idx_shstrtab = shstrtab.len() as u32;
279 shstrtab.extend_from_slice(b".shstrtab\0");
280 let shstrtab_size = shstrtab.len() as u64;
281
282 let shoff_raw = shstrtab_offset as usize + shstrtab_size as usize;
284 let shoff = ((shoff_raw + 7) & !7) as u64;
285 let shstrtab_pad = shoff as usize - shoff_raw;
286 let total_size = shoff + 6 * 64;
287
288 let mut shdrs = Vec::new();
289 shdrs.extend_from_slice(&[0u8; 64]); shdrs.extend_from_slice(&shdr64(idx_hash, 5, HASH_OFFSET, 20, 4, 4, 0, 0)); shdrs.extend_from_slice(&shdr64(
292 idx_dynstr,
293 3,
294 DYNSTR_OFFSET,
295 dynstr_size,
296 1,
297 0,
298 0,
299 0,
300 )); shdrs.extend_from_slice(&shdr64(
302 idx_dynsym,
303 11,
304 dynsym_offset,
305 dynsym_size,
306 8,
307 24,
308 2,
309 1,
310 )); shdrs.extend_from_slice(&shdr64(
312 idx_dynamic,
313 6,
314 dynamic_offset,
315 dynamic_size,
316 8,
317 16,
318 2,
319 0,
320 )); shdrs.extend_from_slice(&shdr64(
322 idx_shstrtab,
323 3,
324 shstrtab_offset,
325 shstrtab_size,
326 1,
327 0,
328 0,
329 0,
330 )); let mut hash_bytes = Vec::new();
334 for v in [1u32, 2, 1, 0, 0] {
335 hash_bytes.extend_from_slice(&v.to_le_bytes());
336 }
337
338 let mut phdr_load = vec![0u8; 56];
340 phdr_load[0..4].copy_from_slice(&1u32.to_le_bytes()); phdr_load[4..8].copy_from_slice(&5u32.to_le_bytes()); phdr_load[32..40].copy_from_slice(&total_size.to_le_bytes()); phdr_load[40..48].copy_from_slice(&total_size.to_le_bytes()); phdr_load[48..56].copy_from_slice(&0x1000u64.to_le_bytes()); let mut phdr_dyn = vec![0u8; 56];
349 phdr_dyn[0..4].copy_from_slice(&2u32.to_le_bytes()); phdr_dyn[4..8].copy_from_slice(&6u32.to_le_bytes()); phdr_dyn[8..16].copy_from_slice(&dynamic_offset.to_le_bytes()); phdr_dyn[16..24].copy_from_slice(&dynamic_offset.to_le_bytes()); phdr_dyn[24..32].copy_from_slice(&dynamic_offset.to_le_bytes()); phdr_dyn[32..40].copy_from_slice(&dynamic_size.to_le_bytes()); phdr_dyn[40..48].copy_from_slice(&dynamic_size.to_le_bytes()); phdr_dyn[48..56].copy_from_slice(&8u64.to_le_bytes()); let mut hdr = vec![0u8; 64];
360 hdr[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
361 hdr[4] = 2;
362 hdr[5] = 1;
363 hdr[6] = 1; hdr[16] = 3;
365 hdr[17] = 0; hdr[18] = 62;
367 hdr[19] = 0; hdr[20] = 1; hdr[32..40].copy_from_slice(&64u64.to_le_bytes()); hdr[40..48].copy_from_slice(&shoff.to_le_bytes()); hdr[52..54].copy_from_slice(&64u16.to_le_bytes()); hdr[54..56].copy_from_slice(&56u16.to_le_bytes()); hdr[56..58].copy_from_slice(&2u16.to_le_bytes()); hdr[58..60].copy_from_slice(&64u16.to_le_bytes()); hdr[60..62].copy_from_slice(&6u16.to_le_bytes()); hdr[62..64].copy_from_slice(&5u16.to_le_bytes()); let mut out = hdr; out.extend_from_slice(&phdr_load); out.extend_from_slice(&phdr_dyn); out.extend_from_slice(&hash_bytes); out.extend_from_slice(&[0u8; 4]); out.extend_from_slice(&dynstr); out.extend_from_slice(&vec![0u8; dynstr_pad]);
386 out.extend_from_slice(&dynsym_bytes);
387 out.extend_from_slice(&vec![0u8; dynsym_pad]);
388 out.extend_from_slice(&dyn_bytes);
389 out.extend_from_slice(&vec![0u8; dynamic_pad]);
390 out.extend_from_slice(&shstrtab);
391 out.extend_from_slice(&vec![0u8; shstrtab_pad]);
392 out.extend_from_slice(&shdrs);
393 out
394 }
395
396 #[allow(clippy::too_many_arguments)] fn shdr64(
399 sh_name: u32,
400 sh_type: u32,
401 sh_offset: u64,
402 sh_size: u64,
403 sh_addralign: u64,
404 sh_entsize: u64,
405 sh_link: u32,
406 sh_info: u32,
407 ) -> Vec<u8> {
408 let mut b = vec![0u8; 64];
409 b[0..4].copy_from_slice(&sh_name.to_le_bytes());
414 b[4..8].copy_from_slice(&sh_type.to_le_bytes());
415 b[24..32].copy_from_slice(&sh_offset.to_le_bytes());
416 b[32..40].copy_from_slice(&sh_size.to_le_bytes());
417 b[40..44].copy_from_slice(&sh_link.to_le_bytes());
418 b[44..48].copy_from_slice(&sh_info.to_le_bytes());
419 b[48..56].copy_from_slice(&sh_addralign.to_le_bytes());
420 b[56..64].copy_from_slice(&sh_entsize.to_le_bytes());
421 b
422 }
423
424 #[test]
427 fn analyse_empty_bytes_returns_none() {
428 assert!(analyse_elf_capabilities(b"", "test").is_none());
429 }
430
431 #[test]
432 fn analyse_non_elf_bytes_returns_none() {
433 assert!(analyse_elf_capabilities(b"not an elf binary", "test").is_none());
434 }
435
436 #[test]
437 fn analyse_elf_without_hook_symbols_returns_empty_signals() {
438 let elf = minimal_elf();
439 if let Some(report) = analyse_elf_capabilities(&elf, "minimal") {
440 assert!(report.signals.is_empty());
441 assert!(report.matched_hooks.is_empty());
442 }
443 }
444
445 #[test]
446 fn analyse_elf_with_readdir64_import_emits_process_hiding_signal() {
447 use forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING;
448 let elf = elf_with_dynamic_import("readdir64");
449 let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
450 assert!(report.signals.contains(&ELF_HOOKS_PROCESS_HIDING));
451 }
452
453 #[test]
454 fn analyse_elf_with_pam_get_item_import_emits_pam_credential_signal() {
455 use forensicnomicon::threat_intel::signals::ELF_HOOKS_PAM_CREDENTIAL;
456 let elf = elf_with_dynamic_import("pam_get_item");
457 let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
458 assert!(report.signals.contains(&ELF_HOOKS_PAM_CREDENTIAL));
459 }
460
461 #[test]
462 fn analyse_elf_with_readdir64_export_emits_libc_shadow_signal() {
463 use forensicnomicon::threat_intel::signals::ELF_LIBC_SHADOW_EXPORTS;
464 let elf = elf_with_dynamic_import("readdir64");
470 let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
471 assert!(
473 report
474 .signals
475 .contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING)
476 || report.signals.contains(&ELF_LIBC_SHADOW_EXPORTS)
477 );
478 }
479
480 #[test]
481 fn analyse_elf_multiple_hooks_deduplicates_signals() {
482 let elf = elf_with_dynamic_import("readdir64");
484 let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
485 let count = report
486 .signals
487 .iter()
488 .filter(|&&s| s == forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING)
489 .count();
490 assert!(count <= 1, "duplicate signal IDs must be deduplicated");
491 }
492
493 #[test]
494 fn analyse_elf_multiple_hooks_deduplicates_mitre_techniques() {
495 let elf = elf_with_dynamic_import("readdir64");
496 let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
497 let t1014_count = report
498 .mitre_techniques
499 .iter()
500 .filter(|&&t| t == "T1014")
501 .count();
502 assert!(
503 t1014_count <= 1,
504 "duplicate MITRE techniques must be deduplicated"
505 );
506 }
507
508 #[test]
509 fn analyse_elf_process_hiding_and_pam_both_in_signals() {
510 let elf_ph = elf_with_dynamic_import("readdir64");
513 let report_ph = analyse_elf_capabilities(&elf_ph, "test").expect("valid elf");
514 assert!(report_ph
515 .signals
516 .contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING));
517
518 let elf_pam = elf_with_dynamic_import("pam_get_item");
519 let report_pam = analyse_elf_capabilities(&elf_pam, "test").expect("valid elf");
520 assert!(report_pam
521 .signals
522 .contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PAM_CREDENTIAL));
523 }
524
525 #[test]
526 fn analyse_elf_signals_are_valid_forensicnomicon_signal_ids() {
527 let elf = elf_with_dynamic_import("readdir64");
529 if let Some(report) = analyse_elf_capabilities(&elf, "test") {
530 for sig in &report.signals {
531 assert!(!sig.is_empty(), "signal ID must not be empty");
532 assert!(
533 sig.contains('.'),
534 "signal ID '{sig}' must be dot-namespaced"
535 );
536 }
537 }
538 }
539
540 #[test]
543 fn scan_elf_strings_non_elf_returns_none() {
544 assert!(scan_elf_string_artifacts(b"not an elf").is_none());
545 }
546
547 #[test]
548 fn scan_elf_strings_elf_without_patterns_returns_empty_vec() {
549 let elf = minimal_elf();
550 if let Some(results) = scan_elf_string_artifacts(&elf) {
551 assert!(results.is_empty());
552 }
553 }
554
555 #[test]
556 fn scan_elf_strings_detects_password_format_fragment() {
557 let elf = elf_with_section_data(b"UID:%d:");
559 let results = scan_elf_string_artifacts(&elf).expect("valid elf");
560 assert!(
561 results.iter().any(|r| r.matched_pattern == "UID:%d:"),
562 "Father UID format string must be detected"
563 );
564 }
565
566 #[test]
567 fn scan_elf_strings_detects_silly_txt_reference() {
568 let elf = elf_with_section_data(b"silly.txt");
569 let results = scan_elf_string_artifacts(&elf).expect("valid elf");
570 assert!(results.iter().any(|r| r.matched_pattern == "silly.txt"));
571 }
572
573 #[test]
574 fn scan_elf_strings_context_window_is_bounded() {
575 let elf = elf_with_section_data(b"UID:%d:");
576 if let Some(results) = scan_elf_string_artifacts(&elf) {
577 for r in &results {
578 assert!(r.context.len() <= 80 + 40, "context must be bounded");
579 }
580 }
581 }
582
583 #[test]
584 fn scan_elf_strings_multiple_patterns_all_returned() {
585 let data = b"UID:%d: silly.txt".to_vec();
586 let elf = elf_with_section_data(&data);
587 let results = scan_elf_string_artifacts(&elf).expect("valid elf");
588 let patterns: Vec<&str> = results.iter().map(|r| r.matched_pattern).collect();
589 assert!(
590 patterns.contains(&"UID:%d:"),
591 "UID format string must be found"
592 );
593 assert!(patterns.contains(&"silly.txt"), "silly.txt must be found");
594 }
595
596 #[test]
597 fn scan_elf_strings_stripped_binary_still_matches_rodata() {
598 let elf = elf_with_section_data(b"UID:%d:");
600 let results = scan_elf_string_artifacts(&elf).expect("valid elf");
601 assert!(
602 !results.is_empty(),
603 "pattern must be found even in stripped-style binary"
604 );
605 }
606
607 fn elf_with_section_data(data: &[u8]) -> Vec<u8> {
609 let data_offset: u64 = 64;
611 let data_size = data.len();
612
613 let shstrtab_offset_raw = data_offset as usize + data_size;
614 let shstrtab_offset = (shstrtab_offset_raw + 7) & !7;
615 let shstrtab_pad = shstrtab_offset - shstrtab_offset_raw;
616
617 let mut shstrtab = vec![0u8];
618 let idx_rodata = shstrtab.len() as u32;
619 shstrtab.extend_from_slice(b".rodata\0");
620 let idx_shstrtab = shstrtab.len() as u32;
621 shstrtab.extend_from_slice(b".shstrtab\0");
622 let shstrtab_size = shstrtab.len();
623
624 let shoff_raw = shstrtab_offset + shstrtab_size;
625 let shoff = (shoff_raw + 7) & !7;
626 let shoff_pad = shoff - shoff_raw;
627
628 let mut shdrs = Vec::new();
629 shdrs.extend_from_slice(&[0u8; 64]); shdrs.extend_from_slice(&shdr64(
632 idx_rodata,
633 1,
634 data_offset,
635 data_size as u64,
636 1,
637 0,
638 0,
639 0,
640 ));
641 shdrs.extend_from_slice(&shdr64(
643 idx_shstrtab,
644 3,
645 shstrtab_offset as u64,
646 shstrtab_size as u64,
647 1,
648 0,
649 0,
650 0,
651 ));
652
653 let mut hdr = vec![0u8; 64];
654 hdr[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
655 hdr[4] = 2;
656 hdr[5] = 1;
657 hdr[6] = 1;
658 hdr[16] = 3;
659 hdr[17] = 0; hdr[18] = 62;
661 hdr[19] = 0; hdr[20] = 1;
663 hdr[40..48].copy_from_slice(&(shoff as u64).to_le_bytes());
664 hdr[52..54].copy_from_slice(&64u16.to_le_bytes()); hdr[54..56].copy_from_slice(&56u16.to_le_bytes()); hdr[58..60].copy_from_slice(&64u16.to_le_bytes()); hdr[60..62].copy_from_slice(&3u16.to_le_bytes()); hdr[62..64].copy_from_slice(&2u16.to_le_bytes()); let mut out = hdr;
671 out.extend_from_slice(data);
672 out.extend_from_slice(&vec![0u8; shstrtab_pad]);
673 out.extend_from_slice(&shstrtab);
674 out.extend_from_slice(&vec![0u8; shoff_pad]);
675 out.extend_from_slice(&shdrs);
676 out
677 }
678}