reknife 1.8.0

A reverse engineer's binary Swiss-army knife: parse, triage, and disassemble PE/ELF/Mach-O. Installs as `knife`.
Documentation
//! ELF → Binary.

use super::mk_section;
use crate::model::{Arch, Binary, Format, HardeningFacts, ImportedLib, SymKind, Symbol};
use goblin::elf::Elf;

const SHF_WRITE: u64 = 0x1;
const SHF_ALLOC: u64 = 0x2;
const SHF_EXECINSTR: u64 = 0x4;

const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
const PT_GNU_STACK: u32 = 0x6474_e551;
const PT_GNU_RELRO: u32 = 0x6474_e552;
const PF_X: u32 = 0x1;
const DF_BIND_NOW: u64 = 0x8;
const DF_1_NOW: u64 = 0x1;
const DF_1_PIE: u64 = 0x0800_0000;

pub fn build(path: &str, bytes: &[u8], elf: Elf) -> Binary {
    let arch = match elf.header.e_machine {
        3 => Arch::X86,
        62 => Arch::X86_64,
        40 => Arch::Arm,
        183 => Arch::Aarch64,
        8 => Arch::Mips,
        243 => Arch::Riscv,
        _ => Arch::Other,
    };

    let sections = elf
        .section_headers
        .iter()
        .filter(|sh| sh.sh_type != 0) // skip SHT_NULL
        .map(|sh| {
            let name = elf
                .shdr_strtab
                .get_at(sh.sh_name)
                .unwrap_or("(bad)")
                .to_string();
            let f = sh.sh_flags;
            let alloc = f & SHF_ALLOC != 0;
            mk_section(
                name,
                sh.sh_addr,
                sh.sh_size,
                sh.sh_offset,
                if sh.sh_type == 8 { 0 } else { sh.sh_size }, // SHT_NOBITS (.bss) has no file bytes
                alloc,
                f & SHF_WRITE != 0,
                f & SHF_EXECINSTR != 0,
                bytes,
            )
        })
        .collect();

    // Imports: undefined dynamic symbols. Exports: defined global dynamic symbols.
    let mut imports_fns: Vec<String> = Vec::new();
    let mut exports: Vec<String> = Vec::new();
    for sym in elf.dynsyms.iter() {
        let name = elf.dynstrtab.get_at(sym.st_name).unwrap_or("");
        if name.is_empty() {
            continue;
        }
        if sym.st_shndx == 0 {
            imports_fns.push(name.to_string());
        } else if sym.st_bind() == 1 && sym.st_type() != 4 {
            // STB_GLOBAL, not STT_FILE
            exports.push(name.to_string());
        }
    }
    imports_fns.sort();
    imports_fns.dedup();
    exports.sort();
    exports.dedup();

    // ELF has no per-DLL grouping; needed libraries are separate. Present the
    // undefined symbols under a synthetic "dynamic" bucket plus the DT_NEEDED list.
    let imports = if imports_fns.is_empty() {
        Vec::new()
    } else {
        vec![ImportedLib {
            name: "(dynamic symbols)".to_string(),
            functions: imports_fns,
            ordinals: Vec::new(),
        }]
    };

    // Defined function symbols (STT_FUNC, non-zero address) seed the engine.
    // syms use .strtab, dynsyms use .dynstrtab; keep them separate.
    let mut symbols: Vec<Symbol> = Vec::new();
    let mut collect_funcs = |it: goblin::elf::sym::SymIterator, strtab: &goblin::strtab::Strtab| {
        for sym in it {
            if sym.st_type() != 2 || sym.st_value == 0 || sym.st_shndx == 0 {
                continue; // STT_FUNC, defined, with an address
            }
            let name = strtab.get_at(sym.st_name).unwrap_or("");
            if !name.is_empty() {
                symbols.push(Symbol {
                    addr: sym.st_value,
                    name: name.to_string(),
                    kind: SymKind::Func,
                });
            }
        }
    };
    collect_funcs(elf.syms.iter(), &elf.strtab);
    collect_funcs(elf.dynsyms.iter(), &elf.dynstrtab);

    // Import slots. Every PLT/GOT relocation binds a slot address to a dynamic
    // symbol, and the `.plt` stub that jumps through that slot is what code
    // actually calls. Recording the slot is what later lets a `call` land on
    // `strcpy@plt` instead of an anonymous `sub_`. dynrelas/dynrels are
    // included because a full-RELRO build resolves everything eagerly and has
    // no lazy PLT relocations at all.
    for reloc in elf
        .pltrelocs
        .iter()
        .chain(elf.dynrelas.iter())
        .chain(elf.dynrels.iter())
    {
        if reloc.r_offset == 0 {
            continue;
        }
        let Some(sym) = elf.dynsyms.get(reloc.r_sym) else {
            continue;
        };
        let name = elf.dynstrtab.get_at(sym.st_name).unwrap_or("");
        if !name.is_empty() {
            symbols.push(Symbol {
                addr: reloc.r_offset,
                name: name.to_string(),
                kind: SymKind::Import,
            });
        }
    }

    let stripped = !elf.section_headers.iter().any(|sh| sh.sh_type == 2); // SHT_SYMTAB present == not stripped

    // Mitigation facts. PT_GNU_STACK is tri-state on purpose: present-and-
    // writable-only is the hardened case, present-and-executable is a real
    // finding, and absent is also a finding because the loader then falls back
    // to an executable stack.
    let gnu_stack_exec = elf
        .program_headers
        .iter()
        .find(|ph| ph.p_type == PT_GNU_STACK)
        .map(|ph| ph.p_flags & PF_X != 0);
    let gnu_relro = elf
        .program_headers
        .iter()
        .any(|ph| ph.p_type == PT_GNU_RELRO);
    // Full RELRO needs the GOT resolved eagerly at load; either spelling counts.
    let (dyn_flags, dyn_flags_1) = elf
        .dynamic
        .as_ref()
        .map(|d| (d.info.flags, d.info.flags_1))
        .unwrap_or((0, 0));
    let bind_now = dyn_flags & DF_BIND_NOW != 0
        || dyn_flags_1 & DF_1_NOW != 0
        || elf.dynamic.as_ref().is_some_and(|d| {
            d.dyns
                .iter()
                .any(|e| e.d_tag == goblin::elf::dynamic::DT_BIND_NOW)
        });
    let textrel = elf.dynamic.as_ref().is_some_and(|d| d.info.textrel);

    // A stack cookie shows up as a reference to the guard/fail helpers, and
    // FORTIFY_SOURCE as the `_chk` libc variants. Both are symbol-level facts,
    // so they survive stripping of local symbols.
    let sym_names = || {
        elf.dynsyms
            .iter()
            .filter_map(|s| elf.dynstrtab.get_at(s.st_name))
            .chain(elf.syms.iter().filter_map(|s| elf.strtab.get_at(s.st_name)))
    };
    let stack_chk = sym_names().any(|n| n.starts_with("__stack_chk"));
    let fortify_syms = {
        let mut v: Vec<&str> = sym_names().filter(|n| n.ends_with("_chk")).collect();
        v.sort_unstable();
        v.dedup();
        // `__stack_chk_fail` also ends in `_chk`-adjacent text; count only the
        // fortified wrappers so the number means what it claims.
        v.iter().filter(|n| !n.starts_with("__stack_chk")).count()
    };

    // ET_DYN alone does not mean PIE: shared libraries are ET_DYN too. A PIE
    // executable is the one that also carries an interpreter (or says DF_1_PIE).
    let is_pie_exe = elf.header.e_type == 3
        && (elf.interpreter.is_some() || dyn_flags_1 & DF_1_PIE != 0)
        && elf.entry != 0;

    // The .eh_frame_hdr search table lists function starts the same way the PE
    // exception directory does, recovering code reached only through indirect
    // calls. Computed here so it can also feed the note below.
    let func_hints = eh_frame_hdr_starts(&elf, bytes);

    let mut notes = Vec::new();
    notes.push(format!("ELF{}", if elf.is_64 { "64" } else { "32" }));
    if !func_hints.is_empty() {
        notes.push(format!("{} eh_frame functions", func_hints.len()));
    }
    notes.push(if elf.little_endian {
        "LE".into()
    } else {
        "BE".into()
    });
    match elf.header.e_type {
        1 => notes.push("relocatable".into()),
        2 => notes.push("executable".into()),
        // ET_DYN covers both PIE programs and shared libraries; an interpreter
        // is what tells them apart, and the distinction matters enough to a
        // reader that it is worth making here rather than lumping them.
        3 if is_pie_exe => notes.push("PIE executable".into()),
        3 => notes.push("shared object".into()),
        4 => notes.push("core dump".into()),
        _ => {}
    }
    if elf.interpreter.is_some() {
        notes.push("dynamically linked".into());
    } else if elf.header.e_type == 2 {
        notes.push("statically linked".into());
    }

    Binary {
        path: path.to_string(),
        size: bytes.len() as u64,
        format: Format::Elf,
        arch,
        bits: if elf.is_64 { 64 } else { 32 },
        endian_little: elf.little_endian,
        is_lib: elf.header.e_type == 3 && !is_pie_exe,
        is_stripped: stripped,
        entry: elf.entry,
        image_base: 0,
        subsystem: None,
        timestamp: None,
        sections,
        imports,
        exports,
        symbols,
        func_hints,
        libs: elf.libraries.iter().map(|s| s.to_string()).collect(),
        rpaths: elf
            .rpaths
            .iter()
            .chain(elf.runpaths.iter())
            .map(|s| s.to_string())
            .collect(),
        overall_entropy: 0.0,
        overlay_off: None,
        overlay_size: 0,
        overlay_entropy: 0.0,
        has_signature: false,
        sig_region: None,
        // ELF debug info is DWARF, not a PDB sidecar.
        pdb: crate::formats::pdbsym::Pdb::NotReferenced,
        hardening: HardeningFacts {
            gnu_stack_exec,
            gnu_relro,
            bind_now,
            textrel,
            has_interp: elf.interpreter.is_some(),
            elf_type: elf.header.e_type,
            stack_chk,
            fortify_syms,
            ..Default::default()
        },
        notes,
    }
}

