Blogs
Mach-O Binary

Mach-O Binary

High level architecture of a Mach-O binary. Learned through implementing application support parsing iOS binary
Minh-Tri Le
Minh-Tri Le
Mar 14, 2025 · 19 min read

01Binary file layout

Mach-O is the executable format used across macOS and iOS - for executables, dynamic libraries, bundles, kernel extensions and object files alike. Every one of them is the same three regions in the same order: a fixed-size header, a list of variable-size load commands, and the raw segment data those commands describe.

The header identifies the file and says how many load commands follow. The load commands are the interesting part: they are a table of contents for everything else, declaring which chunks of the file get mapped into memory and where, which libraries must be loaded, where the symbol table lives, and where the code signature sits. The data region is just the bytes those commands point at. Nothing in a Mach-O file is found by scanning for it - every offset is declared somewhere in a load command.

Mach-O binary layout
Mach-O binary layout

Ref: Medium blog

Universal (fat) binaries

There is one wrinkle before any of that applies. A universal binary - the thing that lets one file run on both Apple silicon and Intel, or ship arm64 and arm64e together - is not itself a Mach-O file. It is a thin container: a fat_header, then one fat_arch record per architecture, each pointing at a complete, independent Mach-O at some byte offset inside the file. This is the structure lipo manipulates and that App Store thinning strips down.

c

struct fat_header { uint32_t magic; /* FAT_MAGIC (0xcafebabe) or FAT_MAGIC_64 */ uint32_t nfat_arch; /* number of fat_arch structs that follow */ }; struct fat_arch { int32_t cputype; /* which architecture this slice is for */ int32_t cpusubtype; uint32_t offset; /* file offset to this slice's Mach-O */ uint32_t size; /* size of the slice */ uint32_t align; /* alignment, as a power of 2 */ };

Two details here cause more parsing bugs than anything else in the format. First, fat structures are stored big-endian, always, regardless of the architecture they describe - which is the opposite of the rule that governs the Mach-O header itself. That is why a little-endian machine reads the magic as 0xbebafeca and why every tool accepts both spellings. Second, every offset inside a slice - segment file offsets, symbol table offsets, the code signature offset - is relative to the start of that slice, not to the start of the file. Add fat_arch.offset or everything lands in the wrong place.



03Load commands

A load command is a tagged record: a 4-byte cmd saying what kind it is, a 4-byte cmdsize saying how long it is, and then a payload whose shape depends on cmd. cmdsize must be a multiple of 4 on 32-bit and 8 on 64-bit, with any slack zero-padded. A parser that doesn’t recognise a command can skip it by its size and carry on - unless the high bit LC_REQ_DYLD (0x80000000) is set, which means the opposite: if you don’t understand this one, refuse to load the image rather than ignore it. That is why LC_DYLD_INFO_ONLY is 0x80000022 - there are no classic relocations left as a fallback, so a linker that can’t read it cannot safely continue.

Here is the list from a real binary, abridged - the repetitive section records and the duplicate LC_LOAD_DYLIB entries are elided, everything else is as otool printed it. Even shortened it makes the point: this is the entire table of contents of an executable.

shell

