01Binary file layout
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.

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.
02Header structure
The header is fixed-size and sits at offset zero of the slice - 32 bytes for mach_header_64, or 28 for the 32-bit mach_header, which is the same seven fields without the trailing reserved. It is the first thing the kernel reads, and it is where the decision “can this machine run this file at all?” gets made.
c
struct mach_header_64 {
uint32_t magic; /* File format identifier */
cpu_type_t cputype; /* Target CPU architecture */
cpu_subtype_t cpusubtype; /* Specific CPU variant */
uint32_t filetype; /* Binary type */
uint32_t ncmds; /* Number of load commands */
uint32_t sizeofcmds; /* Total size of load commands */
uint32_t flags; /* File behavior flags */
uint32_t reserved; /* Padding for 64-bit alignment */
};Magic, and why there are four of them
Mach-O stores its fields in the target CPU’s byte order rather than a fixed one, so a reader has to work out the byte order before it can trust any other field. The magic number is how. “CIGAM” is “MAGIC” spelled backwards, and its value is the byte-reversal of its counterpart - so a parser that reads MH_CIGAM_64 has just learned that it must byte-swap every multi-byte field from here on. Note that the 64-bit variants differ only in the final nibble: a single 32-bit read tells you both the pointer width and the endianness.
| Magic | Meaning |
|---|---|
| 0xfeedface | MH_MAGIC - 32-bit, byte order matches the reader |
| 0xcefaedfe | MH_CIGAM - 32-bit, byte-swapped |
| 0xfeedfacf | MH_MAGIC_64 - 64-bit, byte order matches the reader |
| 0xcffaedfe | MH_CIGAM_64 - 64-bit, byte-swapped |
Architecture and file type
64-bit architectures are not separate numbers. The CPU_ARCH_ABI64 bit (0x01000000) is OR’d into the base type, so CPU_TYPE_ARM64 is 0x0100000C - plain CPU_TYPE_ARM with the 64-bit bit set - and CPU_TYPE_X86_64 is 0x01000007. The related trap is in cpusubtype: its top byte is a capability mask (CPU_SUBTYPE_MASK, 0xff000000), not part of the subtype value, so it has to be masked off before any comparison.
filetype is what distinguishes an executable from a library from a debug bundle. The values worth recognising:
| filetype | What it is |
|---|---|
| MH_OBJECT (0x1) | Relocatable object file - a .o straight out of the compiler |
| MH_EXECUTE (0x2) | A demand-paged executable - the main binary of an app |
| MH_DYLIB (0x6) | Dynamically bound shared library - .dylib and frameworks |
| MH_DYLINKER (0x7) | The dynamic linker itself, normally /usr/lib/dyld |
| MH_BUNDLE (0x8) | Bundle loaded at runtime via dlopen - plugins |
| MH_DSYM (0xa) | Companion debug-info file, the contents of a .dSYM |
| MH_KEXT_BUNDLE (0xb) | Kernel extension |
A few flags bits are worth knowing by sight when you are looking at a binary with security in mind. MH_PIE (0x00200000) marks a position-independent executable, meaning the kernel is free to load it at a randomised address - its absence on an executable means no ASLR for the main image. MH_ALLOW_STACK_EXECUTION (0x00020000) disables no-execute protection on the stack and should be treated as a red flag in anything modern. MH_TWOLEVEL (0x80) records that each imported symbol remembers which library provides it, which is what stops naive symbol interposition from working.
From the header into the load commands
The last two fields are the hand-off. ncmds is how many load commands follow and sizeofcmds is their total size in bytes. The first load command begins immediately after the header - offset 32 on 64-bit, 28 on 32-bit, with no padding in between. From there a parser reads a { cmd, cmdsize } pair, does something with it, and advances by that record’s own cmdsize, exactly ncmds times. Load commands are variable-length, so they cannot be indexed - only walked. And because sizeofcmds is the total span, the sum of every cmdsize has to equal it; that identity is the cheapest sanity check you get for free.
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 12960Worth 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 r0–r12, 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 12960Segments, 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
| Field | Explanation |
|---|---|
| cmd | The load command type - LC_SEGMENT here, or LC_SEGMENT_64 on a 64-bit binary |
| vmaddr | Virtual address the segment is mapped at. ASLR adds a constant slide to this |
| vmsize | Size of the mapping in memory - which can be larger than filesize |
| cmdsize | Total size of this load command, including section headers that follow |
| segname | Name of the segment |
| fileoff | Offset from beginning of the file where the data of this segment starts |
| filesize | Size of the segment’s data |
| maxprot | Ceiling on protection - a later mprotect may raise up to this, never past it |
| initprot | Protection applied when the segment is first mapped |
| nsects | Number of sections contained within this segment |
| flags | Segment-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 command | What it does |
|---|---|
| LC_SEGMENT | Maps a segment from file into memory; section records follow inline |
| LC_DYLD_INFO_ONLY | Compressed rebase / bind / lazy-bind / export opcode streams for dyld |
| LC_SYMTAB | Where the symbol table and string table live in __LINKEDIT |
| LC_DYSYMTAB | Partitions that symbol table into local / defined / undefined ranges |
| LC_LOAD_DYLINKER | Path of the dynamic linker to run - normally /usr/lib/dyld |
| LC_UUID | A 128-bit build identifier; the key that matches a binary to its .dSYM |
| LC_VERSION_MIN_IPHONEOS | Minimum OS and SDK version. Superseded by LC_BUILD_VERSION |
| LC_UNIXTHREAD | Initial register state including the program counter - the entry point |
| LC_ENCRYPTION_INFO | Describes the byte range covered by FairPlay encryption |
| LC_LOAD_DYLIB | Declares one required shared library, with its version constraints |
| LC_FUNCTION_STARTS | Compressed table of function entry addresses - finds functions with no symbols |
| LC_DATA_IN_CODE | Ranges inside __text that are data, so disassemblers do not decode them |
| LC_CODE_SIGNATURE | File 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:
| Command | Description |
|---|---|
| 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.
| Magic | Blob type |
|---|---|
| 0xfade0cc0 | Embedded signature - the SuperBlob itself, what dataoff points at |
| 0xfade0c02 | CodeDirectory |
| 0xfade0c00 | A single code requirement |
| 0xfade0c01 | A requirement set - the plural form, which is what binaries carry |
| 0xfade7171 | Entitlements, as an XML property list |
| 0xfade7172 | Entitlements in DER form - the modern encoding |
| 0xfade0b01 | Blob wrapper - the CMS signature, covered in section 06 |
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 chainIndex 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.
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).cdRequirementsSlot (at index -2) holds the digest of the CodeRequirement component, which defines the designated requirement.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.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).



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
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:
| Command | Description |
|---|---|
| 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 -noout | Read 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
- 01Demystifying iOS Code Signature - Mediumhttps://medium.com/csit-tech-blog/demystifying-ios-code-signature-309d52c2ff1d
- 02Apple Developer Forums - thread 702351https://developer.apple.com/forums/thread/702351
- 03Mothers Ruin - codesignhttps://www.mothersruin.com/software/Archaeology/reverse/codesign.html
- 04elnormous/signature_check - main.chttps://github.com/elnormous/signature_check/blob/master/SignatureCheck/main.c
- 05qyang-nj/llios - macho_parserhttps://github.com/qyang-nj/llios/tree/main/macho_parser