/// Function-start addresses from the `.eh_frame_hdr` search table.
///
/// The ELF answer to the PE exception directory. `PT_GNU_EH_FRAME` points at a
/// header whose binary-search table pairs every FDE's `initial_location` (a
/// function start) with its unwind record, sorted by address. Modern PIE
/// executables and shared libraries all carry it, so it recovers the functions
/// a stripped C++ ELF only reaches through vtables, exactly as `.pdata` does on
/// Windows.
///
/// The table encoding is required to be the near-universal `datarel | sdata4`;
/// anything else returns empty rather than risk a misparse. Every read is
/// bounds-checked, because this parses attacker-controlled bytes.
fn eh_frame_hdr_starts(elf: &Elf, bytes: &[u8]) -> Vec<u64> {
    // DW_EH_PE: low nibble is the value format, high nibble the base.
    const DW_EH_PE_DATAREL_SDATA4: u8 = 0x3b;

    let Some(ph) = elf
        .program_headers
        .iter()
        .find(|p| p.p_type == PT_GNU_EH_FRAME)
    else {
        return Vec::new();
    };

    let base_off = ph.p_offset as usize;
    let hdr_vaddr = ph.p_vaddr;
    let rd = |o: usize, n: usize| bytes.get(o..o + n);

    // version(1), eh_frame_ptr_enc(1), fde_count_enc(1), table_enc(1)
    let Some(head) = rd(base_off, 4) else {
        return Vec::new();
    };
    if head[0] != 1 || head[3] != DW_EH_PE_DATAREL_SDATA4 {
        return Vec::new();
    }
    let (eh_frame_ptr_enc, fde_count_enc) = (head[1], head[2]);
    let mut cur = base_off + 4;

    // Skip the eh_frame pointer, then read the FDE count. Both use a
    // fixed-width encoding whose size comes from the low nibble.
    let Some(ptr_sz) = enc_width(eh_frame_ptr_enc) else {
        return Vec::new();
    };
    cur += ptr_sz;
    let Some(cnt_sz) = enc_width(fde_count_enc) else {
        return Vec::new();
    };
    let Some(cnt_bytes) = rd(cur, cnt_sz) else {
        return Vec::new();
    };
    let fde_count = read_uint(cnt_bytes);
    cur += cnt_sz;

    // Each table row is (initial_location, fde_addr), both sdata4 relative to
    // the header's own address. Cap the count at what the file can hold so a
    // corrupted count cannot spin.
    let max_rows = bytes.len().saturating_sub(cur) / 8;
    let rows = (fde_count as usize).min(max_rows);
    let mut out = Vec::with_capacity(rows.min(1 << 16));
    for _ in 0..rows {
        let Some(loc) = rd(cur, 4) else { break };
        let delta = i32::from_le_bytes([loc[0], loc[1], loc[2], loc[3]]);
        out.push(hdr_vaddr.wrapping_add_signed(delta as i64));
        cur += 8; // step past this row's fde_addr too
    }
    out.sort_unstable();
    out.dedup();
    out
}