MyApp: Load command 0 cmd LC_SEGMENT cmdsize 56 segname __PAGEZERO vmaddr 0x00000000 vmsize 0x00001000 fileoff 0 filesize 0 maxprot 0x00000000 initprot 0x00000000 nsects 0 flags 0x0 Load command 1 cmd LC_SEGMENT cmdsize 668 segname __TEXT vmaddr 0x00001000 vmsize 0x00046000 fileoff 0 filesize 286720 maxprot 0x00000005 initprot 0x00000005 nsects 9 flags 0x0 Section sectname __text segname __TEXT addr 0x00002530 size 0x00035274 offset 5424 align 2^3 (8) reloff 0 nreloc 0 flags 0x80000400 reserved1 0 reserved2 0 Section sectname __cstring segname __TEXT addr 0x0003d420 size 0x0000767d offset 246816 align 2^0 (1) reloff 0 nreloc 0 flags 0x00000002 reserved1 0 reserved2 0 ... 7 more sections in __TEXT: __stub_helper, __objc_methname, __objc_classname, __objc_methtype, __gcc_except_tab, __const, __symbolstub1 ... Load command 2 cmd LC_SEGMENT cmdsize 1484 segname __DATA vmaddr 0x00047000 vmsize 0x0000e000 fileoff 286720 filesize 57344 maxprot 0x00000003 initprot 0x00000003 nsects 21 flags 0x0 Section sectname __data segname __DATA addr 0x000546f0 size 0x000003a0 offset 341744 align 2^4 (16) reloff 0 nreloc 0 flags 0x00000000 reserved1 0 reserved2 0 Section sectname __bss segname __DATA addr 0x00054af0 size 0x0000019c offset 0 align 2^4 (16) reloff 0 nreloc 0 flags 0x00000001 reserved1 0 reserved2 0 ... 19 more sections in __DATA: __lazy_symbol, __nl_symbol_ptr, __mod_init_func, the __objc_* metadata, __cfstring, __common ... Load command 3 cmd LC_SEGMENT cmdsize 56 segname __LINKEDIT vmaddr 0x00055000 vmsize 0x00048000 fileoff 344064 filesize 291504 maxprot 0x00000001 initprot 0x00000001 nsects 0 flags 0x0 Load command 4 cmd LC_DYLD_INFO_ONLY cmdsize 48 rebase_off 344064 rebase_size 2848 bind_off 346912 bind_size 5116 weak_bind_off 0 weak_bind_size 0 lazy_bind_off 352028 lazy_bind_size 5236 export_off 357264 export_size 10052 Load command 5 cmd LC_SYMTAB cmdsize 24 symoff 369516 nsyms 7722 stroff 464092 strsize 158508 Load command 6 cmd LC_DYSYMTAB cmdsize 80 ilocalsym 0 nlocalsym 6900 iextdefsym 6900 nextdefsym 483 iundefsym 7383 nundefsym 339 tocoff 0 ntoc 0 modtaboff 0 nmodtab 0 extrefsymoff 0 nextrefsyms 0 indirectsymoff 462180 nindirectsyms 478 extreloff 0 nextrel 0 locreloff 0 nlocrel 0 Load command 7 cmd LC_LOAD_DYLINKER cmdsize 28 name /usr/lib/dyld (offset 12) Load command 8 cmd LC_UUID cmdsize 24 uuid 00000000-0000-0000-0000-000000000000 Load command 9 cmd LC_VERSION_MIN_IPHONEOS cmdsize 16 version 5.0 sdk 6.1 Load command 10 cmd LC_UNIXTHREAD cmdsize 84 flavor ARM_THREAD_STATE count ARM_THREAD_STATE_COUNT r0 0x00000000 r1 0x00000000 r2 0x00000000 r3 0x00000000 r4 0x00000000 r5 0x00000000 r6 0x00000000 r7 0x00000000 r8 0x00000000 r9 0x00000000 r10 0x00000000 r11 0x00000000 r12 0x00000000 sp 0x00000000 lr 0x00000000 pc 0x00002530 cpsr 0x00000000 Load command 11 cmd LC_ENCRYPTION_INFO cmdsize 20 cryptoff 4096 cryptsize 282624 cryptid 0 Load command 12 cmd LC_LOAD_DYLIB cmdsize 84 name /System/Library/Frameworks/QuartzCore.framework/QuartzCore (offset 24) time stamp 2 Thu Jan 1 08:00:02 1970 current version 1.8.0 compatibility version 1.2.0 ... Load commands 13-19: 7 more LC_LOAD_DYLIB for CoreGraphics, CFNetwork, UIKit, Foundation, libobjc.A.dylib, libSystem.B.dylib, CoreFoundation ... Load command 20 cmd LC_FUNCTION_STARTS cmdsize 16 dataoff 367316 datasize 1624 Load command 21 cmd LC_DATA_IN_CODE cmdsize 16 dataoff 368940 datasize 576 Load command 22 cmd LC_CODE_SIGNATURE cmdsize 16 dataoff 622608 datasize 12960

Worth naming what this dump is, because running otool -l on a current app will look different. It shows LC_SEGMENT rather than LC_SEGMENT_64, an LC_UNIXTHREAD carrying ARM_THREAD_STATE with registers r0r12, and LC_VERSION_MIN_IPHONEOS version 5.0 - a 32-bit armv7 iOS binary from around the iOS 6 era. The structure is identical on modern arm64; only the command variants differ.

