From 35d76945caaf41d41a016d2c13ac9c3f575eb72f Mon Sep 17 00:00:00 2001 From: GGWA <65288322+rech4210@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:26:37 +0900 Subject: [PATCH] ELF files with no section headers show their dynamic symbols and functions (#233) ELF files whose section headers were removed (common in NAS and router firmware) now show their functions, imports and exports. The dynamic symbol table is found through PT_DYNAMIC (DT_SYMTAB/DT_STRTAB, counted from DT_HASH, then DT_GNU_HASH, then an estimate with a warning), shared with the Flutter reader. The prologue scan walks executable segments when there are no sections. Fixes #223 --- crates/disasm/src/program.rs | 49 +++- crates/formats/src/dart.rs | 96 +------- crates/formats/src/elf.rs | 365 +++++++++++++++++++++++++++--- crates/testkit/src/lib.rs | 1 + crates/testkit/src/sectionless.rs | 160 +++++++++++++ docs/ROADMAP.md | 5 + 6 files changed, 548 insertions(+), 128 deletions(-) create mode 100644 crates/testkit/src/sectionless.rs diff --git a/crates/disasm/src/program.rs b/crates/disasm/src/program.rs index 66a7831c..11ab3f8e 100644 --- a/crates/disasm/src/program.rs +++ b/crates/disasm/src/program.rs @@ -1076,17 +1076,26 @@ fn prologue_scan(img: &Image, mode: Mode, covered: &BTreeMap) -> Vec return out, }; let is_covered = |a: u64| covered.range(..=a).next_back().is_some_and(|(_, &end)| a < end); - for s in img.bin.sections.iter().filter(|s| s.alloc && s.exec && s.file_offset.is_some()) { - if s.name.starts_with(".plt") || s.name == "__stubs" || s.name == ".init" || s.name == ".fini" { - continue; - } - let Some(mut a) = s.addr.checked_add(step - 1).map(|a| a & !(step - 1)) else { continue }; - let end = s.addr.saturating_add(s.file_size); + // (start, bytes in the file) of each executable range: sections, or the segments when the file has none. + let mut ranges: Vec<(u64, u64)> = img + .bin + .sections + .iter() + .filter(|s| s.alloc && s.exec && s.file_offset.is_some()) + .filter(|s| !(s.name.starts_with(".plt") || s.name == "__stubs" || s.name == ".init" || s.name == ".fini")) + .map(|s| (s.addr, s.file_size)) + .collect(); + if img.bin.sections.is_empty() { + ranges.extend(img.bin.segments.iter().filter(|s| s.exec && s.file_size > 0).map(|s| (s.addr, s.file_size))); + } + for (start, size) in ranges { + let Some(mut a) = start.checked_add(step - 1).map(|a| a & !(step - 1)) else { continue }; + let end = start.saturating_add(size); while a.checked_add(4).is_some_and(|e| e <= end) && out.len() < 200_000 { if !is_covered(a) && let Some(b) = img.read(a, 8) { - let prev_ok = a == s.addr || img.read(a - 1, 1).is_some_and(|p| matches!(p.first(), Some(0xc3 | 0xcc | 0x90 | 0x00 | 0xc2))); + let prev_ok = a == start || img.read(a - 1, 1).is_some_and(|p| matches!(p.first(), Some(0xc3 | 0xcc | 0x90 | 0x00 | 0xc2))); let hit = match mode { Mode::X64 => { prev_ok @@ -1297,6 +1306,32 @@ mod tests { assert_eq!(asm.first().unwrap().text(), "push rbp"); } + #[test] + fn a_program_with_no_section_headers_is_found_by_its_dynamic_symbols() { + for table in [tk::flutter::SymbolTable::DynamicHash, tk::flutter::SymbolTable::DynamicGnuHash] { + let names = ["inflate", "deflate", "adler32"]; + let (img, p) = analyze(tk::sectionless::sectionless_elf(true, false, &names, table)); + assert!(img.bin.sections.is_empty()); + for name in names { + let f = p.function_by_name(name).unwrap_or_else(|| panic!("{name} not found")); + assert_eq!(f.discovered_by, "symbol"); + } + assert_eq!(p.functions.len(), 3); + } + } + + #[test] + fn the_prologue_scan_walks_executable_segments_when_there_are_no_sections() { + let (mut elf, l) = tk::elf64(tk::ElfArch::X86_64, false); + // Remove the section header table: offset, count and name-table index. + elf[40..48].fill(0); + elf[60..64].fill(0); + let (img, p) = analyze(elf); + assert!(img.bin.sections.is_empty()); + let hidden = p.functions.get(&(l.code_va + tk::x64::HIDDEN)).expect("the scan finds it without sections"); + assert_eq!(hidden.discovered_by, "prologue"); + } + #[test] fn instruction_cap_marks_discovery_incomplete() { let (elf, _) = tk::elf64(tk::ElfArch::X86_64, true); diff --git a/crates/formats/src/dart.rs b/crates/formats/src/dart.rs index 1eb5a961..4e00fffe 100644 --- a/crates/formats/src/dart.rs +++ b/crates/formats/src/dart.rs @@ -26,22 +26,13 @@ pub const DART_SYMBOLS: [(&str, &str); 4] = [ const MAX_PHDRS: usize = 4096; const MAX_SYMBOL_SCAN: u64 = 4_000_000; -const MAX_DYNAMIC: usize = 65_536; const MAX_NAME: usize = 4096; const MAX_REGION: usize = 64 << 20; const PT_LOAD: u32 = 1; -const PT_DYNAMIC: u32 = 2; const PT_NOTE: u32 = 4; const SHT_DYNSYM: u32 = 11; const SHT_NOTE: u32 = 7; -const DT_NULL: u64 = 0; -const DT_HASH: u64 = 4; -const DT_STRTAB: u64 = 5; -const DT_SYMTAB: u64 = 6; -const DT_STRSZ: u64 = 10; -const DT_SYMENT: u64 = 11; -const DT_GNU_HASH: u64 = 0x6fff_fef5; /// One dynamic symbol of interest. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -267,57 +258,6 @@ impl<'a> Elf<'a> { self.r.cstr(o, max) } - fn dynamic(&self) -> Vec<(u64, u64)> { - let mut out = Vec::new(); - let Some(d) = self.phdrs.iter().find(|p| p.ty == PT_DYNAMIC) else { return out }; - let ent = if self.is64 { 16u64 } else { 8 }; - let n = d.filesz / ent; - for i in 0..n.min(MAX_DYNAMIC as u64) { - let Some(o) = d.offset.checked_add(i * ent).and_then(to_usize) else { break }; - let e = if self.is64 { - (self.r.u64_le(o), o.checked_add(8).and_then(|x| self.r.u64_le(x))) - } else { - (self.r.u32_le(o).map(u64::from), o.checked_add(4).and_then(|x| self.r.u32_le(x)).map(u64::from)) - }; - let (Some(tag), Some(val)) = e else { break }; - if tag == DT_NULL { - break; - } - out.push((tag, val)); - } - out - } - - fn gnu_hash_count(&self, off: u64) -> Option { - let r = self.r; - let o = to_usize(off)?; - let nbuckets = u64::from(r.u32_le(o)?); - let symoffset = u64::from(r.u32_le(o.checked_add(4)?)?); - let bloom_size = u64::from(r.u32_le(o.checked_add(8)?)?); - if nbuckets > (1 << 24) || bloom_size > (1 << 24) { - return None; - } - let word = if self.is64 { 8 } else { 4 }; - let buckets = off.checked_add(16)?.checked_add(bloom_size.checked_mul(word)?)?; - let mut max = 0u64; - for i in 0..nbuckets { - let b = u64::from(r.u32_le(to_usize(buckets.checked_add(i * 4)?)?)?); - max = max.max(b); - } - if max < symoffset { - return Some(symoffset); - } - let chains = buckets.checked_add(nbuckets * 4)?; - let mut idx = max; - loop { - let v = r.u32_le(to_usize(chains.checked_add(idx.checked_sub(symoffset)?.checked_mul(4)?)?)?)?; - idx = idx.checked_add(1)?; - if v & 1 == 1 || idx > MAX_SYMBOL_SCAN { - return Some(idx); - } - } - } - fn notes(&self, off: u64, size: u64, out: &mut Option) { let mut pos = 0u64; let r = self.r; @@ -384,36 +324,12 @@ pub fn parse_elf(data: &[u8]) -> Result { } } if table.is_none() { - let dynv = elf.dynamic(); - let get = |tag: u64| dynv.iter().find(|(t, _)| *t == tag).map(|(_, v)| *v); - let (symtab, strtab) = (get(DT_SYMTAB).and_then(|v| elf.va_to_off(v)), get(DT_STRTAB).and_then(|v| elf.va_to_off(v))); - if let (Some(symtab), Some(strtab)) = (symtab, strtab) { - let syment = get(DT_SYMENT).unwrap_or(elf.sym_size()); - if syment != elf.sym_size() { - warnings.push(format!("unexpected DT_SYMENT {syment}")); - } else { - let strsz = get(DT_STRSZ).unwrap_or(0); - let mut count = None; - if let Some(n) = get(DT_HASH).and_then(|v| elf.va_to_off(v)).and_then(to_usize).and_then(|o| r.u32_le(o.checked_add(4)?)) { - count = Some(u64::from(n)); - count_source = "DT_HASH"; - } - if count.is_none() - && let Some(n) = get(DT_GNU_HASH).and_then(|v| elf.va_to_off(v)).and_then(|o| elf.gnu_hash_count(o)) - { - count = Some(n); - count_source = "DT_GNU_HASH"; - } - if count.is_none() && strtab > symtab { - count = Some((strtab - symtab) / elf.sym_size()); - count_source = "estimated"; - warnings.push("no hash table: the symbol count was estimated from the table layout".into()); - } - if let Some(c) = count { - table = Some((symtab, c, strtab, strsz)); - symbol_source = "dynamic-segment"; - } - } + let scan = crate::elf::dynamic_table(data); + warnings.extend(scan.warnings); + if let Some(t) = scan.table { + table = Some((t.symtab, t.count, t.strtab, t.strsz)); + symbol_source = "dynamic-segment"; + count_source = t.count_source; } } let mut dart_symbols = Vec::new(); diff --git a/crates/formats/src/elf.rs b/crates/formats/src/elf.rs index e3b8b678..868b37cc 100644 --- a/crates/formats/src/elf.rs +++ b/crates/formats/src/elf.rs @@ -1,7 +1,7 @@ //! ELF details `object` leaves to us: program headers, the dynamic section, mitigations, and //! core-file notes (registers, signal, mapped files) for crash inspection. -use crate::binary::{Arch, Binary}; +use crate::binary::{Arch, Binary, Export, Import, MAX_IMPORTS, MAX_SYMBOLS, Symbol, SymbolKind, demangle}; use crate::bytes::Reader; use crate::{FormatError, hex_addr}; use object::Object; @@ -10,7 +10,7 @@ use serde_json::json; use std::collections::BTreeMap; const MAX_PHDRS: usize = 4096; -const MAX_DYN: usize = 8192; +const MAX_DYN: usize = 65_536; const MAX_NOTES: usize = 100_000; /// Endian/class-aware reads over an ELF image. @@ -104,7 +104,8 @@ pub fn program_headers(data: &[u8]) -> Result, FormatError> { } let mut out = Vec::new(); for i in 0..phnum { - let Some(o) = i.checked_mul(phentsize).and_then(|x| x.checked_add(phoff)) else { break }; + // The fields below sit within `phentsize` bytes of `o`, so their offsets can't overflow either. + let Some(o) = i.checked_mul(phentsize).and_then(|x| x.checked_add(phoff)).filter(|&o| o.checked_add(phentsize).is_some()) else { break }; let ph = if e.is64 { (e.u32(o), e.u32(o + 4), e.u64(o + 8), e.u64(o + 16), e.u64(o + 32), e.u64(o + 40), e.u64(o + 48)) } else { @@ -182,6 +183,24 @@ fn va_to_off(phdrs: &[ProgramHeader], va: u64) -> Option { phdrs.iter().filter(|p| p.p_type == 1).find(|p| va >= p.vaddr && va - p.vaddr < p.file_size).and_then(|p| p.offset.checked_add(va - p.vaddr)) } +/// The (tag, value) pairs of the dynamic section, up to its `DT_NULL`. +fn dynamic_entries(e: &E<'_>, phdrs: &[ProgramHeader]) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let Some(d) = phdrs.iter().find(|p| p.p_type == 2) else { return out }; + let ent = 2 * e.word_size(); + let n = usize::try_from(d.file_size).unwrap_or(0) / ent; + let start = usize::try_from(d.offset).unwrap_or(usize::MAX); + for i in 0..n.min(MAX_DYN) { + let Some(o) = i.checked_mul(ent).and_then(|x| start.checked_add(x)) else { break }; + let (Some(tag), Some(val)) = (e.word(o), o.checked_add(e.word_size()).and_then(|v| e.word(v))) else { break }; + if tag == 0 { + break; + } + out.push((tag, val)); + } + out +} + /// Add ELF facts to a parsed binary: image base, interpreter, needed libraries, RELRO, NX, PIE. pub fn enrich(data: &[u8], file: &object::File<'_>, b: &mut Binary) { let Ok(e) = E::new(data) else { return }; @@ -196,21 +215,7 @@ pub fn enrich(data: &[u8], file: &object::File<'_>, b: &mut Binary) { if let Some(base) = phdrs.iter().filter(|p| p.p_type == 1).map(|p| p.vaddr).min() { b.image_base = base; } - // Dynamic section. - let mut dyns: Vec<(u64, u64)> = Vec::new(); - if let Some(d) = phdrs.iter().find(|p| p.p_type == 2) { - let ent = 2 * e.word_size(); - let n = usize::try_from(d.file_size).unwrap_or(0) / ent.max(1); - let start = usize::try_from(d.offset).unwrap_or(usize::MAX); - for i in 0..n.min(MAX_DYN) { - let Some(o) = start.checked_add(i * ent) else { break }; - let (Some(tag), Some(val)) = (e.word(o), e.word(o + e.word_size())) else { break }; - if tag == 0 { - break; - } - dyns.push((tag, val)); - } - } + let dyns = dynamic_entries(&e, &phdrs); let strtab = dyns.iter().find(|(t, _)| *t == 5).and_then(|(_, v)| va_to_off(&phdrs, *v)); let dstr = |v: u64| -> Option { let off = usize::try_from(strtab?.checked_add(v)?).ok()?; @@ -296,6 +301,43 @@ pub fn enrich(data: &[u8], file: &object::File<'_>, b: &mut Binary) { "dt_flags": format!("{flags:#x}"), "dt_flags_1": format!("{flags1:#x}"), }); + // With no section headers `object` finds no symbols; the dynamic section still points at them. + if b.sections.is_empty() + && let Some(ds) = dynamic_symbols(data, b) + { + add_dynamic_symbols(b, ds); + } +} + +/// Put symbols read from `PT_DYNAMIC` into the binary's symbols, imports and exports. +fn add_dynamic_symbols(b: &mut Binary, ds: DynamicSymbols) { + b.warnings.extend(ds.warnings.into_iter().map(|w| format!("dynamic symbols: {w}"))); + b.symbols_truncated |= ds.truncated; + for (s, defined) in ds.exports.iter().map(|s| (s, true)).chain(ds.imports.iter().map(|s| (s, false))) { + if b.symbols.len() >= MAX_SYMBOLS { + b.symbols_truncated = true; + break; + } + let kind = match s.kind { + "function" | "ifunc" => SymbolKind::Function, + "object" | "tls" | "common" => SymbolKind::Data, + _ => SymbolKind::Other, + }; + b.symbols.push(Symbol { + name: s.name.clone(), + demangled: demangle(&s.name), + addr: s.addr, + size: s.size, + kind, + global: s.binding != "weak", + weak: s.binding == "weak", + defined, + dynamic: true, + }); + } + b.exports.extend(ds.exports.iter().take(MAX_IMPORTS).map(|s| Export { name: s.name.clone(), addr: s.addr })); + let room = MAX_IMPORTS.saturating_sub(b.imports.len()); + b.imports.extend(ds.imports.into_iter().take(room).map(|s| Import { name: s.name, library: s.library, ordinal: None, slot: None })); } // --------------------------------------------------------------------------------------------- @@ -369,8 +411,11 @@ fn version_defs(e: &E<'_>, base: usize, strs: &[u8]) -> BTreeMap { out } +/// Version needs by index: (name, library). +type Needs = BTreeMap; + /// Version needs: index → (name, library). -fn version_needs(e: &E<'_>, base: usize, strs: &[u8]) -> BTreeMap { +fn version_needs(e: &E<'_>, base: usize, strs: &[u8]) -> Needs { let mut out = BTreeMap::new(); let mut pos = 0usize; let mut budget = MAX_VERSIONS; @@ -408,30 +453,204 @@ fn version_needs(e: &E<'_>, base: usize, strs: &[u8]) -> BTreeMap, + pub warnings: Vec, +} + +const DT_HASH: u64 = 4; +const DT_STRTAB: u64 = 5; +const DT_SYMTAB: u64 = 6; +const DT_STRSZ: u64 = 10; +const DT_SYMENT: u64 = 11; +const DT_GNU_HASH: u64 = 0x6fff_fef5; +const DT_VERSYM: u64 = 0x6fff_fff0; +const DT_VERDEF: u64 = 0x6fff_fffc; +const DT_VERNEED: u64 = 0x6fff_fffe; +/// Most symbols a `DT_GNU_HASH` chain walk counts. +const MAX_HASH_SYMS: u64 = 4_000_000; + +/// How many symbols a `DT_GNU_HASH` table at file offset `off` covers: past the last bucket's +/// start, to the end of its chain. +fn gnu_hash_count(e: &E<'_>, off: u64) -> Option { + let o = usize::try_from(off).ok()?; + let nbuckets = u64::from(e.u32(o)?); + let symoffset = u64::from(e.u32(o.checked_add(4)?)?); + let bloom_size = u64::from(e.u32(o.checked_add(8)?)?); + if nbuckets > (1 << 24) || bloom_size > (1 << 24) { + return None; + } + let buckets = off.checked_add(16)?.checked_add(bloom_size.checked_mul(e.word_size() as u64)?)?; + let mut max = 0u64; + for i in 0..nbuckets { + let b = u64::from(e.u32(usize::try_from(buckets.checked_add(i * 4)?).ok()?)?); + max = max.max(b); + } + if max < symoffset { + return Some(symoffset); + } + let chains = buckets.checked_add(nbuckets * 4)?; + let mut idx = max; + loop { + let v = e.u32(usize::try_from(chains.checked_add(idx.checked_sub(symoffset)?.checked_mul(4)?)?).ok()?)?; + idx = idx.checked_add(1)?; + if v & 1 == 1 || idx > MAX_HASH_SYMS { + return Some(idx); + } + } +} + +/// Find the dynamic symbol table through `PT_DYNAMIC`: `DT_SYMTAB` and `DT_STRTAB`, with the +/// entry count from `DT_HASH`, else `DT_GNU_HASH`, else estimated from where the string table +/// starts (with a warning). For images whose section headers were removed. +pub fn dynamic_table(data: &[u8]) -> DynTableScan { + let mut scan = DynTableScan::default(); + let Ok(e) = E::new(data) else { return scan }; + let Ok(phdrs) = program_headers(data) else { return scan }; + let dynv = dynamic_entries(&e, &phdrs); + let get = |tag: u64| dynv.iter().find(|(t, _)| *t == tag).map(|(_, v)| *v); + let (Some(symtab), Some(strtab)) = (get(DT_SYMTAB).and_then(|v| va_to_off(&phdrs, v)), get(DT_STRTAB).and_then(|v| va_to_off(&phdrs, v))) else { + return scan; + }; + let sym_size = e.sym_size(); + let syment = get(DT_SYMENT).unwrap_or(sym_size); + if syment != sym_size { + scan.warnings.push(format!("unexpected DT_SYMENT {syment}")); + return scan; + } + let mut found: Option<(u64, &'static str)> = None; + if let Some(n) = get(DT_HASH).and_then(|v| va_to_off(&phdrs, v)).and_then(|o| usize::try_from(o).ok()).and_then(|o| e.u32(o.checked_add(4)?)) { + found = Some((u64::from(n), "DT_HASH")); + } + if found.is_none() + && let Some(n) = get(DT_GNU_HASH).and_then(|v| va_to_off(&phdrs, v)).and_then(|o| gnu_hash_count(&e, o)) + { + found = Some((n, "DT_GNU_HASH")); + } + if found.is_none() && strtab > symtab { + found = Some(((strtab - symtab) / sym_size, "estimated")); + scan.warnings.push("no hash table: the symbol count was estimated from the table layout".into()); + } + if let Some((count, count_source)) = found { + scan.table = Some(DynTable { symtab, count, strtab, strsz: get(DT_STRSZ).unwrap_or(0), count_source }); + } + scan +} + +/// One raw symbol table entry. +struct RawSym { + name: u32, + info: u8, + other: u8, + shndx: u16, + value: u64, + size: u64, +} + +fn raw_sym(e: &E<'_>, at: usize) -> Option { + let f = |d: usize| at.checked_add(d); + Some(if e.is64 { + RawSym { name: e.u32(at)?, info: e.r.u8(f(4)?)?, other: e.r.u8(f(5)?)?, shndx: e.u16(f(6)?)?, value: e.u64(f(8)?)?, size: e.u64(f(16)?)? } + } else { + RawSym { + name: e.u32(at)?, + info: e.r.u8(f(12)?)?, + other: e.r.u8(f(13)?)?, + shndx: e.u16(f(14)?)?, + value: u64::from(e.u32(f(4)?)?), + size: u64::from(e.u32(f(8)?)?), + } + }) +} + +impl E<'_> { + /// Bytes in one symbol table entry. + fn sym_size(&self) -> u64 { + if self.is64 { 24 } else { 16 } + } +} + +/// Read `.dynsym` with its version tables; with no such section, the table `PT_DYNAMIC` points +/// at, with its version tables. `None` when the file isn't ELF or has neither. pub fn dynamic_symbols(data: &[u8], b: &Binary) -> Option { let e = E::new(data).ok()?; - let syms = section_bytes(data, b, ".dynsym")?; - let strs = section_bytes(data, b, ".dynstr")?; + let ent = usize::try_from(e.sym_size()).ok()?; + let (Some(syms), Some(strs)) = (section_bytes(data, b, ".dynsym"), section_bytes(data, b, ".dynstr")) else { + return segment_dynamic_symbols(data, &e); + }; let off_of = |name: &str| b.sections.iter().find(|s| s.name == name).and_then(|s| s.file_offset).and_then(|o| usize::try_from(o).ok()); let defs = off_of(".gnu.version_d").map(|o| version_defs(&e, o, strs)).unwrap_or_default(); let needs = off_of(".gnu.version_r").map(|o| version_needs(&e, o, strs)).unwrap_or_default(); let versym = off_of(".gnu.version"); let syms_off = off_of(".dynsym")?; - let ent = if e.is64 { 24usize } else { 16 }; - let count = syms.len() / ent; let mut out = DynamicSymbols::default(); + read_dynsyms(&e, (syms_off, syms.len() / ent), strs, versym, &defs, &needs, &mut out); + Some(out) +} + +/// [`dynamic_symbols`] for a file without section headers. +fn segment_dynamic_symbols(data: &[u8], e: &E<'_>) -> Option { + let scan = dynamic_table(data); + let t = scan.table?; + let phdrs = program_headers(data).ok()?; + let dynv = dynamic_entries(e, &phdrs); + let off_of = |tag: u64| dynv.iter().find(|(t, _)| *t == tag).and_then(|(_, v)| va_to_off(&phdrs, *v)).and_then(|o| usize::try_from(o).ok()); + // A string table with no DT_STRSZ runs to the end of the file (each name is capped). + let strs = data.get(usize::try_from(t.strtab).ok()?..)?; + let strs = if t.strsz == 0 { strs } else { strs.get(..usize::try_from(t.strsz).unwrap_or(usize::MAX).min(strs.len()))? }; + let defs = off_of(DT_VERDEF).map(|o| version_defs(e, o, strs)).unwrap_or_default(); + let needs = off_of(DT_VERNEED).map(|o| version_needs(e, o, strs)).unwrap_or_default(); + let ent = usize::try_from(e.sym_size()).ok()?; + let symtab = usize::try_from(t.symtab).ok()?; + // The count comes from the file's own tables: only the entries that lie inside the file are read. + let room = data.len().saturating_sub(symtab) / ent; + let count = usize::try_from(t.count).unwrap_or(usize::MAX); + let mut out = DynamicSymbols { warnings: scan.warnings, ..DynamicSymbols::default() }; + if count > room { + out.truncated = true; + out.warnings.push(format!("the symbol table ends early: {room} of {count} entries are in the file")); + } + read_dynsyms(e, (symtab, count.min(room)), strs, off_of(DT_VERSYM), &defs, &needs, &mut out); + Some(out) +} + +/// Read `count` symbols of the table at `syms_off` into `out`, naming each from `strs` and its +/// version from the version tables. +fn read_dynsyms( + e: &E<'_>, + (syms_off, count): (usize, usize), + strs: &[u8], + versym: Option, + defs: &BTreeMap, + needs: &Needs, + out: &mut DynamicSymbols, +) { + let ent = e.sym_size() as usize; if count > MAX_DYNSYMS { out.truncated = true; out.warnings.push(format!("only the first {MAX_DYNSYMS} of {count} dynamic symbols were read")); } for i in 0..count.min(MAX_DYNSYMS) { - let at = syms_off.checked_add(i.checked_mul(ent)?)?; - let (name, info, other, shndx, value, size) = if e.is64 { - (e.u32(at)?, e.r.u8(at + 4)?, e.r.u8(at + 5)?, e.u16(at + 6)?, e.u64(at + 8)?, e.u64(at + 16)?) - } else { - (e.u32(at)?, e.r.u8(at + 12)?, e.r.u8(at + 13)?, e.u16(at + 14)?, u64::from(e.u32(at + 4)?), u64::from(e.u32(at + 8)?)) + let Some(RawSym { name, info, other, shndx, value, size }) = i.checked_mul(ent).and_then(|d| syms_off.checked_add(d)).and_then(|at| raw_sym(e, at)) + else { + break; }; let binding = match info >> 4 { 1 => "global", @@ -471,7 +690,6 @@ pub fn dynamic_symbols(data: &[u8], b: &Binary) -> Option { out.imports.push(row); } } - Some(out) } /// Name of an ELF dynamic relocation type. @@ -893,3 +1111,88 @@ mod tests { assert!(b.libraries.is_empty()); } } + +#[cfg(test)] +mod sectionless_tests { + use super::*; + use crate::binary::SymbolKind; + use craft_anything_testkit::flutter::SymbolTable; + use craft_anything_testkit::sectionless::sectionless_elf; + + const NAMES: [&str; 3] = ["inflate", "deflate", "adler32"]; + const TABLES: [(SymbolTable, &str); 2] = [(SymbolTable::DynamicHash, "DT_HASH"), (SymbolTable::DynamicGnuHash, "DT_GNU_HASH")]; + + #[test] + fn symbols_come_from_the_dynamic_segment_in_every_class_and_byte_order() { + for (is64, big) in [(true, false), (false, false), (false, true), (true, true)] { + for (table, source) in TABLES { + let d = sectionless_elf(is64, big, &NAMES, table); + let b = crate::parse_binary(&d).unwrap(); + assert!(b.sections.is_empty(), "{is64} {big}"); + let scan = dynamic_table(&d); + let t = scan.table.unwrap(); + assert_eq!((t.count, t.count_source), (5, source), "{is64} {big}"); + assert!(scan.warnings.is_empty()); + let funcs: Vec<_> = b.symbols.iter().filter(|s| s.defined && s.kind == SymbolKind::Function && s.dynamic).map(|s| s.name.as_str()).collect(); + assert_eq!(funcs, NAMES, "{is64} {big} {source}"); + let exports: Vec<_> = b.exports.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(exports, NAMES); + assert!(b.exports.iter().all(|e| e.addr != 0)); + assert_eq!(b.imports.iter().map(|i| i.name.as_str()).collect::>(), ["puts"]); + let ds = dynamic_symbols(&d, &b).unwrap(); + assert_eq!((ds.exports.len(), ds.imports.len()), (3, 1)); + } + } + } + + #[test] + fn the_section_stripped_flutter_libraries_name_their_symbols() { + for (table, source) in TABLES { + let d = craft_anything_testkit::flutter::libapp_so(table); + let b = crate::parse_binary(&d).unwrap(); + assert!(b.sections.is_empty()); + assert_eq!(dynamic_table(&d).table.unwrap().count_source, source); + let mut names: Vec<_> = b.symbols.iter().filter(|s| s.dynamic && s.defined).map(|s| s.name.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, ["_kDartIsolateSnapshotData", "_kDartIsolateSnapshotInstructions", "_kDartVmSnapshotData", "_kDartVmSnapshotInstructions"]); + assert_eq!(b.exports.len(), 4); + } + } + + #[test] + fn a_damaged_sectionless_file_never_panics() { + for (is64, big) in [(true, false), (false, true)] { + for (table, _) in TABLES { + let d = sectionless_elf(is64, big, &NAMES, table); + for sample in craft_anything_testkit::mutations(&d) { + let _ = dynamic_table(&sample); + if let Ok(b) = crate::parse_binary(&sample) { + let _ = dynamic_symbols(&sample, &b); + } + } + for n in (0..d.len()).step_by(7) { + let _ = dynamic_table(&d[..n]); + let _ = crate::parse_binary(&d[..n]); + } + } + } + } + + #[test] + fn a_symbol_count_past_the_end_of_the_file_reads_only_what_is_there() { + let mut d = sectionless_elf(true, false, &NAMES, SymbolTable::DynamicHash); + // Patch the DT_HASH table's nchain to the largest count. + let hash = { + let phdrs = program_headers(&d).unwrap(); + let e = E::new(&d).unwrap(); + let dynv = dynamic_entries(&e, &phdrs); + va_to_off(&phdrs, dynv.iter().find(|(tag, _)| *tag == DT_HASH).unwrap().1).unwrap() as usize + }; + d[hash + 4..hash + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(dynamic_table(&d).table.unwrap().count, u64::from(u32::MAX)); + let b = crate::parse_binary(&d).unwrap(); + let ds = dynamic_symbols(&d, &b).unwrap(); + assert!(ds.truncated && !ds.warnings.is_empty()); + assert_eq!(ds.exports.len(), 3); + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 9c2ce669..4d03da11 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -25,6 +25,7 @@ pub mod packed; pub mod pcap; pub mod program_facts; pub mod sbpf; +pub mod sectionless; pub mod sqlite; pub mod wasm_plugin; diff --git a/crates/testkit/src/sectionless.rs b/crates/testkit/src/sectionless.rs new file mode 100644 index 00000000..59a7881e --- /dev/null +++ b/crates/testkit/src/sectionless.rs @@ -0,0 +1,160 @@ +//! Shared objects with no section headers, as NAS and router firmware ships them: one `PT_LOAD` +//! (addresses equal file offsets), a `PT_DYNAMIC` with `DT_SYMTAB`, `DT_STRTAB`, `DT_STRSZ`, +//! `DT_SYMENT` and one hash table, an imported `puts` and one global function per name given. +//! Any class and byte order, so a big-endian MIPS ELF32 is covered too. + +use crate::flutter::SymbolTable; + +/// How many bytes each function body takes (its first byte is an x86 `ret`). +pub const BODY_SIZE: u64 = 16; + +struct W { + big: bool, + is64: bool, +} + +impl W { + fn put(&self, v: &mut [u8], at: usize, size: usize, x: u64) { + let b = if self.big { x.to_be_bytes() } else { x.to_le_bytes() }; + let b = if self.big { &b[8 - size..] } else { &b[..size] }; + v[at..at + size].copy_from_slice(b); + } + fn push(&self, v: &mut Vec, size: usize, x: u64) { + let at = v.len(); + v.resize(at + size, 0); + self.put(v, at, size, x); + } + fn word(&self) -> usize { + if self.is64 { 8 } else { 4 } + } +} + +fn align(v: &mut Vec, a: usize) { + while !v.len().is_multiple_of(a) { + v.push(0); + } +} + +/// Build the file. Function `i` is a [`BODY_SIZE`]-byte body, in order, from a 16-byte aligned +/// start. `table` picks `DT_HASH` or `DT_GNU_HASH`; there are never section headers. +pub fn sectionless_elf(is64: bool, big: bool, functions: &[&str], table: SymbolTable) -> Vec { + let w = W { big, is64 }; + let sym_size = if is64 { 24 } else { 16 }; + let mut f = vec![0u8; 0x100]; + let dynstr_off = f.len(); + f.push(0); + let mut names = Vec::new(); + for n in std::iter::once("puts").chain(functions.iter().copied()) { + names.push(f.len() - dynstr_off); + f.extend_from_slice(n.as_bytes()); + f.push(0); + } + let dynstr_size = f.len() - dynstr_off; + align(&mut f, 16); + let mut bodies = Vec::new(); + for _ in functions { + bodies.push(f.len() as u64); + f.push(0xc3); + f.extend(std::iter::repeat_n(0x90u8, BODY_SIZE as usize - 1)); + } + align(&mut f, 8); + let dynsym_off = f.len(); + let nsyms = functions.len() + 2; + // (name, info, shndx, value, size): the null symbol, the import, then the functions. + let mut rows: Vec<(u64, u8, u16, u64, u64)> = vec![(0, 0, 0, 0, 0), (names[0] as u64, 0x12, 0, 0, 0)]; + for (i, body) in bodies.iter().enumerate() { + rows.push((names[i + 1] as u64, 0x12, 1, *body, BODY_SIZE)); + } + for (name, info, shndx, value, size) in rows { + if is64 { + w.push(&mut f, 4, name); + f.extend_from_slice(&[info, 0]); + w.push(&mut f, 2, u64::from(shndx)); + w.push(&mut f, 8, value); + w.push(&mut f, 8, size); + } else { + w.push(&mut f, 4, name); + w.push(&mut f, 4, value); + w.push(&mut f, 4, size); + f.extend_from_slice(&[info, 0]); + w.push(&mut f, 2, u64::from(shndx)); + } + } + // DT_HASH: one bucket, nchain = the symbol count. + align(&mut f, 8); + let hash_off = f.len(); + for v in [1u64, nsyms as u64, 0] { + w.push(&mut f, 4, v); + } + f.extend(std::iter::repeat_n(0u8, 4 * nsyms)); + // DT_GNU_HASH: one bucket starting at the first function (the import sits before it). + align(&mut f, 8); + let gnu_off = f.len(); + for v in [1u64, 2, 1, 6] { + w.push(&mut f, 4, v); + } + w.push(&mut f, w.word(), u64::MAX); + w.push(&mut f, 4, 2); + for i in 0..functions.len() { + let last = i + 1 == functions.len(); + w.push(&mut f, 4, 0x1234_5670 | u64::from(last)); + } + // .dynamic + align(&mut f, 8); + let dyn_off = f.len(); + let mut dynv: Vec<(u64, u64)> = vec![(5, dynstr_off as u64), (6, dynsym_off as u64), (10, dynstr_size as u64), (11, sym_size)]; + match table { + SymbolTable::Sections | SymbolTable::DynamicHash => dynv.push((4, hash_off as u64)), + SymbolTable::DynamicGnuHash => dynv.push((0x6fff_fef5, gnu_off as u64)), + } + dynv.push((0, 0)); + for (t, v) in dynv { + w.push(&mut f, w.word(), t); + w.push(&mut f, w.word(), v); + } + let dyn_size = (f.len() - dyn_off) as u64; + let total = f.len() as u64; + // ELF header and two program headers: LOAD (read, execute) and DYNAMIC. + f[0..4].copy_from_slice(b"\x7fELF"); + f[4] = if is64 { 2 } else { 1 }; + f[5] = if big { 2 } else { 1 }; + f[6] = 1; + let machine = match (is64, big) { + (true, false) => 62, + (false, false) => 3, + (false, true) => 8, + (true, true) => 21, + }; + w.put(&mut f, 16, 2, 3); + w.put(&mut f, 18, 2, machine); + w.put(&mut f, 20, 4, 1); + let (phoff, phent, ehsize) = if is64 { (64, 56, 64) } else { (52, 32, 52) }; + if is64 { + w.put(&mut f, 32, 8, phoff); + w.put(&mut f, 52, 2, ehsize); + w.put(&mut f, 54, 2, phent); + w.put(&mut f, 56, 2, 2); + w.put(&mut f, 58, 2, 64); + } else { + w.put(&mut f, 28, 4, phoff); + w.put(&mut f, 40, 2, ehsize); + w.put(&mut f, 42, 2, phent); + w.put(&mut f, 44, 2, 2); + w.put(&mut f, 46, 2, 40); + } + for (i, (ty, flags, off, size)) in [(1u64, 5u64, 0u64, total), (2, 6, dyn_off as u64, dyn_size)].into_iter().enumerate() { + let p = phoff as usize + i * phent as usize; + w.put(&mut f, p, 4, ty); + if is64 { + w.put(&mut f, p + 4, 4, flags); + for (d, x) in [(8, off), (16, off), (24, off), (32, size), (40, size), (48, 8)] { + w.put(&mut f, p + d, 8, x); + } + } else { + for (d, x) in [(4, off), (8, off), (12, off), (16, size), (20, size), (24, flags), (28, 4)] { + w.put(&mut f, p + d, 4, x); + } + } + } + f +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b9ac81bd..b2f138c2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -171,6 +171,11 @@ Owner: open · Status: done (branch chore/r0.5-cleanups) - [x] `macho.rs` `method_list`: `list + 8 + i * entsize` adds to a pointer read from the file, so a segment that ends at `u64::MAX` overflowed it (found by a lint scan; now reproduced by a regression test, and the entry addresses are checked) +- [x] ELF files whose section headers were removed (common in NAS and router firmware, also big-endian + MIPS) show their dynamic symbols: `elf::dynamic_table` reads `DT_SYMTAB`/`DT_STRTAB` and the count + from `DT_HASH` or `DT_GNU_HASH` (moved out of `dart.rs`), `Binary` symbols, imports and exports are + filled from it, `elf::dynamic_symbols` reads versions through `DT_VERSYM`/`DT_VERDEF`/`DT_VERNEED`, + and the prologue scan walks executable segments (#223) ### R0.8 Tests never touch the real machine Owner: open · Status: done, except protecting `main` (a maintainer setting)