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