use forensicnomicon::heuristics::linux_rootkit::FATHER_CLASS_ELF_PATTERNS;
use forensicnomicon::heuristics::linux_rootkit::ROOTKIT_HOOK_SYMBOLS;
use forensicnomicon::threat_intel::signals as S;
use goblin::elf::Elf;
pub struct ElfCapabilityReport {
pub source: String,
pub matched_hooks: Vec<HookMatch>,
pub libc_shadow_exports: Vec<String>,
pub signals: Vec<&'static str>,
pub mitre_techniques: Vec<&'static str>,
}
impl std::fmt::Debug for ElfCapabilityReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ElfCapabilityReport")
.field("source", &self.source)
.field("signals", &self.signals)
.finish_non_exhaustive()
}
}
impl Clone for ElfCapabilityReport {
fn clone(&self) -> Self {
Self {
source: self.source.clone(),
matched_hooks: self.matched_hooks.clone(),
libc_shadow_exports: self.libc_shadow_exports.clone(),
signals: self.signals.clone(),
mitre_techniques: self.mitre_techniques.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct HookMatch {
pub symbol_name: String,
pub signal_id: &'static str,
pub mitre_technique: &'static str,
}
#[derive(Debug, Clone)]
pub struct ElfStringArtifact {
pub matched_pattern: &'static str,
pub description: &'static str,
pub weight: u32,
pub context: String,
}
pub fn analyse_elf_capabilities(
bytes: &[u8],
source: impl Into<String>,
) -> Option<ElfCapabilityReport> {
let elf = Elf::parse(bytes).ok()?;
let mut matched_hooks = Vec::new();
let mut libc_shadow_exports = Vec::new();
let hook_names: std::collections::HashSet<&str> =
ROOTKIT_HOOK_SYMBOLS.iter().map(|s| s.name).collect();
for sym in &elf.dynsyms {
if sym.st_name == 0 {
continue;
}
let name = match elf.dynstrtab.get_at(sym.st_name) {
Some(n) => n,
None => continue,
};
if let Some(hook) = ROOTKIT_HOOK_SYMBOLS.iter().find(|s| s.name == name) {
matched_hooks.push(HookMatch {
symbol_name: name.to_string(),
signal_id: hook.emits_signal,
mitre_technique: hook.mitre_technique,
});
}
if !sym.is_import() && hook_names.contains(name) {
libc_shadow_exports.push(name.to_string());
}
}
let mut seen_sig = std::collections::HashSet::new();
let mut signals: Vec<&'static str> = matched_hooks
.iter()
.filter_map(|h| seen_sig.insert(h.signal_id).then_some(h.signal_id))
.collect();
if !libc_shadow_exports.is_empty() && seen_sig.insert(S::ELF_LIBC_SHADOW_EXPORTS) {
signals.push(S::ELF_LIBC_SHADOW_EXPORTS);
}
let mut seen_tt = std::collections::HashSet::new();
let mitre_techniques: Vec<&'static str> = matched_hooks
.iter()
.filter_map(|h| {
seen_tt
.insert(h.mitre_technique)
.then_some(h.mitre_technique)
})
.collect();
Some(ElfCapabilityReport {
source: source.into(),
matched_hooks,
libc_shadow_exports,
signals,
mitre_techniques,
})
}
pub fn scan_elf_string_artifacts(bytes: &[u8]) -> Option<Vec<ElfStringArtifact>> {
let elf = Elf::parse(bytes).ok()?;
let mut results = Vec::new();
for section in &elf.section_headers {
let name = elf.shdr_strtab.get_at(section.sh_name).unwrap_or("");
let is_string_section = matches!(
name,
".rodata" | ".rodata.str1.1" | ".rodata.str1.8" | ".data.rel.ro"
) || section.sh_type == goblin::elf::section_header::SHT_PROGBITS;
if !is_string_section {
continue;
}
let start = section.sh_offset as usize;
let end = start.saturating_add(section.sh_size as usize);
let section_bytes = bytes.get(start..end).unwrap_or(&[]);
let section_str = String::from_utf8_lossy(section_bytes);
for pattern_def in FATHER_CLASS_ELF_PATTERNS {
if let Some(pos) = section_str.find(pattern_def.pattern) {
let ctx_start = pos.saturating_sub(20);
let ctx_end = (pos + pattern_def.pattern.len() + 20).min(section_str.len());
let context: String = section_str[ctx_start..ctx_end]
.chars()
.map(|c| {
if c.is_ascii_graphic() || c == ' ' {
c
} else {
'.'
}
})
.collect();
results.push(ElfStringArtifact {
matched_pattern: pattern_def.pattern,
description: pattern_def.description,
weight: pattern_def.weight,
context,
});
}
}
}
Some(results)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_elf() -> Vec<u8> {
let mut e = vec![0u8; 64];
e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
e[4] = 2; e[5] = 1; e[6] = 1; e[7] = 0; e[16] = 3;
e[17] = 0; e[18] = 62;
e[19] = 0; e[20] = 1; e
}
fn elf_with_dynamic_import(sym_name: &str) -> Vec<u8> {
const HASH_OFFSET: u64 = 0xB0; const DYNSTR_OFFSET: u64 = 0xC8;
let mut dynstr = vec![0u8];
let sym_name_idx = dynstr.len() as u32; dynstr.extend_from_slice(sym_name.as_bytes());
dynstr.push(0);
let dynstr_size = dynstr.len() as u64;
let dynsym_offset_raw = DYNSTR_OFFSET as usize + dynstr_size as usize;
let dynsym_offset = ((dynsym_offset_raw + 7) & !7) as u64;
let dynstr_pad = dynsym_offset as usize - dynsym_offset_raw;
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;
let dynamic_offset_raw = dynsym_offset as usize + dynsym_size as usize;
let dynamic_offset = ((dynamic_offset_raw + 7) & !7) as u64;
let dynsym_pad = dynamic_offset as usize - dynamic_offset_raw;
let mut dyn_bytes = Vec::new();
let push_dyn = |tag: u64, val: u64, buf: &mut Vec<u8>| {
buf.extend_from_slice(&tag.to_le_bytes());
buf.extend_from_slice(&val.to_le_bytes());
};
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;
let shstrtab_offset_raw = dynamic_offset as usize + dynamic_size as usize;
let shstrtab_offset = ((shstrtab_offset_raw + 7) & !7) as u64;
let dynamic_pad = shstrtab_offset as usize - shstrtab_offset_raw;
let mut shstrtab = vec![0u8];
let idx_hash = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".hash\0");
let idx_dynstr = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".dynstr\0");
let idx_dynsym = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".dynsym\0");
let idx_dynamic = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".dynamic\0");
let idx_shstrtab = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".shstrtab\0");
let shstrtab_size = shstrtab.len() as u64;
let shoff_raw = shstrtab_offset as usize + shstrtab_size as usize;
let shoff = ((shoff_raw + 7) & !7) as u64;
let shstrtab_pad = shoff as usize - shoff_raw;
let total_size = shoff + 6 * 64;
let mut shdrs = Vec::new();
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(
idx_dynstr,
3,
DYNSTR_OFFSET,
dynstr_size,
1,
0,
0,
0,
)); shdrs.extend_from_slice(&shdr64(
idx_dynsym,
11,
dynsym_offset,
dynsym_size,
8,
24,
2,
1,
)); shdrs.extend_from_slice(&shdr64(
idx_dynamic,
6,
dynamic_offset,
dynamic_size,
8,
16,
2,
0,
)); shdrs.extend_from_slice(&shdr64(
idx_shstrtab,
3,
shstrtab_offset,
shstrtab_size,
1,
0,
0,
0,
));
let mut hash_bytes = Vec::new();
for v in [1u32, 2, 1, 0, 0] {
hash_bytes.extend_from_slice(&v.to_le_bytes());
}
let mut phdr_load = vec![0u8; 56];
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];
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];
hdr[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
hdr[4] = 2;
hdr[5] = 1;
hdr[6] = 1; hdr[16] = 3;
hdr[17] = 0; hdr[18] = 62;
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]);
out.extend_from_slice(&dynsym_bytes);
out.extend_from_slice(&vec![0u8; dynsym_pad]);
out.extend_from_slice(&dyn_bytes);
out.extend_from_slice(&vec![0u8; dynamic_pad]);
out.extend_from_slice(&shstrtab);
out.extend_from_slice(&vec![0u8; shstrtab_pad]);
out.extend_from_slice(&shdrs);
out
}
#[allow(clippy::too_many_arguments)] fn shdr64(
sh_name: u32,
sh_type: u32,
sh_offset: u64,
sh_size: u64,
sh_addralign: u64,
sh_entsize: u64,
sh_link: u32,
sh_info: u32,
) -> Vec<u8> {
let mut b = vec![0u8; 64];
b[0..4].copy_from_slice(&sh_name.to_le_bytes());
b[4..8].copy_from_slice(&sh_type.to_le_bytes());
b[24..32].copy_from_slice(&sh_offset.to_le_bytes());
b[32..40].copy_from_slice(&sh_size.to_le_bytes());
b[40..44].copy_from_slice(&sh_link.to_le_bytes());
b[44..48].copy_from_slice(&sh_info.to_le_bytes());
b[48..56].copy_from_slice(&sh_addralign.to_le_bytes());
b[56..64].copy_from_slice(&sh_entsize.to_le_bytes());
b
}
#[test]
fn analyse_empty_bytes_returns_none() {
assert!(analyse_elf_capabilities(b"", "test").is_none());
}
#[test]
fn analyse_non_elf_bytes_returns_none() {
assert!(analyse_elf_capabilities(b"not an elf binary", "test").is_none());
}
#[test]
fn analyse_elf_without_hook_symbols_returns_empty_signals() {
let elf = minimal_elf();
if let Some(report) = analyse_elf_capabilities(&elf, "minimal") {
assert!(report.signals.is_empty());
assert!(report.matched_hooks.is_empty());
}
}
#[test]
fn analyse_elf_with_readdir64_import_emits_process_hiding_signal() {
use forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING;
let elf = elf_with_dynamic_import("readdir64");
let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
assert!(report.signals.contains(&ELF_HOOKS_PROCESS_HIDING));
}
#[test]
fn analyse_elf_with_pam_get_item_import_emits_pam_credential_signal() {
use forensicnomicon::threat_intel::signals::ELF_HOOKS_PAM_CREDENTIAL;
let elf = elf_with_dynamic_import("pam_get_item");
let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
assert!(report.signals.contains(&ELF_HOOKS_PAM_CREDENTIAL));
}
#[test]
fn analyse_elf_with_readdir64_export_emits_libc_shadow_signal() {
use forensicnomicon::threat_intel::signals::ELF_LIBC_SHADOW_EXPORTS;
let elf = elf_with_dynamic_import("readdir64");
let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
assert!(
report
.signals
.contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING)
|| report.signals.contains(&ELF_LIBC_SHADOW_EXPORTS)
);
}
#[test]
fn analyse_elf_multiple_hooks_deduplicates_signals() {
let elf = elf_with_dynamic_import("readdir64");
let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
let count = report
.signals
.iter()
.filter(|&&s| s == forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING)
.count();
assert!(count <= 1, "duplicate signal IDs must be deduplicated");
}
#[test]
fn analyse_elf_multiple_hooks_deduplicates_mitre_techniques() {
let elf = elf_with_dynamic_import("readdir64");
let report = analyse_elf_capabilities(&elf, "test").expect("valid elf");
let t1014_count = report
.mitre_techniques
.iter()
.filter(|&&t| t == "T1014")
.count();
assert!(
t1014_count <= 1,
"duplicate MITRE techniques must be deduplicated"
);
}
#[test]
fn analyse_elf_process_hiding_and_pam_both_in_signals() {
let elf_ph = elf_with_dynamic_import("readdir64");
let report_ph = analyse_elf_capabilities(&elf_ph, "test").expect("valid elf");
assert!(report_ph
.signals
.contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PROCESS_HIDING));
let elf_pam = elf_with_dynamic_import("pam_get_item");
let report_pam = analyse_elf_capabilities(&elf_pam, "test").expect("valid elf");
assert!(report_pam
.signals
.contains(&forensicnomicon::threat_intel::signals::ELF_HOOKS_PAM_CREDENTIAL));
}
#[test]
fn analyse_elf_signals_are_valid_forensicnomicon_signal_ids() {
let elf = elf_with_dynamic_import("readdir64");
if let Some(report) = analyse_elf_capabilities(&elf, "test") {
for sig in &report.signals {
assert!(!sig.is_empty(), "signal ID must not be empty");
assert!(
sig.contains('.'),
"signal ID '{sig}' must be dot-namespaced"
);
}
}
}
#[test]
fn scan_elf_strings_non_elf_returns_none() {
assert!(scan_elf_string_artifacts(b"not an elf").is_none());
}
#[test]
fn scan_elf_strings_elf_without_patterns_returns_empty_vec() {
let elf = minimal_elf();
if let Some(results) = scan_elf_string_artifacts(&elf) {
assert!(results.is_empty());
}
}
#[test]
fn scan_elf_strings_detects_password_format_fragment() {
let elf = elf_with_section_data(b"UID:%d:");
let results = scan_elf_string_artifacts(&elf).expect("valid elf");
assert!(
results.iter().any(|r| r.matched_pattern == "UID:%d:"),
"Father UID format string must be detected"
);
}
#[test]
fn scan_elf_strings_detects_silly_txt_reference() {
let elf = elf_with_section_data(b"silly.txt");
let results = scan_elf_string_artifacts(&elf).expect("valid elf");
assert!(results.iter().any(|r| r.matched_pattern == "silly.txt"));
}
#[test]
fn scan_elf_strings_context_window_is_bounded() {
let elf = elf_with_section_data(b"UID:%d:");
if let Some(results) = scan_elf_string_artifacts(&elf) {
for r in &results {
assert!(r.context.len() <= 80 + 40, "context must be bounded");
}
}
}
#[test]
fn scan_elf_strings_multiple_patterns_all_returned() {
let data = b"UID:%d: silly.txt".to_vec();
let elf = elf_with_section_data(&data);
let results = scan_elf_string_artifacts(&elf).expect("valid elf");
let patterns: Vec<&str> = results.iter().map(|r| r.matched_pattern).collect();
assert!(
patterns.contains(&"UID:%d:"),
"UID format string must be found"
);
assert!(patterns.contains(&"silly.txt"), "silly.txt must be found");
}
#[test]
fn scan_elf_strings_stripped_binary_still_matches_rodata() {
let elf = elf_with_section_data(b"UID:%d:");
let results = scan_elf_string_artifacts(&elf).expect("valid elf");
assert!(
!results.is_empty(),
"pattern must be found even in stripped-style binary"
);
}
fn elf_with_section_data(data: &[u8]) -> Vec<u8> {
let data_offset: u64 = 64;
let data_size = data.len();
let shstrtab_offset_raw = data_offset as usize + data_size;
let shstrtab_offset = (shstrtab_offset_raw + 7) & !7;
let shstrtab_pad = shstrtab_offset - shstrtab_offset_raw;
let mut shstrtab = vec![0u8];
let idx_rodata = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".rodata\0");
let idx_shstrtab = shstrtab.len() as u32;
shstrtab.extend_from_slice(b".shstrtab\0");
let shstrtab_size = shstrtab.len();
let shoff_raw = shstrtab_offset + shstrtab_size;
let shoff = (shoff_raw + 7) & !7;
let shoff_pad = shoff - shoff_raw;
let mut shdrs = Vec::new();
shdrs.extend_from_slice(&[0u8; 64]); shdrs.extend_from_slice(&shdr64(
idx_rodata,
1,
data_offset,
data_size as u64,
1,
0,
0,
0,
));
shdrs.extend_from_slice(&shdr64(
idx_shstrtab,
3,
shstrtab_offset as u64,
shstrtab_size as u64,
1,
0,
0,
0,
));
let mut hdr = vec![0u8; 64];
hdr[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
hdr[4] = 2;
hdr[5] = 1;
hdr[6] = 1;
hdr[16] = 3;
hdr[17] = 0; hdr[18] = 62;
hdr[19] = 0; hdr[20] = 1;
hdr[40..48].copy_from_slice(&(shoff as u64).to_le_bytes());
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;
out.extend_from_slice(data);
out.extend_from_slice(&vec![0u8; shstrtab_pad]);
out.extend_from_slice(&shstrtab);
out.extend_from_slice(&vec![0u8; shoff_pad]);
out.extend_from_slice(&shdrs);
out
}
}