Skip to content

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 bothMH_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:

Measured 2026-09-13 — macOS 26.6.2, xxd -l 32 /bin/ls, then otool -f -v /bin/ls and otool -h /bin/ls, abridged. Not machine-checked: the file is a property of this macOS build
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:

/usr/share/file/magic/cafebabe — file-5.41, macOS 26.6.2, read 2026-09-13; two rules from the same file, with the maintainer's comment between them
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

  1. xxd -l 8 /bin/ls on a Mac, and on any .dylib under /usr/lib you can find as a file. Say from the first four bytes whether each is fat or thin, and which way it is written.
  2. otool -f -v on a fat one. Convert each offset to hex and xxd -s <offset> -l 4 there.
  3. otool -h on a thin one, then otool -l | grep -A2 LC_LOAD_DYLINKER. Find the path's offset in the command and confirm it with xxd -s.
  4. lipo -info on a fat binary, then lipo -thin arm64e -output ls.arm64e /bin/ls. The thin file starts cf fa ed fe and has no fat header at all.
  5. Compile Hello.java and xxd -l 8 Hello.class beside xxd -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 be first, and what its next four bytes are
  • DYLD shared cache — where the Mach-O libraries otool -L names 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 execve on a Mac wants cf fa ed fe or ca fe ba be at offset 0, and that page measured /bin/ls the day before this chapter did
  • mach-o/loader.h and mach-o/fat.h — the two headers every field above was checked against