/// Byte width of a fixed-size DW_EH_PE encoding, or `None` for the variable and
/// unsupported forms (leb128, absptr) that the fast path does not decode.
fn enc_width(enc: u8) -> Option<usize> {
    match enc & 0x0f {
        0x02 | 0x0a => Some(2), // udata2 / sdata2
        0x03 | 0x0b => Some(4), // udata4 / sdata4
        0x04 | 0x0c => Some(8), // udata8 / sdata8
        _ => None,
    }
}

/// Read a little-endian unsigned integer from 1..=8 bytes.
fn read_uint(b: &[u8]) -> u64 {
    let mut v = 0u64;
    for (i, &byte) in b.iter().take(8).enumerate() {
        v |= (byte as u64) << (8 * i);
    }
    v
}

#[cfg(test)]
mod tests {
    #[test]
    fn eh_frame_hdr_recovers_function_starts() {
        // The synthetic ELF lists two function starts in its .eh_frame_hdr
        // table; both must appear as func_hints so the engine can seed them.
        let bytes = crate::formats::fixture::elf_with_eh_frame_hdr();
        let bin = crate::formats::analyze("eh.elf", &bytes).unwrap();
        assert_eq!(bin.func_hints, vec![0x1000, 0x1400]);
    }

    #[test]
    fn a_binary_without_eh_frame_hdr_yields_no_hints() {
        // The plain PLT fixture has no PT_GNU_EH_FRAME; discovery must return
        // empty rather than invent addresses.
        let bytes = crate::formats::fixture::elf_with_plt_call();
        let bin = crate::formats::analyze("plt.elf", &bytes).unwrap();
        assert!(bin.func_hints.is_empty());
    }
}