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/// cap on the number of PT_LOAD segments. total-memory is not enough on its own:
79/// thousands of tiny segments stay under MAX_TOTAL_MEM but each becomes a qemu
80/// memory region at emulate time, and softmmu cost is super-linear in region
81/// count, so a few-hundred-KB crafted ELF can drive minutes of CPU. a real
82/// binary has a handful of PT_LOADs; 256 is far above anything legit.
83const MAX_LOAD_SEGS: usize = 256;
84
85pub fn load(bytes: &[u8]) -> Result<Loaded> {
86    let elf = Elf::parse(bytes).context("not a valid elf")?;
87    if elf.header.e_machine != goblin::elf::header::EM_X86_64 {
88        bail!(
89            "only x86-64 is supported in this version (got e_machine {})",
90            elf.header.e_machine
91        );
92    }
93
94    // first pass: validate every PT_LOAD and sum what it WILL allocate, WITHOUT
95    // allocating anything yet. the total-memory guard has to count the actual
96    // bytes each segment holds, which is the file-window copy (end-start) grown to
97    // the bss tail (memsz), not p_memsz alone. a header with p_memsz=0 but a huge
98    // p_filesz still copies a whole file window, so many overlapping ones would
99    // amplify past the cap if we only counted memsz. computing the sum before any
100    // copy means such a bomb is rejected outright instead of ballooning up to the
101    // cap and only then bailing.
102    let mut plans: Vec<(usize, usize, usize, u64, bool, bool)> = Vec::new();
103    let mut total_mem: u64 = 0;
104    for ph in &elf.program_headers {
105        if ph.p_type != goblin::elf::program_header::PT_LOAD {
106            continue;
107        }
108        // refuse a bss claim we won't allocate for (loader bomb)
109        if ph.p_memsz > MAX_SEG_MEM {
110            bail!(
111                "PT_LOAD p_memsz {} over {}-byte limit, refusing",
112                ph.p_memsz,
113                MAX_SEG_MEM
114            );
115        }
116        // a vaddr+memsz that wraps u64 is nonsense and would overflow the
117        // page math downstream, reject it here at the boundary.
118        if ph.p_vaddr.checked_add(ph.p_memsz).is_none() {
119            bail!("PT_LOAD vaddr {:#x} + memsz overflows", ph.p_vaddr);
120        }
121
122        // clamp the file window: p_offset past EOF must not slice-panic. try_from
123        // so a value too big for usize (32-bit target) clamps to EOF instead of
124        // truncating past the .min() guard.
125        let start = usize::try_from(ph.p_offset)
126            .unwrap_or(usize::MAX)
127            .min(bytes.len());
128        let fsz = usize::try_from(ph.p_filesz).unwrap_or(usize::MAX);
129        let end = start.saturating_add(fsz).min(bytes.len());
130        // p_memsz is already capped under MAX_SEG_MEM above, so it fits usize.
131        let memsz = usize::try_from(ph.p_memsz).unwrap_or(usize::MAX);
132
133        let alloc_len = (end - start).max(memsz); // end >= start, no wrap
134        total_mem = total_mem.saturating_add(alloc_len as u64);
135        if total_mem > MAX_TOTAL_MEM {
136            bail!("total PT_LOAD memory over {}-byte limit", MAX_TOTAL_MEM);
137        }
138        plans.push((
139            start,
140            end,
141            memsz,
142            ph.p_vaddr,
143            ph.is_executable(),
144            ph.is_write(),
145        ));
146        if plans.len() > MAX_LOAD_SEGS {
147            bail!("over {} PT_LOAD segments, refusing", MAX_LOAD_SEGS);
148        }
149    }
150    if plans.is_empty() {
151        bail!("no PT_LOAD segments");
152    }
153
154    // second pass: now that the total is known-bounded, actually copy.
155    let mut segments = Vec::with_capacity(plans.len());
156    for (start, end, memsz, vaddr, exec, write) in plans {
157        let mut data = Vec::with_capacity((end - start).max(memsz));
158        data.extend_from_slice(&bytes[start..end]);
159        // bss: memsz > filesz, pad with zeros so reads there are defined.
160        if memsz > data.len() {
161            data.resize(memsz, 0);
162        }
163        segments.push(Segment {
164            vaddr,
165            bytes: data,
166            exec,
167            write,
168        });
169    }
170
171    let is_pie = elf.header.e_type == goblin::elf::header::ET_DYN;
172
173    let mut funcs = discover(&elf, bytes)?;
174    // sort + dedup by entry, prefer named entries
175    funcs.sort_by(|a, b| {
176        a.entry
177            .cmp(&b.entry)
178            .then(b.name.is_some().cmp(&a.name.is_some()))
179    });
180    funcs.dedup_by_key(|f| f.entry);
181
182    Ok(Loaded {
183        image: Image {
184            segments,
185            entry: elf.header.e_entry,
186            is_pie,
187        },
188        funcs,
189    })
190}
191
192// a crafted ELF can point thousands of symbols at one long strtab run, so cloning
193// each name would be (symbol count * name length) bytes: a small file forcing a
194// huge allocation. cap both. real binaries are far under these (libcrypto ~6k
195// functions, symbol names tens of bytes); a name past the cap is truncated, and we
196// stop after MAX_FUNCS symbols. the segment loader's own MAX_TOTAL_MEM does not
197// cover the symbol table, so this is where that amplification is bounded.
198const MAX_NAME: usize = 4096;
199const MAX_FUNCS: usize = 1_000_000;
200
201fn clamp_name(s: &str) -> String {
202    if s.len() <= MAX_NAME {
203        return s.to_string();
204    }
205    let mut end = MAX_NAME;
206    while end > 0 && !s.is_char_boundary(end) {
207        end -= 1;
208    }
209    s[..end].to_string()
210}
211
212fn discover(elf: &Elf, raw: &[u8]) -> Result<Vec<Func>> {
213    let mut out = Vec::new();
214
215    for (sym, src) in elf
216        .syms
217        .iter()
218        .map(|s| (s, FuncSource::Symtab))
219        .chain(elf.dynsyms.iter().map(|s| (s, FuncSource::DynSym)))
220    {
221        if out.len() >= MAX_FUNCS {
222            break; // pathological symbol count; stop rather than amplify
223        }
224        if sym.st_type() != goblin::elf::sym::STT_FUNC {
225            continue;
226        }
227        if sym.st_value == 0 || sym.st_size == 0 {
228            continue; // imports / plt stubs with no body
229        }
230        let name = match src {
231            FuncSource::Symtab => elf.strtab.get_at(sym.st_name),
232            _ => elf.dynstrtab.get_at(sym.st_name),
233        }
234        .map(clamp_name)
235        .filter(|s| !s.is_empty());
236        out.push(Func {
237            name,
238            entry: sym.st_value,
239            size: sym.st_size,
240            source: src,
241        });
242    }
243
244    // stripped? lean on unwind info.
245    if out.is_empty() {
246        if let Some(mut fdes) = eh_frame_funcs(elf, raw) {
247            out.append(&mut fdes);
248        }
249    }
250
251    Ok(out)
252}
253
254// pull function start+length out of every FDE in .eh_frame.
255fn eh_frame_funcs(elf: &Elf, raw: &[u8]) -> Option<Vec<Func>> {
256    use gimli::{BaseAddresses, CieOrFde, EhFrame, LittleEndian, UnwindSection};
257
258    let sh = elf
259        .section_headers
260        .iter()
261        .find(|s| elf.shdr_strtab.get_at(s.sh_name) == Some(".eh_frame"))?;
262    // sh_offset/sh_size are attacker-controlled. try_from (None if too big for
263    // usize) + checked_add so a crafted size can't overflow the range and
264    // panic; get() handles past-EOF as None.
265    let start = usize::try_from(sh.sh_offset).ok()?;
266    let size = usize::try_from(sh.sh_size).ok()?;
267    let end = start.checked_add(size)?;
268    let data = raw.get(start..end)?;
269
270    let eh = EhFrame::new(data, LittleEndian);
271    let bases = BaseAddresses::default().set_eh_frame(sh.sh_addr);
272
273    let mut entries = eh.entries(&bases);
274    let mut out = Vec::new();
275    loop {
276        match entries.next() {
277            Ok(Some(CieOrFde::Fde(partial))) => {
278                if let Ok(fde) = partial.parse(EhFrame::cie_from_offset) {
279                    let entry = fde.initial_address();
280                    let size = fde.len();
281                    if size > 0 {
282                        out.push(Func {
283                            name: None,
284                            entry,
285                            size,
286                            source: FuncSource::EhFrame,
287                        });
288                    }
289                }
290            }
291            Ok(Some(CieOrFde::Cie(_))) => {}
292            Ok(None) => break,
293            Err(_) => break,
294        }
295        // same cap discover() applies: a crafted .eh_frame full of tiny FDEs
296        // must not force an unbounded func list here where symtab's cap misses.
297        if out.len() >= MAX_FUNCS {
298            break;
299        }
300    }
301    if out.is_empty() {
302        None
303    } else {
304        Some(out)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn garbage_input_errors_not_panics() {
314        assert!(load(b"").is_err());
315        assert!(load(b"not an elf at all, just text here").is_err());
316        // elf magic then garbage bytes
317        let mut junk = vec![0x7f, b'E', b'L', b'F'];
318        junk.extend(std::iter::repeat_n(0x41u8, 400));
319        let _ = load(&junk); // must return Err/Ok, never panic
320    }
321
322    #[test]
323    fn truncated_header_does_not_panic() {
324        let mut hdr = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0];
325        hdr.extend(std::iter::repeat_n(0u8, 48));
326        let _ = load(&hdr);
327    }
328
329    // minimal ELF64 x86-64 with exactly one PT_LOAD, so we can craft hostile
330    // program-header fields and prove the loader refuses them instead of
331    // panicking or allocating its way into an OOM.
332    fn craft_elf(p_offset: u64, p_vaddr: u64, p_filesz: u64, p_memsz: u64) -> Vec<u8> {
333        let mut e = vec![0u8; 64 + 56];
334        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
335        e[4] = 2; // ELFCLASS64
336        e[5] = 1; // ELFDATA2LSB
337        e[6] = 1; // EV_CURRENT
338        let put16 =
339            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
340        let put32 =
341            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
342        let put64 =
343            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
344        put16(&mut e, 16, 2); // e_type ET_EXEC
345        put16(&mut e, 18, 62); // e_machine EM_X86_64
346        put32(&mut e, 20, 1); // e_version
347        put64(&mut e, 32, 64); // e_phoff, header is 64 bytes
348        put16(&mut e, 52, 64); // e_ehsize
349        put16(&mut e, 54, 56); // e_phentsize
350        put16(&mut e, 56, 1); // e_phnum
351                              // program header at offset 64
352        let ph = 64;
353        put32(&mut e, ph, 1); // p_type PT_LOAD
354        put32(&mut e, ph + 4, 5); // p_flags R+X
355        put64(&mut e, ph + 8, p_offset);
356        put64(&mut e, ph + 16, p_vaddr);
357        put64(&mut e, ph + 32, p_filesz);
358        put64(&mut e, ph + 40, p_memsz);
359        put64(&mut e, ph + 48, 0x1000); // p_align
360        e
361    }
362
363    #[test]
364    fn huge_memsz_is_refused_not_allocated() {
365        // p_memsz near u64::MAX must error, never try to allocate ~16 EiB
366        let elf = craft_elf(0, 0x1000, 0, u64::MAX);
367        assert!(load(&elf).is_err());
368        // just over the cap is refused too
369        let elf = craft_elf(0, 0x1000, 0, MAX_SEG_MEM + 1);
370        assert!(load(&elf).is_err());
371    }
372
373    #[test]
374    fn vaddr_plus_memsz_overflow_is_refused() {
375        let elf = craft_elf(0, u64::MAX - 16, 0, 4096);
376        assert!(load(&elf).is_err());
377    }
378
379    // many overlapping file-backed PT_LOAD headers (p_memsz=0, huge p_filesz).
380    // each copies a full file window; the old cap only summed p_memsz so it never
381    // tripped and the loader would allocate n*filesize (hundreds of GB). the fix
382    // sums the real copy length and refuses before allocating anything.
383    fn craft_elf_many_load(n: u16) -> Vec<u8> {
384        let phoff = 64usize;
385        let file_len = phoff + (n as usize) * 56;
386        let mut e = vec![0u8; file_len];
387        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
388        e[4] = 2; // ELFCLASS64
389        e[5] = 1; // ELFDATA2LSB
390        e[6] = 1; // EV_CURRENT
391        let put16 =
392            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
393        let put32 =
394            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
395        let put64 =
396            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
397        put16(&mut e, 16, 2); // e_type ET_EXEC
398        put16(&mut e, 18, 62); // e_machine EM_X86_64
399        put32(&mut e, 20, 1);
400        put64(&mut e, 32, phoff as u64);
401        put16(&mut e, 52, 64);
402        put16(&mut e, 54, 56);
403        put16(&mut e, 56, n);
404        for i in 0..n as usize {
405            let ph = phoff + i * 56;
406            put32(&mut e, ph, 1); // PT_LOAD
407            put32(&mut e, ph + 4, 4); // R
408            put64(&mut e, ph + 8, 0); // p_offset = 0
409            put64(&mut e, ph + 16, 0x1000 + i as u64 * 0x1000); // distinct vaddr
410            put64(&mut e, ph + 32, file_len as u64); // p_filesz = whole file
411            put64(&mut e, ph + 40, 0); // p_memsz = 0, evades the old sum
412            put64(&mut e, ph + 48, 0x1000);
413        }
414        e
415    }
416
417    // n PT_LOADs each tiny (filesz 16, memsz 0x1000) so total memory stays far
418    // under MAX_TOTAL_MEM. isolates the per-count cap from the byte cap.
419    fn craft_elf_nsegs_small(n: u16) -> Vec<u8> {
420        let phoff = 64usize;
421        let file_len = phoff + (n as usize) * 56;
422        let mut e = vec![0u8; file_len];
423        e[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
424        e[4] = 2;
425        e[5] = 1;
426        e[6] = 1;
427        let put16 =
428            |e: &mut [u8], off: usize, v: u16| e[off..off + 2].copy_from_slice(&v.to_le_bytes());
429        let put32 =
430            |e: &mut [u8], off: usize, v: u32| e[off..off + 4].copy_from_slice(&v.to_le_bytes());
431        let put64 =
432            |e: &mut [u8], off: usize, v: u64| e[off..off + 8].copy_from_slice(&v.to_le_bytes());
433        put16(&mut e, 16, 2);
434        put16(&mut e, 18, 62);
435        put32(&mut e, 20, 1);
436        put64(&mut e, 32, phoff as u64);
437        put16(&mut e, 52, 64);
438        put16(&mut e, 54, 56);
439        put16(&mut e, 56, n);
440        for i in 0..n as usize {
441            let ph = phoff + i * 56;
442            put32(&mut e, ph, 1); // PT_LOAD
443            put32(&mut e, ph + 4, 4); // R
444            put64(&mut e, ph + 8, 0); // p_offset
445            put64(&mut e, ph + 16, 0x1000 + i as u64 * 0x1000); // distinct vaddr
446            put64(&mut e, ph + 32, 16); // small p_filesz
447            put64(&mut e, ph + 40, 0x1000); // p_memsz
448            put64(&mut e, ph + 48, 0x1000);
449        }
450        e
451    }
452
453    #[test]
454    fn too_many_small_segments_refused_by_count() {
455        // ~300 tiny segments: total memory is ~1.2 MB (well under MAX_TOTAL_MEM),
456        // so only the per-count cap can catch this region-flood. must be refused
457        // rather than turned into hundreds of qemu memory regions at emulate time.
458        let elf = craft_elf_nsegs_small(300);
459        assert!(
460            load(&elf).is_err(),
461            "expected the segment-count cap to refuse the region flood"
462        );
463    }
464
465    #[test]
466    fn overlapping_file_backed_segments_are_refused_not_allocated() {
467        // n * file_len copies far exceed MAX_TOTAL_MEM. must Err on accounting,
468        // never allocate its way there. (if this OOMs the test, the fix regressed.)
469        let elf = craft_elf_many_load(20000);
470        let r = load(&elf);
471        assert!(
472            r.is_err(),
473            "expected the alloc-amplification bomb to be refused"
474        );
475    }
476
477    #[test]
478    fn offset_past_eof_does_not_panic() {
479        // p_offset way past the file end must clamp, not slice-panic
480        let elf = craft_elf(0xffff_0000, 0x1000, 32, 32);
481        let _ = load(&elf); // Err or Ok, never a panic
482    }
483
484    #[test]
485    fn code_at_high_vaddr_no_overflow() {
486        // a segment near the top of the address space, then a read whose
487        // vaddr+len would wrap: must return None, not panic
488        let img = Image {
489            segments: vec![Segment {
490                vaddr: u64::MAX - 8,
491                bytes: vec![0u8; 8],
492                exec: true,
493                write: false,
494            }],
495            entry: 0,
496            is_pie: false,
497        };
498        assert!(img.code_at(u64::MAX - 4, 64).is_none());
499        assert!(img.code_at(u64::MAX, 16).is_none());
500    }
501}