Skip to main content

memf_linux/
elf_analysis.rs

1//! ELF dynamic symbol analysis for LD_PRELOAD rootkit detection.
2
3use 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
8/// Capability report for a single ELF binary.
9pub struct ElfCapabilityReport {
10    /// Path or identifier of the ELF binary analysed.
11    pub source: String,
12    /// Imported/exported hook symbols matched against the hook table.
13    pub matched_hooks: Vec<HookMatch>,
14    /// Symbols this library exports that shadow libc functions (by name).
15    pub libc_shadow_exports: Vec<String>,
16    /// Deduplicated signal IDs emitted by this ELF.
17    pub signals: Vec<&'static str>,
18    /// Deduplicated MITRE technique IDs implied by `signals`.
19    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/// A single hook symbol match.
44#[derive(Debug, Clone)]
45pub struct HookMatch {
46    /// Dynamic symbol name.
47    pub symbol_name: String,
48    /// Signal ID this match contributes.
49    pub signal_id: &'static str,
50    /// MITRE ATT&CK technique ID.
51    pub mitre_technique: &'static str,
52}
53
54/// String artifact matched from ELF section data.
55#[derive(Debug, Clone)]
56pub struct ElfStringArtifact {
57    /// Literal pattern that matched.
58    pub matched_pattern: &'static str,
59    /// Human-readable description of what the pattern indicates.
60    pub description: &'static str,
61    /// Suspicion weight (higher = more suspicious).
62    pub weight: u32,
63    /// Up to 80 chars of context around the match.
64    pub context: String,
65}
66
67/// Analyse ELF bytes and return a capability report.
68///
69/// Returns `None` if bytes are not a valid ELF.
70/// Returns `Some(report)` with empty `signals` if valid ELF but no hook matches.
71pub 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
134/// Extract printable-string artifact matches from ELF `.rodata` and related sections.
135///
136/// Returns `None` if bytes are not a valid ELF object.
137/// Returns `Some(vec![])` if valid ELF but no Father-class patterns found.
138pub 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    // ── helpers ──────────────────────────────────────────────────────────────
188
189    /// Build a minimal valid ELF64 LE shared library with no dynamic symbols.
190    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; // class 64
194        e[5] = 1; // LE
195        e[6] = 1; // ELF version
196        e[7] = 0; // OS/ABI
197        e[16] = 3;
198        e[17] = 0; // e_type = ET_DYN
199        e[18] = 62;
200        e[19] = 0; // e_machine = EM_X86_64
201        e[20] = 1; // e_version
202        e
203    }
204
205    /// Build a minimal ELF64 shared object with one dynamic import named `sym_name`.
206    ///
207    /// Goblin 0.9 uses `vm_to_offset(phdrs, d_val)` to convert DT_STRTAB/DT_SYMTAB/DT_HASH
208    /// virtual addresses to file offsets, so both a PT_LOAD and a PT_DYNAMIC segment are
209    /// required. We map vaddr=0 → file offset 0 (identity mapping) so VA == file offset.
210    fn elf_with_dynamic_import(sym_name: &str) -> Vec<u8> {
211        // Layout (with 2 program headers: PT_LOAD + PT_DYNAMIC):
212        //   0x00: ELF header (64)
213        //   0x40: PT_LOAD  (56) → identity-maps whole file
214        //   0x78: PT_DYNAMIC (56) → points to .dynamic section
215        //   0xB0: .hash (20 bytes)   ← vaddr = file offset (identity mapping)
216        //   0xC4: pad 4 → 0xC8
217        //   0xC8: .dynstr
218        //   after dynstr (aligned): .dynsym
219        //   after dynsym (aligned): .dynamic
220        //   after dynamic: .shstrtab, section headers
221
222        const HASH_OFFSET: u64 = 0xB0; // vaddr == file offset via PT_LOAD identity map
223        const DYNSTR_OFFSET: u64 = 0xC8;
224
225        // .dynstr: \0sym_name\0
226        let mut dynstr = vec![0u8];
227        let sym_name_idx = dynstr.len() as u32; // = 1
228        dynstr.extend_from_slice(sym_name.as_bytes());
229        dynstr.push(0);
230        let dynstr_size = dynstr.len() as u64;
231
232        // .dynsym: 2 × Sym64 (24 bytes each), aligned to 8
233        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]; // sym[0] = null
238        dynsym_bytes.extend_from_slice(&sym_name_idx.to_le_bytes()); // st_name
239        dynsym_bytes.push(0x12); // st_info = STB_GLOBAL|STT_FUNC
240        dynsym_bytes.push(0); // st_other
241        dynsym_bytes.extend_from_slice(&0u16.to_le_bytes()); // st_shndx = SHN_UNDEF (import)
242        dynsym_bytes.extend_from_slice(&[0u8; 16]); // st_value, st_size
243        let dynsym_size = dynsym_bytes.len() as u64;
244
245        // .dynamic: 6 × Elf64_Dyn (d_tag:u64 + d_val:u64 = 16 bytes), aligned to 8
246        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        // Virtual addresses == file offsets because PT_LOAD maps vaddr=0 → offset=0
256        push_dyn(4, HASH_OFFSET, &mut dyn_bytes); // DT_HASH (vaddr = file offset)
257        push_dyn(5, DYNSTR_OFFSET, &mut dyn_bytes); // DT_STRTAB
258        push_dyn(10, dynstr_size, &mut dyn_bytes); // DT_STRSZ
259        push_dyn(6, dynsym_offset, &mut dyn_bytes); // DT_SYMTAB
260        push_dyn(11, 24, &mut dyn_bytes); // DT_SYMENT
261        push_dyn(0, 0, &mut dyn_bytes); // DT_NULL
262        let dynamic_size = dyn_bytes.len() as u64;
263
264        // .shstrtab
265        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        // Section headers (6 entries × 64 bytes)
283        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]); // [0] null
290        shdrs.extend_from_slice(&shdr64(idx_hash, 5, HASH_OFFSET, 20, 4, 4, 0, 0)); // .hash SHT_HASH
291        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        )); // .dynstr
301        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        )); // .dynsym, link→.dynstr[2]
311        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        )); // .dynamic
321        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        )); // .shstrtab
331
332        // .hash: [nbuckets=1, nchain=2, bucket[0]=1, chain[0]=0, chain[1]=0]
333        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        // PT_LOAD (56 bytes) — identity map: vaddr=0, offset=0, covers whole file
339        let mut phdr_load = vec![0u8; 56];
340        phdr_load[0..4].copy_from_slice(&1u32.to_le_bytes()); // PT_LOAD
341        phdr_load[4..8].copy_from_slice(&5u32.to_le_bytes()); // PF_R|PF_X
342                                                              // p_offset=0, p_vaddr=0, p_paddr=0 (all zero)
343        phdr_load[32..40].copy_from_slice(&total_size.to_le_bytes()); // p_filesz
344        phdr_load[40..48].copy_from_slice(&total_size.to_le_bytes()); // p_memsz
345        phdr_load[48..56].copy_from_slice(&0x1000u64.to_le_bytes()); // p_align
346
347        // PT_DYNAMIC (56 bytes)
348        let mut phdr_dyn = vec![0u8; 56];
349        phdr_dyn[0..4].copy_from_slice(&2u32.to_le_bytes()); // PT_DYNAMIC
350        phdr_dyn[4..8].copy_from_slice(&6u32.to_le_bytes()); // PF_R|PF_W
351        phdr_dyn[8..16].copy_from_slice(&dynamic_offset.to_le_bytes()); // p_offset
352        phdr_dyn[16..24].copy_from_slice(&dynamic_offset.to_le_bytes()); // p_vaddr
353        phdr_dyn[24..32].copy_from_slice(&dynamic_offset.to_le_bytes()); // p_paddr
354        phdr_dyn[32..40].copy_from_slice(&dynamic_size.to_le_bytes()); // p_filesz
355        phdr_dyn[40..48].copy_from_slice(&dynamic_size.to_le_bytes()); // p_memsz
356        phdr_dyn[48..56].copy_from_slice(&8u64.to_le_bytes()); // p_align
357
358        // ELF header (e_phoff=64, e_phnum=2, e_shstrndx=5)
359        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; // class64, LE, ELF version
364        hdr[16] = 3;
365        hdr[17] = 0; // ET_DYN
366        hdr[18] = 62;
367        hdr[19] = 0; // EM_X86_64
368        hdr[20] = 1; // e_version
369        hdr[32..40].copy_from_slice(&64u64.to_le_bytes()); // e_phoff = 64
370        hdr[40..48].copy_from_slice(&shoff.to_le_bytes()); // e_shoff
371        hdr[52..54].copy_from_slice(&64u16.to_le_bytes()); // e_ehsize
372        hdr[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize
373        hdr[56..58].copy_from_slice(&2u16.to_le_bytes()); // e_phnum = 2
374        hdr[58..60].copy_from_slice(&64u16.to_le_bytes()); // e_shentsize
375        hdr[60..62].copy_from_slice(&6u16.to_le_bytes()); // e_shnum = 6
376        hdr[62..64].copy_from_slice(&5u16.to_le_bytes()); // e_shstrndx = 5
377
378        // Assemble
379        let mut out = hdr; // [0x00..0x40)
380        out.extend_from_slice(&phdr_load); // [0x40..0x78)
381        out.extend_from_slice(&phdr_dyn); // [0x78..0xB0)
382        out.extend_from_slice(&hash_bytes); // [0xB0..0xC4)
383        out.extend_from_slice(&[0u8; 4]); // pad [0xC4..0xC8)
384        out.extend_from_slice(&dynstr); // [0xC8..)
385        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    /// Build a SHT64 section header entry (64 bytes).
397    #[allow(clippy::too_many_arguments)] // mirrors the 8-field ELF64 Shdr layout
398    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        // ELF64 Shdr layout:
410        // 0: sh_name(4), 4: sh_type(4), 8: sh_flags(8), 16: sh_addr(8),
411        // 24: sh_offset(8), 32: sh_size(8), 40: sh_link(4), 44: sh_info(4),
412        // 48: sh_addralign(8), 56: sh_entsize(8)
413        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    // ── analyse_elf_capabilities tests ───────────────────────────────────────
425
426    #[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        // To test export detection, we need a symbol with shndx != SHN_UNDEF.
465        // Our helper builds imports (shndx=0). For the export case, we verify
466        // the libc_shadow_exports field is populated when shndx != 0.
467        // The signal test is covered by integration; here we verify the import path
468        // does emit the process-hiding signal as a minimum.
469        let elf = elf_with_dynamic_import("readdir64");
470        let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
471        // Import of a hook symbol → process_hiding signal OR libc_shadow_exports signal
472        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        // readdir64 alone emits ELF_HOOKS_PROCESS_HIDING once
483        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        // Each elf_with_dynamic_import creates one symbol; testing both signals
511        // together requires confirming each individual signal appears independently.
512        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        // Signal IDs from forensicnomicon use dot-separated namespaces (e.g. "elf.hooks.*")
528        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    // ── scan_elf_string_artifacts tests ──────────────────────────────────────
541
542    #[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        // Embed the Father format string in a SHT_PROGBITS section so goblin sees it.
558        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        // Stripping removes symbol table but leaves section data — patterns still fire
599        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    /// Helper: build a minimal ELF with one SHT_PROGBITS section containing `data`.
608    fn elf_with_section_data(data: &[u8]) -> Vec<u8> {
609        // Layout: hdr(64) | data_section | .shstrtab | section headers
610        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]); // null
630                                             // .rodata: SHT_PROGBITS=1
631        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        // .shstrtab: SHT_STRTAB=3
642        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; // ET_DYN
660        hdr[18] = 62;
661        hdr[19] = 0; // EM_X86_64
662        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()); // e_ehsize
665        hdr[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize
666        hdr[58..60].copy_from_slice(&64u16.to_le_bytes()); // e_shentsize
667        hdr[60..62].copy_from_slice(&3u16.to_le_bytes()); // e_shnum = 3
668        hdr[62..64].copy_from_slice(&2u16.to_le_bytes()); // e_shstrndx = 2
669
670        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}