Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

crates/nornir-introspect/src/hardening.rs

//! Binary **hardening** analysis — a pure-Rust `checksec`: RELRO / PIE /
//! stack-canary / NX / stripped / debug-info-present / FORTIFY, plus the
//! format/arch/class/size header, all derived from the ELF header, program
//! headers, dynamic table, and symbol table via the `object` reader (no
//! shell-out to `checksec`/`readelf`). Non-ELF inputs still report
//! format/arch/class/size; the ELF-specific hardening flags default off.

use std::fs::File;
use std::path::Path;

use anyhow::{Context, Result};
use memmap2::Mmap;
use object::read::elf::{ElfFile, ElfFile32, ElfFile64, FileHeader, ProgramHeader};
use object::{elf, Endianness, FileKind, Object, ObjectSection, ObjectSymbol, ReadRef, SymbolKind};
use serde::{Deserialize, Serialize};

/// The checksec-class hardening + format/size header for one artifact — one row
/// of `binary_facts`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Hardening {
    /// Container format: `elf` / `macho` / `pe` / `wasm` / `coff` / `unknown`.
    pub format: String,
    /// CPU architecture label, e.g. `X86_64`, `Aarch64`.
    pub arch: String,
    /// Object class: `exe` / `dylib` / `relocatable` / `core` / `other`.
    pub class: String,
    /// Artifact size on disk, bytes.
    pub total_bytes: u64,
    /// No static symbol table (`.symtab`) — the binary was stripped.
    pub stripped: bool,
    /// Carries DWARF debug sections (`.debug_*`).
    pub has_debug_info: bool,
    /// Position-independent executable (ET_DYN / `DF_1_PIE`).
    pub pie: bool,
    /// RELRO level: `none` / `partial` / `full`.
    pub relro: String,
    /// Non-executable stack (`PT_GNU_STACK` without `PF_X`).
    pub nx: bool,
    /// Stack-smashing protector present (`__stack_chk_fail`/`__stack_chk_guard`).
    pub stack_canary: bool,
    /// `_FORTIFY_SOURCE` in use (a `*_chk` fortified libc symbol is referenced).
    pub fortify: bool,
    /// A legacy `DT_RPATH` entry is present.
    pub rpath: bool,
    /// A `DT_RUNPATH` entry is present.
    pub runpath: bool,
    /// The GNU build-id note (`.note.gnu.build-id`), hex, if present.
    pub build_id: Option<String>,
    /// Number of sections.
    pub n_sections: u64,
    /// Defined (non-undefined) code/data symbols.
    pub n_sym_defined: u64,
    /// Undefined (imported) code/data symbols.
    pub n_sym_undefined: u64,
    /// `DT_NEEDED` dynamic dependency count.
    pub n_dyn_deps: u64,
}

/// Analyse the hardening posture + header of the artifact at `path`.
pub fn analyze_hardening(path: &Path) -> Result<Hardening> {
    let file = File::open(path).with_context(|| format!("open artifact {}", path.display()))?;
    let mmap = unsafe { Mmap::map(&file) }.with_context(|| format!("mmap {}", path.display()))?;
    let total_bytes = mmap.len() as u64;
    let obj =
        object::File::parse(&*mmap).with_context(|| format!("parse object {}", path.display()))?;

    let format = match obj.format() {
        object::BinaryFormat::Elf => "elf",
        object::BinaryFormat::MachO => "macho",
        object::BinaryFormat::Pe => "pe",
        object::BinaryFormat::Wasm => "wasm",
        object::BinaryFormat::Coff => "coff",
        _ => "unknown",
    }
    .to_string();
    let arch = format!("{:?}", obj.architecture());
    let class = match obj.kind() {
        object::ObjectKind::Executable => "exe",
        object::ObjectKind::Dynamic => "dylib",
        object::ObjectKind::Relocatable => "relocatable",
        object::ObjectKind::Core => "core",
        _ => "other",
    }
    .to_string();

    // Format-agnostic facts from the generic reader.
    let n_sections = obj.sections().count() as u64;
    let has_debug_info = obj.sections().any(|s| {
        s.name()
            .map(|n| n.starts_with(".debug") || n.starts_with("__debug"))
            .unwrap_or(false)
    });
    let stripped = obj.symbols().next().is_none() && obj.section_by_name(".symtab").is_none();
    let build_id = obj
        .build_id()
        .ok()
        .flatten()
        .filter(|b| !b.is_empty())
        .map(hex_encode);

    // Symbol-name scan (static + dynamic) for the canary / FORTIFY tells and the
    // defined/undefined counts.
    let mut stack_canary = false;
    let mut fortify = false;
    let mut n_sym_defined = 0u64;
    let mut n_sym_undefined = 0u64;
    for sym in obj.symbols().chain(obj.dynamic_symbols()) {
        let Ok(name) = sym.name() else { continue };
        if name.is_empty() {
            continue;
        }
        if name == "__stack_chk_fail" || name == "__stack_chk_guard" {
            stack_canary = true;
        }
        if name.ends_with("_chk") {
            fortify = true;
        }
        if matches!(
            sym.kind(),
            SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown
        ) {
            if sym.is_undefined() {
                n_sym_undefined += 1;
            } else {
                n_sym_defined += 1;
            }
        }
    }

    // ELF-specific hardening: PIE / RELRO / NX / RPATH / RUNPATH / dep count.
    let mut elf = ElfHardening::default();
    if let Ok(kind) = FileKind::parse(&*mmap) {
        match kind {
            FileKind::Elf64 => {
                if let Ok(f) = ElfFile64::<Endianness>::parse(&*mmap) {
                    elf = harden_elf(&f);
                }
            }
            FileKind::Elf32 => {
                if let Ok(f) = ElfFile32::<Endianness>::parse(&*mmap) {
                    elf = harden_elf(&f);
                }
            }
            _ => {}
        }
    }

    Ok(Hardening {
        format,
        arch,
        class,
        total_bytes,
        stripped,
        has_debug_info,
        pie: elf.pie,
        relro: elf.relro,
        nx: elf.nx,
        stack_canary,
        fortify,
        rpath: elf.rpath,
        runpath: elf.runpath,
        build_id,
        n_sections,
        n_sym_defined,
        n_sym_undefined,
        n_dyn_deps: elf.n_dyn_deps,
    })
}

