Skip to main content

threatflux_binary_analysis/formats/
macho.rs

1//! Mach-O format parser for macOS/iOS binaries
2
3use crate::{
4    BinaryError, BinaryFormatParser, BinaryFormatTrait, Result,
5    types::{
6        Architecture, BinaryFormat as Format, BinaryMetadata, Endianness, Export, Import, Section,
7        SectionPermissions, SectionType, SecurityFeatures, Symbol, SymbolBinding, SymbolType,
8        SymbolVisibility,
9    },
10};
11use goblin::mach::{Mach, MachO};
12use goblin::mach::{
13    exports::{
14        EXPORT_SYMBOL_FLAGS_KIND_ABSOLUTE, EXPORT_SYMBOL_FLAGS_KIND_MASK,
15        ExportInfo as MachExportInfo,
16    },
17    header,
18    load_command::CommandVariant,
19};
20
21mod dyld_preflight;
22mod load_command_preflight;
23
24const FAT_MAGIC_32: [u8; 4] = [0xca, 0xfe, 0xba, 0xbe];
25const FAT_CIGAM_32: [u8; 4] = [0xbe, 0xba, 0xfe, 0xca];
26const FAT_MAGIC_64: [u8; 4] = [0xca, 0xfe, 0xba, 0xbf];
27const FAT_CIGAM_64: [u8; 4] = [0xbf, 0xba, 0xfe, 0xca];
28const FAT_HEADER_SIZE: usize = 8;
29const FAT_ARCH_32_SIZE: usize = 20;
30const FAT_ARCH_64_SIZE: usize = 32;
31const MAX_FAT_ARCHITECTURES: usize = 32;
32
33/// Mach-O format parser
34pub struct MachOParser;
35
36impl BinaryFormatParser for MachOParser {
37    fn parse(data: &[u8]) -> Result<Box<dyn BinaryFormatTrait>> {
38        if is_universal_macho(data) {
39            return Err(BinaryError::unsupported_format(
40                "Universal (fat) Mach-O binaries are not supported",
41            ));
42        }
43
44        // Goblin 0.10 uses unchecked host-sized arithmetic for selected load
45        // command strings, nlist names, and LC_MAIN address construction.
46        // Validate those operands from the raw bytes before invoking it.
47        load_command_preflight::validate(data)?;
48        let mach = Mach::parse(data)?;
49        match mach {
50            Mach::Binary(macho) => Ok(Box::new(MachOBinary::new(macho, data)?)),
51            Mach::Fat(_) => Err(BinaryError::unsupported_format(
52                "Universal (fat) Mach-O binaries are not supported",
53            )),
54        }
55    }
56
57    fn can_parse(data: &[u8]) -> bool {
58        if data.len() < 4 {
59            return false;
60        }
61
62        let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
63        matches!(
64            magic,
65            goblin::mach::header::MH_MAGIC
66                | goblin::mach::header::MH_CIGAM
67                | goblin::mach::header::MH_MAGIC_64
68                | goblin::mach::header::MH_CIGAM_64
69        ) || is_universal_macho(data)
70    }
71}
72
73fn is_universal_macho(data: &[u8]) -> bool {
74    let Some(header) = data.get(..FAT_HEADER_SIZE) else {
75        return false;
76    };
77
78    let magic = [header[0], header[1], header[2], header[3]];
79    let count_bytes = [header[4], header[5], header[6], header[7]];
80    let (architecture_count, architecture_size) = match magic {
81        FAT_MAGIC_32 => (u32::from_be_bytes(count_bytes), FAT_ARCH_32_SIZE),
82        FAT_CIGAM_32 => (u32::from_le_bytes(count_bytes), FAT_ARCH_32_SIZE),
83        FAT_MAGIC_64 => (u32::from_be_bytes(count_bytes), FAT_ARCH_64_SIZE),
84        FAT_CIGAM_64 => (u32::from_le_bytes(count_bytes), FAT_ARCH_64_SIZE),
85        _ => return false,
86    };
87
88    let Ok(architecture_count) = usize::try_from(architecture_count) else {
89        return false;
90    };
91    if !(1..=MAX_FAT_ARCHITECTURES).contains(&architecture_count) {
92        return false;
93    }
94
95    architecture_size
96        .checked_mul(architecture_count)
97        .and_then(|table_size| FAT_HEADER_SIZE.checked_add(table_size))
98        .is_some_and(|table_end| table_end <= data.len())
99}
100
101/// Parsed Mach-O binary
102pub struct MachOBinary {
103    metadata: BinaryMetadata,
104    sections: Vec<Section>,
105    symbols: Vec<Symbol>,
106    imports: Vec<Import>,
107    exports: Vec<Export>,
108}
109
110impl MachOBinary {
111    fn new(macho: MachO<'_>, data: &[u8]) -> Result<Self> {
112        let mut output_budget = super::ParseOutputBudget::default();
113        dyld_preflight::validate(&macho, data)?;
114
115        // Convert architecture
116        let architecture = match macho.header.cputype() {
117            goblin::mach::constants::cputype::CPU_TYPE_X86 => Architecture::X86,
118            goblin::mach::constants::cputype::CPU_TYPE_X86_64 => Architecture::X86_64,
119            goblin::mach::constants::cputype::CPU_TYPE_ARM => Architecture::Arm,
120            goblin::mach::constants::cputype::CPU_TYPE_ARM64 => Architecture::Arm64,
121            goblin::mach::constants::cputype::CPU_TYPE_POWERPC => Architecture::PowerPC,
122            goblin::mach::constants::cputype::CPU_TYPE_POWERPC64 => Architecture::PowerPC64,
123            _ => Architecture::Unknown,
124        };
125
126        // Determine endianness from the original data parsing
127        // Goblin normalizes magic numbers, so we need to check the raw bytes
128        let endian = if data.len() >= 4 {
129            let raw_magic_be = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
130
131            // Check for big endian magic (raw bytes match canonical form)
132            if raw_magic_be == header::MH_MAGIC || raw_magic_be == header::MH_MAGIC_64 {
133                Endianness::Big
134            } else {
135                // All other cases (including CIGAM variants) are little endian
136                Endianness::Little
137            }
138        } else {
139            Endianness::Little // Default for malformed data
140        };
141
142        // Analyze security features
143        let security_features = analyze_security_features(&macho);
144
145        let metadata = BinaryMetadata {
146            size: data.len(),
147            format: Format::MachO,
148            architecture,
149            entry_point: find_entry_point(&macho),
150            base_address: preferred_base_address(&macho),
151            timestamp: None, // Not readily available in Mach-O
152            compiler_info: None,
153            endian,
154            security_features,
155        };
156
157        // Parse sections
158        let sections = parse_sections(&macho, data, &mut output_budget)?;
159
160        // Parse symbols
161        let symbols = parse_symbols(&macho, &mut output_budget)?;
162
163        // Parse imports and exports
164        let (imports, exports) = parse_imports_exports(&macho, &mut output_budget)?;
165
166        Ok(Self {
167            metadata,
168            sections,
169            symbols,
170            imports,
171            exports,
172        })
173    }
174}
175
176impl BinaryFormatTrait for MachOBinary {
177    fn format_type(&self) -> Format {
178        Format::MachO
179    }
180
181    fn architecture(&self) -> Architecture {
182        self.metadata.architecture
183    }
184
185    fn entry_point(&self) -> Option<u64> {
186        self.metadata.entry_point
187    }
188
189    fn sections(&self) -> &[Section] {
190        &self.sections
191    }
192
193    fn symbols(&self) -> &[Symbol] {
194        &self.symbols
195    }
196
197    fn imports(&self) -> &[Import] {
198        &self.imports
199    }
200
201    fn exports(&self) -> &[Export] {
202        &self.exports
203    }
204
205    fn metadata(&self) -> &BinaryMetadata {
206        &self.metadata
207    }
208}
209
210fn parse_sections(
211    macho: &MachO,
212    data: &[u8],
213    output_budget: &mut super::ParseOutputBudget,
214) -> Result<Vec<Section>> {
215    use goblin::mach::constants::{
216        S_ATTR_DEBUG, S_GB_ZEROFILL, S_THREAD_LOCAL_ZEROFILL, S_ZEROFILL, SECTION_TYPE,
217    };
218
219    let mut sections = Vec::new();
220
221    for segment in &macho.segments {
222        for (section, _) in &segment.sections()? {
223            output_budget.reserve_record(&mut sections, "Mach-O sections")?;
224            let name = output_budget
225                .copy_name(section.name().unwrap_or("unknown"), "Mach-O section name")?;
226
227            // Determine section type based on native flags before applying name
228            // heuristics. Zero-fill sections have no bytes in the file.
229            let native_section_type = section.flags & SECTION_TYPE;
230            let is_zero_fill = matches!(
231                native_section_type,
232                S_ZEROFILL | S_GB_ZEROFILL | S_THREAD_LOCAL_ZEROFILL
233            );
234            let section_type = if is_zero_fill {
235                SectionType::Bss
236            } else if section.flags & S_ATTR_DEBUG != 0 {
237                SectionType::Debug
238            } else if section.flags & goblin::mach::constants::S_ATTR_PURE_INSTRUCTIONS != 0
239                || name.starts_with("__text")
240            {
241                SectionType::Code
242            } else if name.starts_with("__data") {
243                SectionType::Data
244            } else if name.starts_with("__const") || name.starts_with("__rodata") {
245                SectionType::ReadOnlyData
246            } else if name.starts_with("__bss") {
247                SectionType::Bss
248            } else if name.starts_with("__debug") {
249                SectionType::Debug
250            } else {
251                SectionType::Other("MACHO_SECTION".to_string())
252            };
253
254            // Mach-O section permissions are inherited from segment
255            let permissions = SectionPermissions {
256                read: segment.initprot & 0x1 != 0,    // VM_PROT_READ
257                write: segment.initprot & 0x2 != 0,   // VM_PROT_WRITE
258                execute: segment.initprot & 0x4 != 0, // VM_PROT_EXECUTE
259            };
260
261            let has_file_data = !is_zero_fill && section.size != 0;
262            if has_file_data
263                && super::checked_file_range(data, u64::from(section.offset), section.size)
264                    .is_none()
265            {
266                return Err(BinaryError::invalid_data(format!(
267                    "Mach-O section '{name}' range {}..+{} exceeds the file",
268                    section.offset, section.size
269                )));
270            }
271
272            // Extract small section data
273            let section_data = if has_file_data {
274                super::inline_section_data(data, u64::from(section.offset), section.size)
275            } else {
276                None
277            };
278
279            sections.push(Section {
280                name,
281                address: section.addr,
282                size: section.size,
283                offset: section.offset as u64,
284                file_size: if has_file_data { section.size } else { 0 },
285                permissions,
286                section_type,
287                data: section_data,
288            });
289        }
290    }
291
292    Ok(sections)
293}
294
295fn parse_symbols(
296    macho: &MachO,
297    output_budget: &mut super::ParseOutputBudget,
298) -> Result<Vec<Symbol>> {
299    use goblin::mach::symbols::{N_FUN, N_OSO, N_PEXT, N_SECT, N_SO, N_SOL, N_STAB, N_UNDF};
300
301    let mut symbols = Vec::new();
302
303    for entry in macho.symbols() {
304        let (name, native) = entry?;
305        if name.is_empty() {
306            continue;
307        }
308        output_budget.reserve_record(&mut symbols, "Mach-O symbols")?;
309
310        let symbol_type = if native.n_type & N_STAB != 0 {
311            match native.n_type {
312                N_FUN => SymbolType::Function,
313                N_SO | N_OSO | N_SOL => SymbolType::File,
314                other => SymbolType::Other(format!("N_STAB_{other:#04x}")),
315            }
316        } else {
317            match native.get_type() {
318                N_SECT => SymbolType::Other("N_SECT".to_string()),
319                N_UNDF => SymbolType::Other("N_UNDF".to_string()),
320                other => SymbolType::Other(format!("MACH_N_TYPE_{other:#04x}")),
321            }
322        };
323
324        let binding = if native.is_weak() {
325            SymbolBinding::Weak
326        } else if native.is_global() {
327            SymbolBinding::Global
328        } else {
329            SymbolBinding::Local
330        };
331
332        symbols.push(Symbol {
333            name: output_budget.copy_name(name, "Mach-O symbol name")?,
334            demangled_name: None,
335            address: native.n_value,
336            // Mach-O nlist records do not carry symbol sizes.
337            size: 0,
338            symbol_type,
339            binding,
340            visibility: if native.n_type & N_PEXT != 0 {
341                SymbolVisibility::Internal
342            } else {
343                SymbolVisibility::Default
344            },
345            // Mach-O section ordinals are one-based; the public representation
346            // uses zero-based vector indexes like the other parsers.
347            section_index: native.n_sect.checked_sub(1),
348        });
349    }
350
351    Ok(symbols)
352}
353
354fn parse_imports_exports(
355    macho: &MachO,
356    output_budget: &mut super::ParseOutputBudget,
357) -> crate::types::ImportExportResult {
358    let mut imports = Vec::new();
359    let mut exports = Vec::new();
360    let image_base = preferred_base_address(macho).unwrap_or(0);
361
362    // Parse imports from bind info
363    for import in &macho.imports()? {
364        output_budget.reserve_record(&mut imports, "Mach-O imports")?;
365        imports.push(Import {
366            name: output_budget.copy_name(import.name, "Mach-O import name")?,
367            library: Some(output_budget.copy_name(import.dylib, "Mach-O import library")?),
368            address: Some(import.address),
369            ordinal: None,
370        });
371    }
372
373    // Parse exports from export info
374    for export in &macho.exports()? {
375        output_budget.reserve_record(&mut exports, "Mach-O exports")?;
376        let name = output_budget.copy_name(&export.name, "Mach-O export name")?;
377        let (relative_address, should_rebase, forwarded_name) = match &export.info {
378            MachExportInfo::Regular { flags, .. } => (
379                export.offset,
380                flags & EXPORT_SYMBOL_FLAGS_KIND_MASK != EXPORT_SYMBOL_FLAGS_KIND_ABSOLUTE,
381                None,
382            ),
383            MachExportInfo::Stub { stub_offset, .. } => (u64::from(*stub_offset), true, None),
384            MachExportInfo::Reexport {
385                lib,
386                lib_symbol_name,
387                ..
388            } => (
389                0,
390                false,
391                Some(macho_reexport_name(
392                    output_budget,
393                    lib,
394                    *lib_symbol_name,
395                    name.as_str(),
396                )?),
397            ),
398        };
399        let address = if forwarded_name.is_some() {
400            0
401        } else {
402            resolved_export_address(image_base, relative_address, should_rebase, &name)?
403        };
404
405        exports.push(Export {
406            name,
407            address,
408            ordinal: None,
409            forwarded_name,
410        });
411    }
412
413    Ok((imports, exports))
414}
415
416fn resolved_export_address(
417    image_base: u64,
418    address: u64,
419    should_rebase: bool,
420    name: &str,
421) -> Result<u64> {
422    if !should_rebase {
423        return Ok(address);
424    }
425
426    image_base.checked_add(address).ok_or_else(|| {
427        BinaryError::invalid_data(format!("Mach-O export '{name}' address overflows u64"))
428    })
429}
430
431fn macho_reexport_name(
432    output_budget: &mut super::ParseOutputBudget,
433    lib: &str,
434    symbol_name: Option<&str>,
435    exported_name: &str,
436) -> Result<String> {
437    output_budget.copy_name_parts(
438        &[lib, ":", symbol_name.unwrap_or(exported_name)],
439        "Mach-O reexport name",
440    )
441}
442
443fn analyze_security_features(macho: &MachO) -> SecurityFeatures {
444    let mut features = SecurityFeatures::default();
445
446    // Check file type and flags for security features
447    let flags = macho.header.flags;
448
449    // PIE (Position Independent Executable)
450    features.pie = flags & goblin::mach::header::MH_PIE != 0;
451
452    // ASLR is generally enabled with PIE on macOS
453    features.aslr = features.pie;
454
455    // The absence of MH_ALLOW_STACK_EXECUTION requests a non-executable stack;
456    // MH_NO_HEAP_EXECUTION independently requests a non-executable heap.
457    features.nx_bit =
458        flags & header::MH_ALLOW_STACK_EXECUTION == 0 || flags & header::MH_NO_HEAP_EXECUTION != 0;
459
460    // Check for stack canaries (would need more complex analysis)
461    features.stack_canary = false; // Would need to analyze for __stack_chk_guard
462
463    // This records the presence of a code-signature load command. It does not
464    // cryptographically validate the signature or establish trust.
465    features.signed = macho
466        .load_commands
467        .iter()
468        .any(|load_command| matches!(&load_command.command, CommandVariant::CodeSignature(_)));
469
470    features
471}
472
473fn find_entry_point(macho: &MachO) -> Option<u64> {
474    (macho.entry != 0).then_some(macho.entry)
475}
476
477fn preferred_base_address(macho: &MachO) -> Option<u64> {
478    macho
479        .segments
480        .iter()
481        .find_map(|segment| {
482            (segment.filesize != 0 && segment.name().is_ok_and(|name| name == "__TEXT"))
483                .then(|| segment.vmaddr.checked_sub(segment.fileoff))
484                .flatten()
485        })
486        .or_else(|| {
487            macho
488                .segments
489                .iter()
490                .filter(|segment| segment.filesize != 0)
491                .filter_map(|segment| segment.vmaddr.checked_sub(segment.fileoff))
492                .min()
493        })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn universal_header(magic: [u8; 4], count: u32, little_endian: bool, is_64: bool) -> Vec<u8> {
501        let record_size = if is_64 {
502            FAT_ARCH_64_SIZE
503        } else {
504            FAT_ARCH_32_SIZE
505        };
506        let mut data = vec![0; FAT_HEADER_SIZE + record_size * count as usize];
507        data[..4].copy_from_slice(&magic);
508        let count_bytes = if little_endian {
509            count.to_le_bytes()
510        } else {
511            count.to_be_bytes()
512        };
513        data[4..8].copy_from_slice(&count_bytes);
514        data
515    }
516
517    fn macho_with_segments(segments: &[(&str, u64, u64, u64)]) -> Vec<u8> {
518        const HEADER_SIZE: usize = 32;
519        const SEGMENT_COMMAND_SIZE: usize = 72;
520
521        let commands_size = SEGMENT_COMMAND_SIZE * segments.len();
522        let mut data = vec![0_u8; HEADER_SIZE + commands_size];
523        data[..4].copy_from_slice(&header::MH_MAGIC_64.to_le_bytes());
524        data[4..8].copy_from_slice(&0x0100_0007_u32.to_le_bytes());
525        data[8..12].copy_from_slice(&3_u32.to_le_bytes());
526        data[12..16].copy_from_slice(&2_u32.to_le_bytes());
527        data[16..20].copy_from_slice(&(segments.len() as u32).to_le_bytes());
528        data[20..24].copy_from_slice(&(commands_size as u32).to_le_bytes());
529
530        for (index, (name, vmaddr, fileoff, filesize)) in segments.iter().enumerate() {
531            let offset = HEADER_SIZE + index * SEGMENT_COMMAND_SIZE;
532            data[offset..offset + 4].copy_from_slice(&0x19_u32.to_le_bytes());
533            data[offset + 4..offset + 8]
534                .copy_from_slice(&(SEGMENT_COMMAND_SIZE as u32).to_le_bytes());
535            data[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes());
536            data[offset + 24..offset + 32].copy_from_slice(&vmaddr.to_le_bytes());
537            data[offset + 32..offset + 40].copy_from_slice(&0x1000_u64.to_le_bytes());
538            data[offset + 40..offset + 48].copy_from_slice(&fileoff.to_le_bytes());
539            data[offset + 48..offset + 56].copy_from_slice(&filesize.to_le_bytes());
540        }
541
542        let required_file_size = segments
543            .iter()
544            .filter_map(|(_, _, fileoff, filesize)| fileoff.checked_add(*filesize))
545            .filter_map(|end| usize::try_from(end).ok())
546            .max()
547            .unwrap_or(data.len());
548        data.resize(data.len().max(required_file_size), 0);
549
550        data
551    }
552
553    fn parse_thin_macho(data: &[u8]) -> MachO<'_> {
554        match Mach::parse(data).expect("test Mach-O must parse") {
555            Mach::Binary(macho) => macho,
556            Mach::Fat(_) => panic!("expected a thin Mach-O"),
557        }
558    }
559
560    #[test]
561    fn universal_header_recognition_is_bounded_and_supports_both_widths() {
562        assert!(is_universal_macho(&universal_header(
563            FAT_MAGIC_32,
564            2,
565            false,
566            false
567        )));
568        assert!(is_universal_macho(&universal_header(
569            FAT_CIGAM_32,
570            2,
571            true,
572            false
573        )));
574        assert!(is_universal_macho(&universal_header(
575            FAT_MAGIC_64,
576            1,
577            false,
578            true
579        )));
580        assert!(is_universal_macho(&universal_header(
581            FAT_CIGAM_64,
582            1,
583            true,
584            true
585        )));
586
587        assert!(!is_universal_macho(&FAT_MAGIC_32));
588        assert!(!is_universal_macho(&universal_header(
589            FAT_MAGIC_32,
590            0,
591            false,
592            false
593        )));
594        assert!(!is_universal_macho(&universal_header(
595            FAT_MAGIC_32,
596            (MAX_FAT_ARCHITECTURES + 1) as u32,
597            false,
598            false
599        )));
600    }
601
602    #[test]
603    fn preferred_base_uses_file_backed_text_segment() {
604        let data = macho_with_segments(&[
605            ("__PAGEZERO", 0, 0, 0),
606            ("__DATA", 0x9000, 0x1000, 0x100),
607            ("__TEXT", 0x1_0000_4000, 0x4000, 0x100),
608        ]);
609        let macho = parse_thin_macho(&data);
610
611        assert_eq!(preferred_base_address(&macho), Some(0x1_0000_0000));
612    }
613
614    #[test]
615    fn preferred_base_falls_back_to_lowest_file_backed_segment() {
616        let data = macho_with_segments(&[
617            ("__PAGEZERO", 0, 0, 0),
618            ("__LINKEDIT", 0xa000, 0x1000, 0x100),
619            ("__DATA", 0x9000, 0x1000, 0x100),
620        ]);
621        let macho = parse_thin_macho(&data);
622
623        assert_eq!(preferred_base_address(&macho), Some(0x8000));
624    }
625
626    #[test]
627    fn export_addresses_are_rebased_with_checked_arithmetic() {
628        assert_eq!(
629            resolved_export_address(0x1_0000_0000, 0x1234, true, "_entry").unwrap(),
630            0x1_0000_1234
631        );
632        assert_eq!(
633            resolved_export_address(0x1_0000_0000, 0x1234, false, "_absolute").unwrap(),
634            0x1234
635        );
636        assert!(resolved_export_address(u64::MAX, 1, true, "_overflow").is_err());
637    }
638
639    #[test]
640    fn reexports_retain_library_and_target_symbol() {
641        let mut output_budget = crate::formats::ParseOutputBudget::default();
642        assert_eq!(
643            macho_reexport_name(
644                &mut output_budget,
645                "/usr/lib/libSystem.B.dylib",
646                Some("_malloc"),
647                "_alloc"
648            )
649            .unwrap(),
650            "/usr/lib/libSystem.B.dylib:_malloc"
651        );
652        assert_eq!(
653            macho_reexport_name(
654                &mut output_budget,
655                "/usr/lib/libSystem.B.dylib",
656                None,
657                "_malloc"
658            )
659            .unwrap(),
660            "/usr/lib/libSystem.B.dylib:_malloc"
661        );
662    }
663}