1import io
2from enum import Enum, IntEnum
3from pathlib import Path
4
5import attrs
6
7from unblob.file_utils import (
8 Endian,
9 File,
10 FileSystem,
11 InvalidInputFormat,
12 StructParser,
13)
14from unblob.models import (
15 ExtractResult,
16 HandlerDoc,
17 HandlerType,
18 HexString,
19 Reference,
20 StructHandler,
21 ValidChunk,
22)
23
24
25class MachOMagic(bytes, Enum):
26 """Mach-O magics as they appear on disk (first 4 bytes).
27
28 Fat headers are always big-endian; a thin magic is byte-swapped ("cigam")
29 when the binary's endianness is big.
30 """
31
32 FAT = b"\xca\xfe\xba\xbe" # fat / universal binary
33 THIN_64_LE = b"\xcf\xfa\xed\xfe" # 64-bit little-endian (arm64, x86_64)
34 THIN_32_LE = b"\xce\xfa\xed\xfe" # 32-bit little-endian (armv7, x86)
35 THIN_64_BE = b"\xfe\xed\xfa\xcf" # 64-bit big-endian
36 THIN_32_BE = b"\xfe\xed\xfa\xce" # 32-bit big-endian
37
38
39# Header sizes in bytes
40_HEADER_SIZE_32 = 28
41_HEADER_SIZE_64 = 32 # adds a 4-byte reserved field
42
43# Maps (cputype, cpusubtype) to a human-readable name for fat binary slice filenames
44_CPU_ARCH_NAMES: dict[tuple[int, int], str] = {
45 (0xC, 5): "arm_v4t",
46 (0xC, 6): "arm_v6",
47 (0xC, 9): "arm_v7",
48 (0xC, 11): "arm_v7s",
49 (0xC, 12): "arm_v7k",
50 (0x100000C, 0): "arm64",
51 (0x100000C, 1): "arm64_v8",
52 (0x100000C, 2): "arm64e",
53 (0x7, 3): "i386",
54 (0x1000007, 3): "x86_64",
55 (0x12, 0): "ppc",
56 (0x1000012, 0): "ppc64",
57}
58
59# Single source of truth for every Mach-O structure, shared by the handler's struct parser
60# and the fat binary extractor below.
61C_DEFINITIONS = r"""
62 typedef struct macho_header {
63 uint32 magic;
64 uint32 cputype;
65 uint32 cpusubtype;
66 uint32 filetype;
67 uint32 ncmds;
68 uint32 sizeofcmds;
69 uint32 flags;
70 } macho_header_t;
71
72 typedef struct macho_load_command {
73 uint32 cmd;
74 uint32 cmdsize;
75 } macho_load_command_t;
76
77 // Covers both LC_SEGMENT (32-bit) and LC_SEGMENT_64 via a union. Only the fields up to
78 // filesize are modelled (all we need to compute file extents), so the struct is 56 bytes
79 // and never reads past the smaller 32-bit segment command.
80 typedef struct macho_segment_command {
81 uint32 cmd;
82 uint32 cmdsize;
83 char segname[16];
84 union {
85 struct {
86 uint32 vmaddr;
87 uint32 vmsize;
88 uint32 fileoff;
89 uint32 filesize;
90 } bits32;
91 struct {
92 uint64 vmaddr;
93 uint64 vmsize;
94 uint64 fileoff;
95 uint64 filesize;
96 } bits64;
97 } seg;
98 } macho_segment_command_t;
99
100 typedef struct fat_header {
101 uint32 magic; // 0xcafebabe, always big-endian
102 uint32 nfat_arch; // number of architecture slices
103 } fat_header_t;
104
105 typedef struct fat_arch {
106 uint32 cputype; // CPU architecture type
107 uint32 cpusubtype; // CPU subtype
108 uint32 offset; // file offset to this architecture's Mach-O slice
109 uint32 slice_size; // size of this architecture's Mach-O slice
110 uint32 align; // slice alignment as a power of 2
111 } fat_arch_t;
112"""
113
114_parser = StructParser(C_DEFINITIONS)
115
116
117def _cpu_arch_name(cputype: int, cpusubtype: int) -> str:
118 return _CPU_ARCH_NAMES.get(
119 (cputype, cpusubtype), f"cputype_{cputype:#x}_{cpusubtype:#x}"
120 )
121
122
123class LoadCommandType(IntEnum):
124 SEGMENT = 0x1
125 SEGMENT_64 = 0x19
126
127
128@attrs.define(repr=False)
129class MachOChunk(ValidChunk):
130 """A fat (universal) Mach-O binary whose architecture slices are carved out on extraction."""
131
132 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
133 fs = FileSystem(outdir)
134 with File.from_path(inpath) as file:
135 fat_hdr = _parser.parse("fat_header_t", file, Endian.BIG)
136 architectures = [
137 _parser.parse("fat_arch_t", file, Endian.BIG)
138 for _ in range(fat_hdr.nfat_arch)
139 ]
140 for arch in architectures:
141 if arch.slice_size == 0:
142 continue
143 cpu_name = _cpu_arch_name(arch.cputype, arch.cpusubtype)
144 fs.carve(
145 Path(f"arch_{cpu_name}.macho"), file, arch.offset, arch.slice_size
146 )
147 return ExtractResult(reports=fs.problems)
148
149
150class MachOHandler(StructHandler):
151 NAME = "macho"
152
153 EXTRACTOR = None
154
155 PATTERNS = [
156 # Thin Mach-O: magic (4) + [24] wildcards = full 28-byte common header guaranteed present,
157 # avoiding false positives from partial matches and removing the need for truncation checks.
158 HexString("CF FA ED FE [24]"), # 64-bit little-endian (arm64, x86_64)
159 HexString("CE FA ED FE [24]"), # 32-bit little-endian (armv7, x86)
160 HexString("FE ED FA CF [24]"), # 64-bit big-endian
161 HexString("FE ED FA CE [24]"), # 32-bit big-endian
162 # Fat (universal) binary:
163 # CA FE BA BE — fat magic (always big-endian)
164 # 00 00 00 (01-08) — nfat_arch 1-8; limits to <=8 architectures which
165 # disambiguates from Java class files (major version ≥ 45)
166 HexString("CA FE BA BE 00 00 00 (01|02|03|04|05|06|07|08)"),
167 ]
168
169 C_DEFINITIONS = C_DEFINITIONS
170 HEADER_STRUCT = "macho_header_t"
171
172 DOC = HandlerDoc(
173 name="Mach-O",
174 description="Mach-O (Mach Object) is the native executable and library binary format used by macOS, iOS, tvOS, and watchOS. It encodes load commands describing memory segments, dynamic libraries, and code signing, and supports multiple CPU architectures via fat/universal binaries.",
175 handler_type=HandlerType.EXECUTABLE,
176 vendor="Apple",
177 references=[
178 Reference(
179 title="Mach-O Programming Topics",
180 url="https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/MachOTopics/0-Introduction/introduction.html",
181 ),
182 ],
183 limitations=[],
184 )
185
186 def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
187 magic = file[start_offset : start_offset + 4]
188 match magic:
189 case MachOMagic.FAT:
190 return self._calculate_fat_chunk(file, start_offset)
191 case MachOMagic.THIN_64_LE:
192 endian, is_64bit = Endian.LITTLE, True
193 case MachOMagic.THIN_32_LE:
194 endian, is_64bit = Endian.LITTLE, False
195 case MachOMagic.THIN_64_BE:
196 endian, is_64bit = Endian.BIG, True
197 case MachOMagic.THIN_32_BE:
198 endian, is_64bit = Endian.BIG, False
199 case _:
200 raise InvalidInputFormat(f"Unknown Mach-O magic: {magic.hex()}")
201 return self._calculate_thin_chunk(file, start_offset, endian, is_64bit=is_64bit)
202
203 def _calculate_thin_chunk(
204 self, file: File, start_offset: int, endian: Endian, *, is_64bit: bool
205 ) -> ValidChunk:
206 header = self.parse_header(file, endian)
207
208 # 64-bit headers have an extra 4-byte reserved field after flags
209 hdr_size = _HEADER_SIZE_64 if is_64bit else _HEADER_SIZE_32
210 file.seek(start_offset + hdr_size, io.SEEK_SET)
211
212 end_offset = start_offset + hdr_size + header.sizeofcmds
213
214 for _ in range(header.ncmds):
215 lc_start = file.tell()
216 lc = self._struct_parser.parse("macho_load_command_t", file, endian)
217
218 if lc.cmdsize < 8:
219 raise InvalidInputFormat(
220 f"Mach-O load command size too small: {lc.cmdsize}"
221 )
222
223 if lc.cmd in (LoadCommandType.SEGMENT, LoadCommandType.SEGMENT_64):
224 file.seek(lc_start, io.SEEK_SET)
225 command = self._struct_parser.parse(
226 "macho_segment_command_t", file, endian
227 )
228 seg = (
229 command.seg.bits64
230 if lc.cmd == LoadCommandType.SEGMENT_64
231 else command.seg.bits32
232 )
233 if seg.filesize > 0:
234 end_offset = max(
235 end_offset, start_offset + seg.fileoff + seg.filesize
236 )
237
238 file.seek(lc_start + lc.cmdsize, io.SEEK_SET)
239
240 # Thin Mach-O binaries are leaf files: the handler has no extractor, so the carved
241 # chunk is preserved as-is (see Handler.extract).
242 return ValidChunk(start_offset=start_offset, end_offset=end_offset)
243
244 def _calculate_fat_chunk(self, file: File, start_offset: int) -> MachOChunk:
245 # Fat headers are always big-endian regardless of the contained architectures
246 fat_hdr = self._struct_parser.parse("fat_header_t", file, Endian.BIG)
247
248 end_offset = start_offset
249 for _ in range(fat_hdr.nfat_arch):
250 arch = self._struct_parser.parse("fat_arch_t", file, Endian.BIG)
251 if arch.slice_size > 0:
252 end_offset = max(
253 end_offset, start_offset + arch.offset + arch.slice_size
254 )
255
256 return MachOChunk(start_offset=start_offset, end_offset=end_offset)