/// The ELF-only slice of the hardening verdict.
struct ElfHardening {
    pie: bool,
    relro: String,
    nx: bool,
    rpath: bool,
    runpath: bool,
    n_dyn_deps: u64,
}

impl Default for ElfHardening {
    fn default() -> Self {
        ElfHardening {
            pie: false,
            relro: "none".to_string(),
            nx: false,
            rpath: false,
            runpath: false,
            n_dyn_deps: 0,
        }
    }
}

/// Class/endian-agnostic ELF hardening read: program headers for NX + the RELRO
/// segment, the dynamic table for BIND_NOW / PIE flags / RPATH / RUNPATH /
/// DT_NEEDED, the header for the ET_DYN (PIE) shape.
fn harden_elf<'data, Elf, R>(elf: &ElfFile<'data, Elf, R>) -> ElfHardening
where
    Elf: FileHeader<Endian = Endianness>,
    R: ReadRef<'data>,
{
    let endian = elf.endian();
    let e_type = elf.elf_header().e_type(endian);

    // ── Program headers: NX (GNU_STACK ∌ PF_X) + the RELRO segment ──
    let mut nx = false;
    let mut relro_seg = false;
    for ph in elf.elf_program_headers() {
        match ph.p_type(endian) {
            elf::PT_GNU_STACK => nx = !ph.p_flags(endian).intersects(elf::PF_X),
            elf::PT_GNU_RELRO => relro_seg = true,
            _ => {}
        }
    }

    // ── Dynamic table: BIND_NOW / PIE flag / RPATH / RUNPATH / DT_NEEDED ──
    let mut bind_now = false;
    let mut df_1_pie = false;
    let mut rpath = false;
    let mut runpath = false;
    let mut n_dyn_deps = 0u64;
    if let Ok(dyn_table) = elf.elf_dynamic_table() {
        for d in dyn_table.iter() {
            match d.tag {
                elf::DT_NEEDED => n_dyn_deps += 1,
                elf::DT_BIND_NOW => bind_now = true,
                elf::DT_FLAGS => {
                    if elf::DynamicFlags(d.val).intersects(elf::DF_BIND_NOW) {
                        bind_now = true;
                    }
                }
                elf::DT_FLAGS_1 => {
                    let v = elf::DynamicFlags1(d.val);
                    if v.intersects(elf::DF_1_NOW) {
                        bind_now = true;
                    }
                    if v.intersects(elf::DF_1_PIE) {
                        df_1_pie = true;
                    }
                }
                elf::DT_RPATH => rpath = true,
                elf::DT_RUNPATH => runpath = true,
                _ => {}
            }
        }
    }

    let relro = if relro_seg {
        if bind_now {
            "full"
        } else {
            "partial"
        }
    } else {
        "none"
    }
    .to_string();

    // A PIE executable is ET_DYN; `DF_1_PIE` confirms it against a plain DSO.
    let pie = e_type == elf::ET_DYN && (df_1_pie || elf.entry() != 0);

    ElfHardening {
        pie,
        relro,
        nx,
        rpath,
        runpath,
        n_dyn_deps,
    }
}

/// Lowercase hex (no external hex dep in this leaf crate).
fn hex_encode(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
        s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The test executable decodes to a coherent hardening verdict: an ELF (on
    /// Linux) executable with a valid RELRO level and a decoded arch/class. Red
    /// if the header/program-header/dynamic decode regresses.
    #[test]
    fn analyze_hardening_selfexe() {
        let bin = std::env::current_exe().expect("current test exe");
        let h = analyze_hardening(&bin).expect("analyze");
        assert!(h.total_bytes > 0, "total bytes");
        assert!(h.n_sections > 0, "sections");
        assert!(!h.arch.is_empty(), "arch decoded");
        assert!(
            matches!(h.relro.as_str(), "none" | "partial" | "full"),
            "relro must be a valid level, got {}",
            h.relro
        );
        // A freshly-built test binary is not stripped and carries debug info.
        assert!(!h.stripped, "test exe should not be stripped");
        assert!(h.has_debug_info, "test exe should carry .debug_* sections");
    }
}