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
183// a crafted ELF can point thousands of symbols at one long strtab run, so cloning
184// each name would be (symbol count * name length) bytes: a small file forcing a
185// huge allocation. cap both. real binaries are far under these (libcrypto ~6k
186// functions, symbol names tens of bytes); a name past the cap is truncated, and we
187// stop after MAX_FUNCS symbols. the segment loader's own MAX_TOTAL_MEM does not
188// cover the symbol table, so this is where that amplification is bounded.
189const MAX_NAME: usize = 4096;
190const MAX_FUNCS: usize = 1_000_000;
191
192fn clamp_name(s: &str) -> String {
193    if s.len() <= MAX_NAME {
194        return s.to_string();
195    }
196    let mut end = MAX_NAME;
197    while end > 0 && !s.is_char_boundary(end) {
198        end -= 1;
199    }
200    s[..end].to_string()
201}
202
203fn discover(elf: &Elf, raw: &[u8]) -> Result<Vec<Func>> {
204    let mut out = Vec::new();
205
206    for (sym, src) in elf
207        .syms
208        .iter()
209        .map(|s| (s, FuncSource::Symtab))
210        .chain(elf.dynsyms.iter().map(|s| (s, FuncSource::DynSym)))
211    {
212        if out.len() >= MAX_FUNCS {
213            break; // pathological symbol count; stop rather than amplify
214        }
215        if sym.st_type() != goblin::elf::sym::STT_FUNC {
216            continue;
217        }
218        if sym.st_value == 0 || sym.st_size == 0 {
219            continue; // imports / plt stubs with no body
220        }
221        let name = match src {
222            FuncSource::Symtab => elf.strtab.get_at(sym.st_name),
223            _ => elf.dynstrtab.get_at(sym.st_name),
224        }
225        .map(clamp_name)
226        .filter(|s| !s.is_empty());
227        out.push(Func {
228            name,
229            entry: sym.st_value,
230            size: sym.st_size,
231            source: src,
232        });
233    }
234
235    // stripped? lean on unwind info.
236    if out.is_empty() {
237        if let Some(mut fdes) = eh_frame_funcs(elf, raw) {
238            out.append(&mut fdes);
239        }
240    }
241
242    Ok(out)
243}
244
245// pull function start+length out of every FDE in .eh_frame.
246fn eh_frame_funcs(elf: &Elf, raw: &[u8]) -> Option<Vec<Func>> {
247    use gimli::{BaseAddresses, CieOrFde, EhFrame, LittleEndian, UnwindSection};
248
249    let sh = elf
250        .section_headers
251        .iter()
252        .find(|s| elf.shdr_strtab.get_at(s.sh_name) == Some(".eh_frame"))?;
253    // sh_offset/sh_size are attacker-controlled. try_from (None if too big for
254    // usize) + checked_add so a crafted size can't overflow the range and
255    // panic; get() handles past-EOF as None.
256    let start = usize::try_from(sh.sh_offset).ok()?;
257    let size = usize::try_from(sh.sh_size).ok()?;
258    let end = start.checked_add(size)?;
259    let data = raw.get(start..end)?;
260
261    let eh = EhFrame::new(data, LittleEndian);
262    let bases = BaseAddresses::default().set_eh_frame(sh.sh_addr);
263
264    let mut entries = eh.entries(&bases);
265    let mut out = Vec::new();
266    loop {
267        match entries.next() {
268            Ok(Some(CieOrFde::Fde(partial))) => {
269                if let Ok(fde) = partial.parse(EhFrame::cie_from_offset) {
270                    let entry = fde.initial_address();
271                    let size = fde.len();
272                    if size > 0 {
273                        out.push(Func {
274                            name: None,
275                            entry,
276                            size,
277                            source: FuncSource::EhFrame,
278                        });
279                    }
280                }
281            }
282            Ok(Some(CieOrFde::Cie(_))) => {}
283            Ok(None) => break,
284            Err(_) => break,
285        }
286    }
287    if out.is_empty() {
288        None
289    } else {
290        Some(out)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn garbage_input_errors_not_panics() {
300        assert!(load(b"").is_err());
301        assert!(load(b"not an elf at all, just text here").is_err());
302        // elf magic then garbage bytes
303        let mut junk = vec![0x7f, b'E', b'L', b'F'];
304        junk.extend(std::iter::repeat_n(0x41u8, 400));
305        let _ = load(&junk); // must return Err/Ok, never panic
306    }
307
308    #[test]
309    fn truncated_header_does_not_panic() {
310        let mut hdr = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0];
311        hdr.extend(std::iter::repeat_n(0u8, 48));
312        let _ = load(&hdr);
313    }
314
315    // minimal ELF64 x86-64 with exactly one PT_LOAD, so we can craft hostile
316    // program-header fields and prove the loader refuses them instead of
317    // panicking or allocating its way into an OOM.
318    fn craft_elf(p_offset: u64, p_vaddr: u64, p_filesz: u64, p_memsz: u64) -> Vec<u8> {
319        let mut e = vec![0u8; 64 + 56];
320        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
321        e[4] = 2; // ELFCLASS64
322        e[5] = 1; // ELFDATA2LSB
323        e[6] = 1; // EV_CURRENT
324        let put16 =
325            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
326        let put32 =
327            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
328        let put64 =
329            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
330        put16(&mut e, 16, 2); // e_type ET_EXEC
331        put16(&mut e, 18, 62); // e_machine EM_X86_64
332        put32(&mut e, 20, 1); // e_version
333        put64(&mut e, 32, 64); // e_phoff, header is 64 bytes
334        put16(&mut e, 52, 64); // e_ehsize
335        put16(&mut e, 54, 56); // e_phentsize
336        put16(&mut e, 56, 1); // e_phnum
337                              // program header at offset 64
338        let ph = 64;
339        put32(&mut e, ph, 1); // p_type PT_LOAD
340        put32(&mut e, ph + 4, 5); // p_flags R+X
341        put64(&mut e, ph + 8, p_offset);
342        put64(&mut e, ph + 16, p_vaddr);
343        put64(&mut e, ph + 32, p_filesz);
344        put64(&mut e, ph + 40, p_memsz);
345        put64(&mut e, ph + 48, 0x1000); // p_align
346        e
347    }
348
349    #[test]
350    fn huge_memsz_is_refused_not_allocated() {
351        // p_memsz near u64::MAX must error, never try to allocate ~16 EiB
352        let elf = craft_elf(0, 0x1000, 0, u64::MAX);
353        assert!(load(&elf).is_err());
354        // just over the cap is refused too
355        let elf = craft_elf(0, 0x1000, 0, MAX_SEG_MEM + 1);
356        assert!(load(&elf).is_err());
357    }
358
359    #[test]
360    fn vaddr_plus_memsz_overflow_is_refused() {
361        let elf = craft_elf(0, u64::MAX - 16, 0, 4096);
362        assert!(load(&elf).is_err());
363    }
364
365    // many overlapping file-backed PT_LOAD headers (p_memsz=0, huge p_filesz).
366    // each copies a full file window; the old cap only summed p_memsz so it never
367    // tripped and the loader would allocate n*filesize (hundreds of GB). the fix
368    // sums the real copy length and refuses before allocating anything.
369    fn craft_elf_many_load(n: u16) -> Vec<u8> {
370        let phoff = 64usize;
371        let file_len = phoff + (n as usize) * 56;
372        let mut e = vec![0u8; file_len];
373        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
374        e[4] = 2; // ELFCLASS64
375        e[5] = 1; // ELFDATA2LSB
376        e[6] = 1; // EV_CURRENT
377        let put16 =
378            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
379        let put32 =
380            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
381        let put64 =
382            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
383        put16(&mut e, 16, 2); // e_type ET_EXEC
384        put16(&mut e, 18, 62); // e_machine EM_X86_64
385        put32(&mut e, 20, 1);
386        put64(&mut e, 32, phoff as u64);
387        put16(&mut e, 52, 64);
388        put16(&mut e, 54, 56);
389        put16(&mut e, 56, n);
390        for i in 0..n as usize {
391            let ph = phoff + i * 56;
392            put32(&mut e, ph, 1); // PT_LOAD
393            put32(&mut e, ph + 4, 4); // R
394            put64(&mut e, ph + 8, 0); // p_offset = 0
395            put64(&mut e, ph + 16, 0x1000 + i as u64 * 0x1000); // distinct vaddr
396            put64(&mut e, ph + 32, file_len as u64); // p_filesz = whole file
397            put64(&mut e, ph + 40, 0); // p_memsz = 0, evades the old sum
398            put64(&mut e, ph + 48, 0x1000);
399        }
400        e
401    }
402
403    #[test]
404    fn overlapping_file_backed_segments_are_refused_not_allocated() {
405        // n * file_len copies far exceed MAX_TOTAL_MEM. must Err on accounting,
406        // never allocate its way there. (if this OOMs the test, the fix regressed.)
407        let elf = craft_elf_many_load(20000);
408        let r = load(&elf);
409        assert!(
410            r.is_err(),
411            "expected the alloc-amplification bomb to be refused"
412        );
413    }
414
415    #[test]
416    fn offset_past_eof_does_not_panic() {
417        // p_offset way past the file end must clamp, not slice-panic
418        let elf = craft_elf(0xffff_0000, 0x1000, 32, 32);
419        let _ = load(&elf); // Err or Ok, never a panic
420    }
421
422    #[test]
423    fn code_at_high_vaddr_no_overflow() {
424        // a segment near the top of the address space, then a read whose
425        // vaddr+len would wrap: must return None, not panic
426        let img = Image {
427            segments: vec![Segment {
428                vaddr: u64::MAX - 8,
429                bytes: vec![0u8; 8],
430                exec: true,
431                write: false,
432            }],
433            entry: 0,
434            is_pie: false,
435        };
436        assert!(img.code_at(u64::MAX - 4, 64).is_none());
437        assert!(img.code_at(u64::MAX, 16).is_none());
438    }
439}