Skip to main content

fnprint_loader/
lib.rs

1//! Load an ELF, hand back the loadable segments and a best-effort list of
2//! functions. x86-64 only for now (v0.1). We try symbols first, fall back to
3//! .eh_frame FDE ranges when the thing is stripped, which covers most release
4//! binaries since they keep unwind info even without a symtab.
5
6use anyhow::{bail, Context, Result};
7use goblin::elf::Elf;
8
9#[derive(Clone)]
10pub struct Segment {
11    pub vaddr: u64,
12    pub bytes: Vec<u8>,
13    pub exec: bool,
14    pub write: bool,
15}
16
17#[derive(Clone, Debug)]
18pub struct Func {
19    pub name: Option<String>,
20    pub entry: u64,
21    pub size: u64,
22    /// how we found it, handy for debugging discovery
23    pub source: FuncSource,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum FuncSource {
28    Symtab,
29    DynSym,
30    EhFrame,
31}
32
33pub struct Image {
34    pub segments: Vec<Segment>,
35    pub entry: u64,
36    pub is_pie: bool,
37}
38
39impl Image {
40    /// grab the code bytes for a function out of the mapped segments.
41    /// vaddr/len come from attacker-controlled headers, so every bound is
42    /// checked without ever doing arithmetic that can wrap.
43    pub fn code_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
44        let len = len as u64;
45        for s in &self.segments {
46            if vaddr < s.vaddr {
47                continue;
48            }
49            let off = vaddr - s.vaddr; // safe: vaddr >= s.vaddr
50            let seg_len = s.bytes.len() as u64;
51            // off + len must fit inside the segment, no overflow
52            if off.checked_add(len).is_none_or(|end| end > seg_len) {
53                continue;
54            }
55            let off = off as usize;
56            return Some(&s.bytes[off..off + len as usize]);
57        }
58        None
59    }
60}
61
62pub struct Loaded {
63    pub image: Image,
64    pub funcs: Vec<Func>,
65}
66
67/// a single PT_LOAD bigger than this is refused rather than allocated. real RE
68/// targets, firmware included, sit well under it (real binaries run single-digit
69/// to low-hundreds of MB); anything past it is a crafted header (tiny p_filesz,
70/// huge p_memsz) trying to amplify a few bytes of file into a big zero-fill and
71/// allocate our way into an OOM. kept at 1 GiB: still orders of magnitude over
72/// any real input, but it halves the worst-case single-shot allocation a crafted
73/// bss claim can force.
74const MAX_SEG_MEM: u64 = 1 << 30; // 1 GiB
75/// total mapped memory across all segments, same idea, bounds a fan-out of many
76/// medium segments that each pass the per-segment check.
77const MAX_TOTAL_MEM: u64 = 1 << 31; // 2 GiB
78
79pub fn load(bytes: &[u8]) -> Result<Loaded> {
80    let elf = Elf::parse(bytes).context("not a valid elf")?;
81    if elf.header.e_machine != goblin::elf::header::EM_X86_64 {
82        bail!(
83            "only x86-64 is supported in this version (got e_machine {})",
84            elf.header.e_machine
85        );
86    }
87
88    // first pass: validate every PT_LOAD and sum what it WILL allocate, WITHOUT
89    // allocating anything yet. the total-memory guard has to count the actual
90    // bytes each segment holds, which is the file-window copy (end-start) grown to
91    // the bss tail (memsz), not p_memsz alone. a header with p_memsz=0 but a huge
92    // p_filesz still copies a whole file window, so many overlapping ones would
93    // amplify past the cap if we only counted memsz. computing the sum before any
94    // copy means such a bomb is rejected outright instead of ballooning up to the
95    // cap and only then bailing.
96    let mut plans: Vec<(usize, usize, usize, u64, bool, bool)> = Vec::new();
97    let mut total_mem: u64 = 0;
98    for ph in &elf.program_headers {
99        if ph.p_type != goblin::elf::program_header::PT_LOAD {
100            continue;
101        }
102        // refuse a bss claim we won't allocate for (loader bomb)
103        if ph.p_memsz > MAX_SEG_MEM {
104            bail!(
105                "PT_LOAD p_memsz {} over {}-byte limit, refusing",
106                ph.p_memsz,
107                MAX_SEG_MEM
108            );
109        }
110        // a vaddr+memsz that wraps u64 is nonsense and would overflow the
111        // page math downstream, reject it here at the boundary.
112        if ph.p_vaddr.checked_add(ph.p_memsz).is_none() {
113            bail!("PT_LOAD vaddr {:#x} + memsz overflows", ph.p_vaddr);
114        }
115
116        // clamp the file window: p_offset past EOF must not slice-panic. try_from
117        // so a value too big for usize (32-bit target) clamps to EOF instead of
118        // truncating past the .min() guard.
119        let start = usize::try_from(ph.p_offset)
120            .unwrap_or(usize::MAX)
121            .min(bytes.len());
122        let fsz = usize::try_from(ph.p_filesz).unwrap_or(usize::MAX);
123        let end = start.saturating_add(fsz).min(bytes.len());
124        // p_memsz is already capped under MAX_SEG_MEM above, so it fits usize.
125        let memsz = usize::try_from(ph.p_memsz).unwrap_or(usize::MAX);
126
127        let alloc_len = (end - start).max(memsz); // end >= start, no wrap
128        total_mem = total_mem.saturating_add(alloc_len as u64);
129        if total_mem > MAX_TOTAL_MEM {
130            bail!("total PT_LOAD memory over {}-byte limit", MAX_TOTAL_MEM);
131        }
132        plans.push((
133            start,
134            end,
135            memsz,
136            ph.p_vaddr,
137            ph.is_executable(),
138            ph.is_write(),
139        ));
140    }
141    if plans.is_empty() {
142        bail!("no PT_LOAD segments");
143    }
144
145    // second pass: now that the total is known-bounded, actually copy.
146    let mut segments = Vec::with_capacity(plans.len());
147    for (start, end, memsz, vaddr, exec, write) in plans {
148        let mut data = Vec::with_capacity((end - start).max(memsz));
149        data.extend_from_slice(&bytes[start..end]);
150        // bss: memsz > filesz, pad with zeros so reads there are defined.
151        if memsz > data.len() {
152            data.resize(memsz, 0);
153        }
154        segments.push(Segment {
155            vaddr,
156            bytes: data,
157            exec,
158            write,
159        });
160    }
161
162    let is_pie = elf.header.e_type == goblin::elf::header::ET_DYN;
163
164    let mut funcs = discover(&elf, bytes)?;
165    // sort + dedup by entry, prefer named entries
166    funcs.sort_by(|a, b| {
167        a.entry
168            .cmp(&b.entry)
169            .then(b.name.is_some().cmp(&a.name.is_some()))
170    });
171    funcs.dedup_by_key(|f| f.entry);
172
173    Ok(Loaded {
174        image: Image {
175            segments,
176            entry: elf.header.e_entry,
177            is_pie,
178        },
179        funcs,
180    })
181}
182
183fn discover(elf: &Elf, raw: &[u8]) -> Result<Vec<Func>> {
184    let mut out = Vec::new();
185
186    for (sym, src) in elf
187        .syms
188        .iter()
189        .map(|s| (s, FuncSource::Symtab))
190        .chain(elf.dynsyms.iter().map(|s| (s, FuncSource::DynSym)))
191    {
192        if sym.st_type() != goblin::elf::sym::STT_FUNC {
193            continue;
194        }
195        if sym.st_value == 0 || sym.st_size == 0 {
196            continue; // imports / plt stubs with no body
197        }
198        let name = match src {
199            FuncSource::Symtab => elf.strtab.get_at(sym.st_name),
200            _ => elf.dynstrtab.get_at(sym.st_name),
201        }
202        .map(|s| s.to_string())
203        .filter(|s| !s.is_empty());
204        out.push(Func {
205            name,
206            entry: sym.st_value,
207            size: sym.st_size,
208            source: src,
209        });
210    }
211
212    // stripped? lean on unwind info.
213    if out.is_empty() {
214        if let Some(mut fdes) = eh_frame_funcs(elf, raw) {
215            out.append(&mut fdes);
216        }
217    }
218
219    Ok(out)
220}
221
222// pull function start+length out of every FDE in .eh_frame.
223fn eh_frame_funcs(elf: &Elf, raw: &[u8]) -> Option<Vec<Func>> {
224    use gimli::{BaseAddresses, CieOrFde, EhFrame, LittleEndian, UnwindSection};
225
226    let sh = elf
227        .section_headers
228        .iter()
229        .find(|s| elf.shdr_strtab.get_at(s.sh_name) == Some(".eh_frame"))?;
230    // sh_offset/sh_size are attacker-controlled. try_from (None if too big for
231    // usize) + checked_add so a crafted size can't overflow the range and
232    // panic; get() handles past-EOF as None.
233    let start = usize::try_from(sh.sh_offset).ok()?;
234    let size = usize::try_from(sh.sh_size).ok()?;
235    let end = start.checked_add(size)?;
236    let data = raw.get(start..end)?;
237
238    let eh = EhFrame::new(data, LittleEndian);
239    let bases = BaseAddresses::default().set_eh_frame(sh.sh_addr);
240
241    let mut entries = eh.entries(&bases);
242    let mut out = Vec::new();
243    loop {
244        match entries.next() {
245            Ok(Some(CieOrFde::Fde(partial))) => {
246                if let Ok(fde) = partial.parse(EhFrame::cie_from_offset) {
247                    let entry = fde.initial_address();
248                    let size = fde.len();
249                    if size > 0 {
250                        out.push(Func {
251                            name: None,
252                            entry,
253                            size,
254                            source: FuncSource::EhFrame,
255                        });
256                    }
257                }
258            }
259            Ok(Some(CieOrFde::Cie(_))) => {}
260            Ok(None) => break,
261            Err(_) => break,
262        }
263    }
264    if out.is_empty() {
265        None
266    } else {
267        Some(out)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn garbage_input_errors_not_panics() {
277        assert!(load(b"").is_err());
278        assert!(load(b"not an elf at all, just text here").is_err());
279        // elf magic then garbage bytes
280        let mut junk = vec![0x7f, b'E', b'L', b'F'];
281        junk.extend(std::iter::repeat_n(0x41u8, 400));
282        let _ = load(&junk); // must return Err/Ok, never panic
283    }
284
285    #[test]
286    fn truncated_header_does_not_panic() {
287        let mut hdr = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0];
288        hdr.extend(std::iter::repeat_n(0u8, 48));
289        let _ = load(&hdr);
290    }
291
292    // minimal ELF64 x86-64 with exactly one PT_LOAD, so we can craft hostile
293    // program-header fields and prove the loader refuses them instead of
294    // panicking or allocating its way into an OOM.
295    fn craft_elf(p_offset: u64, p_vaddr: u64, p_filesz: u64, p_memsz: u64) -> Vec<u8> {
296        let mut e = vec![0u8; 64 + 56];
297        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
298        e[4] = 2; // ELFCLASS64
299        e[5] = 1; // ELFDATA2LSB
300        e[6] = 1; // EV_CURRENT
301        let put16 =
302            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
303        let put32 =
304            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
305        let put64 =
306            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
307        put16(&mut e, 16, 2); // e_type ET_EXEC
308        put16(&mut e, 18, 62); // e_machine EM_X86_64
309        put32(&mut e, 20, 1); // e_version
310        put64(&mut e, 32, 64); // e_phoff, header is 64 bytes
311        put16(&mut e, 52, 64); // e_ehsize
312        put16(&mut e, 54, 56); // e_phentsize
313        put16(&mut e, 56, 1); // e_phnum
314                              // program header at offset 64
315        let ph = 64;
316        put32(&mut e, ph, 1); // p_type PT_LOAD
317        put32(&mut e, ph + 4, 5); // p_flags R+X
318        put64(&mut e, ph + 8, p_offset);
319        put64(&mut e, ph + 16, p_vaddr);
320        put64(&mut e, ph + 32, p_filesz);
321        put64(&mut e, ph + 40, p_memsz);
322        put64(&mut e, ph + 48, 0x1000); // p_align
323        e
324    }
325
326    #[test]
327    fn huge_memsz_is_refused_not_allocated() {
328        // p_memsz near u64::MAX must error, never try to allocate ~16 EiB
329        let elf = craft_elf(0, 0x1000, 0, u64::MAX);
330        assert!(load(&elf).is_err());
331        // just over the cap is refused too
332        let elf = craft_elf(0, 0x1000, 0, MAX_SEG_MEM + 1);
333        assert!(load(&elf).is_err());
334    }
335
336    #[test]
337    fn vaddr_plus_memsz_overflow_is_refused() {
338        let elf = craft_elf(0, u64::MAX - 16, 0, 4096);
339        assert!(load(&elf).is_err());
340    }
341
342    // many overlapping file-backed PT_LOAD headers (p_memsz=0, huge p_filesz).
343    // each copies a full file window; the old cap only summed p_memsz so it never
344    // tripped and the loader would allocate n*filesize (hundreds of GB). the fix
345    // sums the real copy length and refuses before allocating anything.
346    fn craft_elf_many_load(n: u16) -> Vec<u8> {
347        let phoff = 64usize;
348        let file_len = phoff + (n as usize) * 56;
349        let mut e = vec![0u8; file_len];
350        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
351        e[4] = 2; // ELFCLASS64
352        e[5] = 1; // ELFDATA2LSB
353        e[6] = 1; // EV_CURRENT
354        let put16 =
355            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
356        let put32 =
357            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
358        let put64 =
359            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
360        put16(&mut e, 16, 2); // e_type ET_EXEC
361        put16(&mut e, 18, 62); // e_machine EM_X86_64
362        put32(&mut e, 20, 1);
363        put64(&mut e, 32, phoff as u64);
364        put16(&mut e, 52, 64);
365        put16(&mut e, 54, 56);
366        put16(&mut e, 56, n);
367        for i in 0..n as usize {
368            let ph = phoff + i * 56;
369            put32(&mut e, ph, 1); // PT_LOAD
370            put32(&mut e, ph + 4, 4); // R
371            put64(&mut e, ph + 8, 0); // p_offset = 0
372            put64(&mut e, ph + 16, 0x1000 + i as u64 * 0x1000); // distinct vaddr
373            put64(&mut e, ph + 32, file_len as u64); // p_filesz = whole file
374            put64(&mut e, ph + 40, 0); // p_memsz = 0, evades the old sum
375            put64(&mut e, ph + 48, 0x1000);
376        }
377        e
378    }
379
380    #[test]
381    fn overlapping_file_backed_segments_are_refused_not_allocated() {
382        // n * file_len copies far exceed MAX_TOTAL_MEM. must Err on accounting,
383        // never allocate its way there. (if this OOMs the test, the fix regressed.)
384        let elf = craft_elf_many_load(20000);
385        let r = load(&elf);
386        assert!(
387            r.is_err(),
388            "expected the alloc-amplification bomb to be refused"
389        );
390    }
391
392    #[test]
393    fn offset_past_eof_does_not_panic() {
394        // p_offset way past the file end must clamp, not slice-panic
395        let elf = craft_elf(0xffff_0000, 0x1000, 32, 32);
396        let _ = load(&elf); // Err or Ok, never a panic
397    }
398
399    #[test]
400    fn code_at_high_vaddr_no_overflow() {
401        // a segment near the top of the address space, then a read whose
402        // vaddr+len would wrap: must return None, not panic
403        let img = Image {
404            segments: vec![Segment {
405                vaddr: u64::MAX - 8,
406                bytes: vec![0u8; 8],
407                exec: true,
408                write: false,
409            }],
410            entry: 0,
411            is_pie: false,
412        };
413        assert!(img.code_at(u64::MAX - 4, 64).is_none());
414        assert!(img.code_at(u64::MAX, 16).is_none());
415    }
416}