shell

Load command 1 cmd LC_SEGMENT cmdsize 668 segname __TEXT vmaddr 0x00001000 vmsize 0x00046000 fileoff 0 filesize 286720 maxprot 0x00000005 initprot 0x00000005 nsects 9 flags 0x0 ... Load command 22 cmd LC_CODE_SIGNATURE cmdsize 16 dataoff 622608 <---- offset of SuperBlob datasize 12960

Segments, and the sections inside them

A segment is a run of file bytes to be mapped into memory. Sections are the named subdivisions inside it - __text, __cstring and so on - and crucially they are not separate load commands. The section records sit inline inside the segment command, which is why the segment’s cmdsize grows with its section count. On 32-bit that works out to 56 + nsects × 68 bytes.

That is checkable against the dump above. Load command 1 is __TEXT with nsects 9 and cmdsize 668 - and 56 + 9 × 68 = 668. Load command 2 is __DATA with nsects 21 and cmdsize 1484 - and 56 + 21 × 68 = 1484. The numbers on your screen add up exactly.

Overview structure of a segment

FieldExplanation
cmdThe load command type - LC_SEGMENT here, or LC_SEGMENT_64 on a 64-bit binary
vmaddrVirtual address the segment is mapped at. ASLR adds a constant slide to this
vmsizeSize of the mapping in memory - which can be larger than filesize
cmdsizeTotal size of this load command, including section headers that follow
segnameName of the segment
fileoffOffset from beginning of the file where the data of this segment starts
filesizeSize of the segment’s data
maxprotCeiling on protection - a later mprotect may raise up to this, never past it
initprotProtection applied when the segment is first mapped
nsectsNumber of sections contained within this segment
flagsSegment-specific flags

vmsize being larger than filesize is not a mistake - it is how zero-initialised data works. The linker never stores long runs of zeros on disk, so the difference is allocated as anonymous zeroed memory at load time; that is __bss and __common. __PAGEZERO is the extreme case, with filesize 0 and no bytes on disk at all.

The two protection fields are a pair worth reading carefully. initprot is what the segment gets when it is mapped; maxprot is a hard ceiling that a later mprotect can raise up to but never past. They decode from VM_PROT_READ = 1, WRITE = 2, EXECUTE = 4, which makes the values in the dump readable at a glance: __PAGEZERO at 0x0 is no access at all, __TEXT at 0x5 is read + execute, __DATA at 0x3 is read + write, and __LINKEDIT at 0x1 is read-only.

The four segments you will always see

__PAGEZERO exists purely to be unmapped. It reserves the lowest addresses with no access rights whatsoever, so any dereference of NULL - or of a small offset from it - faults immediately instead of quietly reading valid memory. It costs nothing on disk. The size is a linker default rather than a format requirement: 4 KB on 32-bit, and on 64-bit the whole low 4 GB, which has the extra benefit that no zero-extended 32-bit value can ever be a valid pointer.

__TEXT is read + execute and holds everything immutable: the machine code in __text, C string literals in __cstring, the lazy-binding trampolines in __stubs and __stub_helper. Note its fileoff is 0 - it maps the Mach header and the load commands themselves. __DATA is read + write and holds everything that changes.

__LINKEDIT is the odd one out and the one that matters for the rest of this article. It is read-only, and its nsects is zero - it has no sections at all. It is a single opaque blob of bytes that other load commands reach into by file offset: the symbol and string tables via LC_SYMTAB, the rebase and bind opcode streams via LC_DYLD_INFO_ONLY, function starts, data-in-code, and - the destination of this whole article - the code signature via LC_CODE_SIGNATURE.

The commands in this binary

