Mach-O¶
Level: 201 · for anyone with a hex editor open
One line: A Mach-O header is written in its own machine's byte order, so 0xfeedfacf appears on disk as cf fa ed fe on x86 and a reader tells the order from which spelling it meets; a universal binary wraps such slices in a fat header that is big-endian regardless, so /bin/ls on a Mac carries both byte orders in one file.
What the loader reads¶
Mach-O is the executable, library and object format of macOS, iOS and every other Apple system, described in Apple's mach-o/loader.h ↗. A 64-bit header is 32 bytes and every field is a 32-bit integer:
| offset | field | what it says |
|---|---|---|
| 0 | magic |
0xfeedface for 32-bit, 0xfeedfacf for 64-bit — written in the file's own byte order |
| 4 | cputype |
7 = x86, 12 = ARM; 0x01000000 or-ed in for the 64-bit ABI, so x86-64 is 0x01000007 and arm64 0x0100000c |
| 8 | cpusubtype |
a variant within the type |
| 12 | filetype |
1 object, 2 executable, 6 dylib, 8 bundle, 10 dSYM |
| 16 | ncmds |
how many load commands follow |
| 20 | sizeofcmds |
how many bytes they take |
| 24 | flags |
|
| 28 | reserved |
64-bit only; the 32-bit header stops at 28 |
Then the load commands, each cmd, cmdsize, and a body: LC_SEGMENT_64 carries a 16-byte NUL-padded name and the addresses of a segment; LC_LOAD_DYLINKER and LC_LOAD_DYLIB carry a path as an offset into their own command, NUL-terminated there.
The magic is the interesting field, because it is not a byte sequence. Apple defines it as a number, and a number is written in some byte order. On a little-endian machine 0xfeedfacf becomes the bytes cf fa ed fe; on a big-endian PowerPC it was fe ed fa cf. A reader that always reads little-endian sees 0xfeedfacf on one and 0xcffaedfe on the other, and the header defines both — MH_MAGIC_64 and MH_CIGAM_64, "magic" spelled backwards — so that meeting the reversed constant is itself the answer to which end comes first. ELF puts a byte in the file to say it; Mach-O lets you read it off the first four.
In Python¶
Verified output of mach_o_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. ONE MAGIC, TWO SPELLINGS, FOUR CONSTANTS
------------------------------------------------------------------------
written on x86 (little) cf fa ed fe read LE 0xfeedfacf MH_MAGIC_64 64-bit, this reader's order
written on PowerPC (big) fe ed fa cf read LE 0xcffaedfe MH_CIGAM_64 64-bit, the other order
The value is 0xfeedfacf in both files. A reader that always reads
little-endian sees it forwards on one and backwards on the other,
and 'backwards' has a name: CIGAM. Ghidra reads the four bytes LE
and accepts all four constants, so the spelling IS the byte order.
2. A 64-BIT HEADER IS 32 BYTES, THEN THE LOAD COMMANDS
------------------------------------------------------------------------
bytes 0..32 cf fa ed fe 07 00 00 01 03 00 00 00 02 00 00 00
02 00 00 00 68 00 00 00 85 00 20 00 00 00 00 00
magic 0xfeedfacf 4277009103
cputype 0x01000007 16777223 CPU_ARCH_ABI64 | 7: the high byte says 64-bit, the low byte says x86
cpusubtype 0x00000003 3
filetype 0x00000002 2 MH_EXECUTE
ncmds 0x00000002 2
sizeofcmds 0x00000068 104
flags 0x00200085 2097285
reserved 0x00000000 0
load command 1 cmd 0x19 LC_SEGMENT_64 cmdsize 72
segname 5f 5f 54 45 58 54 00 00 00 00 00 00 00 00 00 00
b'__TEXT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' -> '__TEXT'
Sixteen bytes, NUL-padded, never NUL-terminated if the name is 16
long: strip the padding, do not read to a NUL.
load command 2 cmd 0xe LC_LOAD_DYLINKER cmdsize 32 name offset 12
the path '/usr/lib/dyld', NUL-terminated at 12 bytes into the command
3. A FAT HEADER IS BIG-ENDIAN, WHATEVER IS INSIDE IT
------------------------------------------------------------------------
bytes 0..8 ca fe ba be 00 00 00 02 magic 0xcafebabe nfat_arch 2
slice cputype subtype offset size align first bytes of the slice
0 0x01000007 3 4096 136 2^12 cf fa ed fe
1 0x0100000c 0 8192 136 2^12 cf fa ed fe
Two byte orders in one file: the fat header and its arch table are
big-endian on every machine, and each slice inside is a whole
Mach-O in its own order -- here cf fa ed fe, little-endian, twice.
4. CAFEBABE IS ALSO A JAVA CLASS, AND OFFSET 4 SETTLES IT
------------------------------------------------------------------------
this fat binary ca fe ba be 00 00 00 02 u32 at 4 = 2 a slice count
a Java 25 class file ca fe ba be 00 00 00 45 u32 at 4 = 69 a class version
file(1)'s rule is exactly that comparison: greater than 30 is Java.
The two formats never agreed to share; the number was Java's first.
5. THE SAME HEADER READ THE WRONG WAY
------------------------------------------------------------------------
magic as big-endian 0xcffaedfe MH_CIGAM_64 64-bit, the other order
cputype as big-endian 0x07000001 117440513
The magic tells a reader it has the order wrong; the cputype does
not, it is simply a different number. Read the magic first.
Two byte orders in one file¶
A universal binary — the fat in fat_header — is a container for several Mach-O files, one per architecture. Its own header is always big-endian: 0xcafebabe, a count, then twenty bytes per slice — cputype, cpusubtype, file offset, size and an alignment exponent, all big-endian. Each slice is a complete Mach-O at its offset, in whatever order its architecture uses, so an x86-64 and an arm64 slice are both little-endian inside a big-endian wrapper. Section 3 above builds one and reads it back with > for the outside and < for the inside, and /bin/ls on this Mac is exactly that shape:
00000000: cafe babe 0000 0002 0100 0007 0000 0003 ................
00000010: 0000 4000 0000 bc00 0000 000e 0100 000c ..@.............
fat_magic FAT_MAGIC nfat_arch 2
architecture x86_64 cputype CPU_TYPE_X86_64 offset 16384 size 48128 align 2^14
architecture arm64e cputype CPU_TYPE_ARM64 offset 65536 size 88672 align 2^14
Mach header (the x86_64 slice)
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777223 3 0x00 2 18 1736 0x00200085
0000 4000 is 16,384 read big-endian — the x86-64 slice starts at 0x4000, and its first four bytes are cf fa ed fe. otool prints the magic as the number, 0xfeedfacf, because it has already chosen the order. 16777223 is 0x01000007, the 64-bit bit plus 7.
Cafebabe, twice¶
ca fe ba be also opens every Java class file. Java had it first, and the collision is genuine — two unrelated formats, one signature — so a tool that reads magic numbers has to look one field further. Section 4 above shows the comparison, and it is the one file(1) makes:
0 belong 0xcafebabe
>4 ubelong >30 compiled Java class data,
# nfat_arch; number of CPU architectures; highest is 23 for CPU_TYPE_AIR in 2022
>4 ubelong >1
>>4 ubelong <25 Mach-O universal binary with %d architectures:
The four bytes after the magic are a slice count in one format and a version in the other, both big-endian, and the first Java class version ever released was 45. So greater than 30 is a class, less than 25 is a fat binary, and the file's own comment says the gap was chosen to leave room for Apple to add architectures.
What the strings are¶
Three kinds of string, three rules. A segment or section name is sixteen bytes, NUL-padded, and not NUL-terminated when it is sixteen long — __TEXT is 5f 5f 54 45 58 54 and ten zeros, and a reader that runs to a NUL from a full-length name reads into the next field. A dylib or dylinker path is an lc_str: a 32-bit offset from the start of its load command, and a NUL-terminated string at that offset — /usr/lib/dyld (offset 12) in otool's output means the path begins twelve bytes into the command. And a symbol name in the symbol table is an offset into a string table, exactly as ELF does it. Nothing in the header is length-prefixed, and nothing carries an encoding: the paths are bytes, and on a Mac the filesystem's rule about them applies (find, and filenames that are bytes).
What Ghidra checks¶
MachoLoader ↗ first asks whether the file is a universal binary and, if so, splits it into one provider per slice; then for each it reads four bytes little-endian and accepts the value if it is any of MH_MAGIC, MH_MAGIC_64, MH_CIGAM or MH_CIGAM_64. For a fat file every slice is offered as a preferred load spec, so the import dialog makes you choose an architecture. The name it shows is Mac OS X Mach-O, unchanged since the format was.
If you are coming from Python or ABAP¶
Python. Read the magic with struct.unpack('<I', b[:4]) and switch on the result: 0xfeedfacf means keep <, 0xcffaedfe means switch to >. That is a two-line decision, and mach_o_py.py makes it in a dictionary. For the fat wrapper use > unconditionally and >iiIII for each fat_arch; the cputype fields are signed in Apple's header, which is why the letter is i. macholib, which ships with py2app, does all of this and is not in the standard library; platform.mac_ver() tells you the machine's architecture and nothing about a file's.
ABAP. (Not machine-checked — CI cannot run ABAP.) There is no Mach-O on an application server, but the shape recurs in any interface that carries a wrapper and a payload from different systems: an outer record in one byte order around an inner record in another. Read the outer fields by assignment (big-endian), and reverse the inner slices before assigning them if the inner system was little-endian — and take the decision from a field in the data, as the magic here allows, never from the machine the job runs on, which is cl_abap_char_utilities=>endian and says nothing about the file.
Try it¶
xxd -l 8 /bin/lson a Mac, and on any.dylibunder/usr/libyou can find as a file. Say from the first four bytes whether each is fat or thin, and which way it is written.otool -f -von a fat one. Convert eachoffsetto hex andxxd -s <offset> -l 4there.otool -hon a thin one, thenotool -l | grep -A2 LC_LOAD_DYLINKER. Find the path's offset in the command and confirm it withxxd -s.lipo -infoon a fat binary, thenlipo -thin arm64e -output ls.arm64e /bin/ls. The thin file startscf fa ed feand has no fat header at all.- Compile
Hello.javaandxxd -l 8 Hello.classbesidexxd -l 8 /bin/ls. Same four bytes, and the next four settle it.
Practice¶
Five openings. The first eight bytes of five files:
cf fa ed fe 07 00 00 01
fe ed fa ce 00 00 00 12
ca fe ba be 00 00 00 02
ca fe ba be 00 00 00 41
ce fa ed fe 0c 00 00 00
For each: read the first four bytes as a little-endian number and say which of the four Mach-O constants it is, or that it is cafebabe; then say the width, the byte order the file was written in, and — for the two cafebabes — whether it is a fat binary or a Java class, and how you know.
Answers
Verified output of mach_o_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
bytes read LE verdict
cf fa ed fe 07 00 00 01 0xfeedfacf MH_MAGIC_64 64-bit, written little-endian
a 64-bit Mach-O written little-endian: x86-64 or arm64
fe ed fa ce 00 00 00 12 0xcefaedfe MH_CIGAM 32-bit, written big-endian
a 32-bit Mach-O written big-endian: PowerPC
ca fe ba be 00 00 00 02 0xbebafeca cafebabe u32 at 4 is 2: a fat binary
a fat binary with two slices
ca fe ba be 00 00 00 41 0xbebafeca cafebabe u32 at 4 is 65: a Java class
a Java class file, major version 65 = Java 21
ce fa ed fe 0c 00 00 00 0xfeedface MH_MAGIC 32-bit, written little-endian
a 32-bit Mach-O written little-endian: armv7
Read LE and look the number up: four values are Mach-O and the
spelling gives the order. 0xbebafeca is cafebabe backwards, which is
the fat magic seen by a little-endian reader -- and then the next
four bytes, always big-endian, say whether Java or a fat header.
See also¶
- ELF — the other answer to "which end": a byte in the file rather than a magic that reads backwards
- Java class file — the format that had
ca fe ba befirst, and what its next four bytes are - DYLD shared cache — where the Mach-O libraries
otool -Lnames have gone on a modern Mac - The bytes do not say which end — the general question, on a file with no magic to infer it from
- The first two bytes — the kernel's
execveon a Mac wantscf fa ed feorca fe ba beat offset 0, and that page measured/bin/lsthe day before this chapter did mach-o/loader.h↗ andmach-o/fat.h↗ — the two headers every field above was checked against