Load commandWhat it does
LC_SEGMENTMaps a segment from file into memory; section records follow inline
LC_DYLD_INFO_ONLYCompressed rebase / bind / lazy-bind / export opcode streams for dyld
LC_SYMTABWhere the symbol table and string table live in __LINKEDIT
LC_DYSYMTABPartitions that symbol table into local / defined / undefined ranges
LC_LOAD_DYLINKERPath of the dynamic linker to run - normally /usr/lib/dyld
LC_UUIDA 128-bit build identifier; the key that matches a binary to its .dSYM
LC_VERSION_MIN_IPHONEOSMinimum OS and SDK version. Superseded by LC_BUILD_VERSION
LC_UNIXTHREADInitial register state including the program counter - the entry point
LC_ENCRYPTION_INFODescribes the byte range covered by FairPlay encryption
LC_LOAD_DYLIBDeclares one required shared library, with its version constraints
LC_FUNCTION_STARTSCompressed table of function entry addresses - finds functions with no symbols
LC_DATA_IN_CODERanges inside __text that are data, so disassemblers do not decode them
LC_CODE_SIGNATUREFile offset and size of the embedded code signature in __LINKEDIT

Two of these have modern replacements you will meet instead on a current binary. LC_BUILD_VERSION replaced the four separate LC_VERSION_MIN_* commands with one that also records which platform. And LC_MAIN replaced LC_UNIXTHREAD for the entry point - the difference is instructive. LC_UNIXTHREAD carries a whole machine-specific thread state whose program counter is an absolute address, so it has to be relocated by the ASLR slide, and it is the kernel that acts on it. LC_MAIN instead carries entryoff, a file offset from the Mach header, which needs no relocation at all - and it is dyld that handles it, which is exactly why it carries the LC_REQ_DYLD bit. Statically linked binaries, having no dyld, still use LC_UNIXTHREAD.

Encryption, and the pointer to the signature

LC_ENCRYPTION_INFO is where App Store DRM shows up. cryptid is an encryption-system identifier rather than a boolean - Apple’s header describes 0 as “not encrypted yet” - and the linker emits the command with the range precomputed and cryptid 0, which is what this dump shows. Apple’s submission pipeline encrypts those bytes in place and sets the field. The consequence for anyone inspecting a released app is that the bytes in that range are ciphertext on disk while the running image is plaintext in memory.

LC_CODE_SIGNATURE is the last command in the list, and it is deliberately simple: a dataoff and a datasize, where dataoff is a file offset - not a virtual address, and not relative to __LINKEDIT. In this binary that is 622608 and 12960, so the signature occupies bytes 622,608 through 635,568 - precisely the range the diagram in the next section describes. Everything from here on lives inside those twelve kilobytes.

If you want to follow along on a binary of your own:

CommandDescription
otool -h <binary>Print just the Mach-O header - magic, cputype, filetype, flags
otool -l <binary>Print every load command, which is the dump shown above
otool -L <binary>List the dynamic libraries the binary links against
lipo -info <binary>Show which architecture slices a universal binary contains
lipo <binary> -thin arm64 -output <out>Extract a single slice so the offsets stop being container-relative

04SuperBlob - Mach-O embedded signature

The Mach-O embedded code signature consists of several logical components, each of which has its own unique purpose. Each of these components are represented as a blob and holds whatever information it requires to serve its purpose.

Structures referenced here: cscdefs.h. That file is a stub, though - for the full set of constants the canonical source is cs_blobs.h in the kernel.

Before any of the structures make sense, one rule governs all of them: every integer in the code signature is big-endian, including the offsets. Apple’s blob.h says so in a comment and then enforces it in the type system - the fields are declared Endian<uint32_t>, a wrapper that byte-swaps on access. This is the single most common way a hand-written parser goes wrong, because the surrounding Mach-O is host-endian: the dataoff in LC_CODE_SIGNATURE is little-endian on every machine you own, and the magic it points at is not. The two conventions meet exactly at that boundary.

c

class Security::Blob { uint32_t _magic; /* Magic Value Kind of Component 0xfade0c02 CodeDirectory (including alternate code directories) 0xfade0c01 CodeRequirement 0xfade0b01 CMS Blob ... */ uint32_t _length; }; class Security::SuperBlob : public Security::Blob { struct Index { uint32_t _type; // type of sub-blob uint32_t _offset; // offset of sub-blob (from start of superblob) }; uint32_t _count; // number of sub-blobs struct Index _index[ _count ]; // _count index entries };

Every blob starts with the same 8-byte header - a magic and a length - and the length includes those 8 bytes. A SuperBlob adds a count and then that many { type, offset } index entries. Two practical notes: the offsets are relative to the start of the SuperBlob, not the file and not the blob being pointed at; and the structures are declared __attribute__((aligned(1))), so there is no padding anywhere and you cannot rely on natural alignment when casting.

MagicBlob type
0xfade0cc0Embedded signature - the SuperBlob itself, what dataoff points at
0xfade0c02CodeDirectory
0xfade0c00A single code requirement
0xfade0c01A requirement set - the plural form, which is what binaries carry
0xfade7171Entitlements, as an XML property list
0xfade7172Entitlements in DER form - the modern encoding
0xfade0b01Blob wrapper - the CMS signature, covered in section 06
Superblob which consists of several blobs. There are 4 blobs of practical importance and which can be found in most Mach-O binaries:
1.
The Entitlements Blob
2.
The Requirements Blob

Referenced from this project: apple-oss-distributions/Security

text

├── MyApp (Mach-O executable) │ └── LC_CODE_SIGNATURE (Load Command) │ ├── dataoff: 622608 // File offset to signature data │ └── datasize: 12960 // Size of signature data │ ============================================================= Bytes 622,608 → 635,568 contain the Embedded Signature: │ └── SuperBlob (magic: 0xfade0cc0 - kSecCodeMagicEmbeddedSignature) ├── Header (magic + length + count) ├── Index[] (array of type + offset pairs) │ ├── [Index 0] Requirements Blob (magic: 0xfade0c00/0xfade0c01) │ └── Designated requirements for code signing validation │ ├── [Index 1] CodeDirectory Blob (magic: 0xfade0c02) │ ├── Metadata (hash type, page size, identity, etc.) │ ├── Special Slots (negative indices, hashed in reverse): │ │ ... can be found here: https://github.com/apple-oss-distributions/Security/blob/main/OSX/libsecurity_codesigning/lib/codedirectory.h │ │ ├── Slot -5: Entitlements hash │ │ ├── Slot -3: CodeResources hash (for bundles) │ │ ├── Slot -2: Requirements hash │ │ ├── Slot -1: Info.plist hash │ │ │ └── Code Slots (positive indices): │ ├── Slot 1: Hash of page 0 (bytes 0-4095) │ ├── Slot 2: Hash of page 1 (bytes 4096-8191) │ ├── Slot 3: Hash of page 2 (bytes 8192-12287) │ └── ... (one slot per 4KB page of executable code) │ ├── [Index 2] Entitlements Blob (magic: 0xfade7171) │ └── XML plist of app capabilities/permissions │ └── [Index 3] CMS Signature / Blob Wrapper (magic: 0xfade0b01) └── PKCS#7/CMS signature (X.509 certificate + signature) ├── Signs the CodeDirectory blob └── Verifies against Apple's certificate chain

Index types and negative slots are the same numbers

The diagram above shows two different things that both look like slot numbers, and conflating them is the usual source of confusion. Apple’s codedirectory.h is explicit that it is one enumeration used in two ways: the same values serve as the type field of a SuperBlob index entry, and - for one subset of them - as the absolute value of a negative index into the CodeDirectory’s hash array.

So 0 means “the CodeDirectory” when it appears as a SuperBlob index type, and 0x10000 means “the CMS signature” in the same position. Neither has a negative counterpart, because neither is ever hashed into the CodeDirectory - and in the signature’s case it plainly cannot be, since it is the thing that signs the CodeDirectory. The values that do appear as negative slots are the ones covering content: -1 for the Info.plist, -2 for requirements, -3 for the resource directory and -5 for entitlements. Those are the subject of the next section.


05CodeDirectory Blob

CodeDirectory definition: codedirectory.h

The CodeDirectory is the root of a tree of digests that make up the code signature. It has some basic metadata, such as the code signing identifier (typically the same as the bundle identifier), the Team ID associated with the Apple Developer Program account, and option flags (such as the hardened runtime), among other things.

The CodeDirectory also contains a table of digests (usually SHA-256 for modern code signatures), each of which is computed from some part of the signed “code” or from some other component of the code signature. This table of digests describes all of the protected parts of the bundle, so that any change - to a bundled resource, for example - invalidates the signature.

The CodeDirectory organizes these digests by slot numbers, which are essentially just indexes into an array (see e.g. codedirectory.h). The most commonly used slots are as follows:
The cdInfoSlot (at index -1) holds the digest of the Info.plist file (for a bundle) or possibly an embedded Info.plist (for a standalone Mach-O executable that has one).
The cdRequirementsSlot (at index -2) holds the digest of the CodeRequirement component, which defines the designated requirement.
The cdResourceDirSlot (at index -3) holds the digest of the CodeResources component, which protects resources in the associated bundle (such as strings files or nib files), as well as any nested code signatures.
preEncryptHash: Points to an array of hashes for the original unencrypted code pages (what the developer signed). Apple will replace the codeSlot with encrypted one after Apple distributes apps through the App Store (especially on iOS), they apply FairPlay DRM encryption.
The non-negative slot indexes, starting from zero, hold the digests of each (4096-byte) page of the Mach-O binary. Since there is a separate code signature for each architecture, page zero always starts with a Mach-O header (even if that’s contained inside a Universal Mach-O file). These per-page digests protect the entire executable, up to the point where the code signature itself is embedded.

The digest of the CodeDirectory itself is called the code directory hash or simply the cdhash. You’ll see the cdhash used in various ways, since it effectively identifies a specific piece of signed code (and will change when any aspect of that code changes).

slots within the CodeDirectory
slots within the CodeDirectory
CDHash (CodeDirectory Hash)
CDHash (CodeDirectory Hash)
CodeDirectory structure
CodeDirectory structure

06Blob Wrapper (CMS) Blob

BlobWrapper is an 8-byte header that wraps cryptographic signature data (PKCS#7) in Apple’s code signing system. It stores the certificates and the signature.

That 8-byte header is worth taking literally. The wrapper is an ordinary blob - magic, then length - so the DER-encoded signature begins at exactly 8 bytes past the start of the blob and runs for length - 8. Knowing that is the difference between being able to carve the signature out and hand it to openssl, and not.

See this github repo to see how they parse the PKCS#7 to get the developer certificate: signature_check/main.c

PKCS#7 - Public-Key Cryptography Standards #7

The name is a small historical inaccuracy that everyone repeats, including Apple. PKCS#7 v1.5 was RSA’s original 1993 syntax for signed and enveloped data, published as RFC 2315. It was taken over by the IETF and became CMS - Cryptographic Message Syntax, now RFC 5652, which is what Apple’s code signing actually uses. The old name stuck to the field names and the tooling, so you will see both; when it matters, CMS is the specification to read.

The important structural property is that this is a detached signature. A CMS SignedData would normally carry the signed content inside it; here the content field is empty, which is what the contentType = data (no embedded content) line in the diagram below means. The signing code sets this explicitly, and the verifying code supplies the missing content from elsewhere in the file. That content is the CodeDirectory.

What is actually signed is a subtlety worth stating precisely. The RSA signature does not cover the CodeDirectory directly - it covers the DER encoding of the signed attributes, one of which, messageDigest, is the digest of the CodeDirectory. And it covers only the CodeDirectory in slot 0. When a binary carries several CodeDirectories hashed with different algorithms, the extras are bound in through Apple’s own attributes instead - that is the whole purpose of hash agility, and it is the detail most write-ups get wrong.

Those Apple attributes are the “proprietary data” in the diagram, and they have real OIDs under Apple’s arc: 1.2.840.113635.100.9.1 for hash agility, .9.2 for its V2 replacement (a dictionary mapping each digest algorithm to that CodeDirectory’s hash), and .9.3 for an expiration time. Verification of the V2 attribute is strict: the number of entries must match the number of CodeDirectories, so a signature cannot be weakened by simply stripping the stronger ones out.

The certificate chain is embedded in full, root included - the signer requests exactly that and fails if the chain cannot be completed. Note that the keychain is deliberately excluded during verification, so the chain has to come from the signature itself. The chain shown below runs through the Apple Worldwide Developer Relations CA, which is right for Development and Distribution certificates; Developer ID certificates chain through a different intermediate.

Visualize the Blobwrapper

text

BlobWrapper (8 bytes header) ├─ Magic: 0xfade0b01 ├─ Length: total size └─ PKCS#7 SignedData payload: │ ├─ [1] Version = 1 ├─ [2] digestAlgorithms = {SHA-256} ├─ [3] contentInfo │ └─ contentType = data (no embedded content) ├─ [4] certificates = {Leaf, Intermediate, Root} │ ├─ Leaf Certificate (developer/Apple) │ │ ├─ Serial Number │ │ ├─ Issuer: "<issuing CA>" │ │ ├─ Subject: "Apple Development: <developer name>" │ │ ├─ Public Key (RSA 2048-bit) │ │ │ ├─ Intermediate CA Certificate │ │ └─ Subject: "Apple Worldwide Developer Relations CA" │ │ │ └─ Root CA Certificate │ └─ Subject: "Apple Root CA" (self-signed) ├─ [5] crls = ∅ (Apple does not use this field) └─ [6] signerInfos └─ SignerInfo: ├─ version = 1 ├─ sid = {issuer + serial} (identifies signing cert) ├─ digestAlgorithm = SHA-256 ├─ signedAttrs = { │ ├─ contentType = data │ ├─ signingTime = "2024-01-15T10:30:00Z" │ ├─ messageDigest = <CDHash> │ └─ Apple extension = proprietary data │ } ├─ signatureAlgorithm = rsaEncryption └─ signature = <256-byte RSA signature>

How verification actually proceeds

Putting the last three sections together, this is the order the system checks a signature in:
1.
An ad-hoc signature short-circuits here - code signed ad-hoc is treated as validly signed by definition, because there is no certificate to evaluate.
2.
Parse the SuperBlob and load the CodeDirectory from slot 0, plus any alternates. Each is bounds-checked, and two CodeDirectories sharing a hash type is fatal.
3.
Decode the CMS blob and hand it the slot-0 CodeDirectory as its detached content.
4.
Verify the signature over the signed attributes and evaluate the certificate chain against Apple’s code-signing policies. This is a single operation, and because the digest of the CodeDirectory is one of those attributes, it is where the CodeDirectory is implicitly authenticated - there is no separate “compare the digest” step.
5.
Check the hash-agility attributes against the set of CodeDirectories actually present.
6.
Recompute the page hashes over the binary and the special slot hashes over the Info.plist, requirements, resources and entitlements, and compare them against the CodeDirectory’s hash array.

Note the direction of trust: the certificate chain vouches for the CodeDirectory, and the CodeDirectory vouches for every page of code and every attached resource. Change one byte of the executable and a page hash stops matching; change the CodeDirectory to cover it and the CMS signature stops matching; re-sign the CodeDirectory and you need a key that chains to Apple.

Most of this is inspectable from the command line - these are worth running against something in /Applications:

CommandDescription
codesign -dvvv <binary>Identifier, CDHash, hash type, page size and the certificate chain
codesign -dvvv --all-architectures <binary>The same, per slice, since each architecture is signed separately
codesign -d --entitlements - --xml <binary>Dump the entitlements blob - slot -5 from the previous section
codesign -d --requirements - <binary>Dump the designated requirement - slot -2
codesign -d --extract-certificates <prefix> <binary>Write the chain to DER files, numbered from 0 at the leaf
openssl x509 -inform DER -in <prefix>0 -text -nooutRead the extracted leaf certificate. -inform DER is required
openssl cms -inform DER -cmsout -print -noverify -in <cms.der>Print the CMS structure once carved out. -noverify is needed because the content is detached

07References

  1. 01
    Demystifying iOS Code Signature - Medium
    https://medium.com/csit-tech-blog/demystifying-ios-code-signature-309d52c2ff1d
  2. 02
    Apple Developer Forums - thread 702351
    https://developer.apple.com/forums/thread/702351
  3. 03
    Mothers Ruin - codesign
    https://www.mothersruin.com/software/Archaeology/reverse/codesign.html
  4. 04
    elnormous/signature_check - main.c
    https://github.com/elnormous/signature_check/blob/master/SignatureCheck/main.c
  5. 05
    qyang-nj/llios - macho_parser
    https://github.com/qyang-nj/llios/tree/main/macho_parser
N

Subscribe to my newsletter

Get notified when I publish new posts on my blog

© 2026 Minh-Tri Le. All rights reserved.