Apple Notarization
Definitely too much. I wrote the last post regarding Apple defences way too long ago. Mea Culpa… yet not too much. I have released two (!) versions of 0tH in the meantime, and I also have a life…
Besides, here we go - shortcuts for the previous articles, if you dared to lose them:
Previous articles in this series
The release of 0tH has inspired me: today we talk Notarization!
What is notarization
At its core, Notarization is a server-side process that Apple introduced to implement an extended trust model, beyond Developer ID. The idea is to state that software distributed outside the App Store has triggered none of Apple’s security controls when submitted, while also providing a baseline for further integrity checks (simplifying the concept: if someone tampered with a notarized, stapled artefact, the ticket becomes invalid).
It is crucial to understand that this process does not replace the digital code signature (commonly referred to as App Signing): it is an additional level of protection. If App Signing identifies who wrote the software, Notarization states that Apple:
- has run some checks on the artefact;
- there was no deviation from the posture (policies and expectations) that Apple requires at submission time
Technically speaking: Developers submit an artefact (app, kext, pkg, dmg, …) to Apple by the means of notarytool. Upon the submission, Apple performs checks on the artefact, including but not limited to malware detection, signature checks, entitlement control, checking hardened runtime, and other undocumented actions. If everything works fine, Apple delivers a cryptographic ticket stating that the artefact has successfully passed the notarization process. Again: this ticket does not mean “the artefact is safe”, but “this artefact passed Apple’s checks at submission time”. It’s a subtle difference, so make sure you catch it.
This ticket is nothing but a blob, signed by Apple. Apple thereby certifies that the artefact has been checked, and at submission time hasn’t raised any warning. From this perspective, notarization is not a security certificate, rather it is a statement that Apple had no objections.
Let’s summarize before proceeding. When a developer notarizes an artefact:
- hardened runtime: before submission, developers must enable the hardened runtime. This activates a set of runtime enforcement mechanisms (such as memory protections and rejection of unsigned libraries), reducing the overall attack surface.
- submission: this is not an “app review”, but primarily an automated check for structural and policy-related issues.
- ticket: if the previous step succeeds, Apple issues a ticket. This ticket represents the notarization evidence.
Now it’s time for the Byte Architect to be the Byte Architect: enter CDHash!
Code directory and CDHash
Code Directory is the heart of Apple’s code signing. In principle, it’s the data structure answering the question “is this artefact the one that has been originally signed?”
Code Directory lives within the Mach-O structure, in the LC_CODE_SIGNATURE load command, and it is a binary blob with a well-defined layout.
Shame on you if you don’t already know where loader.h lives — although I must admit I didn’t either, and I ended up writing an alias in my ~/.zshrc. In any case, take a look at:
$(xcrun --show-sdk-path)/usr/include/mach-o/loader.h
to see the definition of the LC_CODE_SIGNATURE load command.
struct linkedit_data_command {
uint32_t cmd; /* LC_CODE_SIGNATURE, LC_SEGMENT_SPLIT_INFO,
LC_FUNCTION_STARTS, LC_DATA_IN_CODE,
LC_DYLIB_CODE_SIGN_DRS, LC_ATOM_INFO,
LC_LINKER_OPTIMIZATION_HINT,
LC_DYLD_EXPORTS_TRIE,
LC_FUNCTION_VARIANTS,
LC_FUNCTION_VARIANT_FIXUPS, or
LC_DYLD_CHAINED_FIXUPS. */
uint32_t cmdsize; /* sizeof(struct linkedit_data_command) */
uint32_t dataoff; /* file offset of data in __LINKEDIT segment */
uint32_t datasize; /* file size of data in __LINKEDIT segment */
};
This mainly means that there is an offset (dataoff) and a size (datasize): in short, the blob starts dataoff bytes from the beginning of the current slice and spans datasize bytes. This makes understanding the internal structure of this blob particularly relevant.
The definition of the Code Directory data structure can be found in:
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/Kernel.framework/Headers/kern/cs_blobs.h
The relevant definition is the following:
typedef struct __CodeDirectory {
uint32_t magic; /* magic number (CSMAGIC_CODEDIRECTORY) */
uint32_t length; /* total length of CodeDirectory blob */
uint32_t version; /* compatibility version */
uint32_t flags; /* setup and mode flags */
uint32_t hashOffset; /* offset of hash slot element at index zero */
uint32_t identOffset; /* offset of identifier string */
uint32_t nSpecialSlots; /* number of special hash slots */
uint32_t nCodeSlots; /* number of ordinary (code) hash slots */
uint32_t codeLimit; /* limit to main image signature range */
uint8_t hashSize; /* size of each hash in bytes */
uint8_t hashType; /* type of hash (cdHashType* constants) */
uint8_t platform; /* platform identifier; zero if not platform binary */
uint8_t pageSize; /* log2(page size in bytes); 0 => infinite */
uint32_t spare2; /* unused (must be zero) */
char end_earliest[0];
/* Version 0x20100 */
uint32_t scatterOffset; /* offset of optional scatter vector */
char end_withScatter[0];
/* Version 0x20200 */
uint32_t teamOffset; /* offset of optional team identifier */
char end_withTeam[0];
/* Version 0x20300 */
uint32_t spare3; /* unused (must be zero) */
uint64_t codeLimit64; /* limit to main image signature range, 64 bits */
char end_withCodeLimit64[0];
/* Version 0x20400 */
uint64_t execSegBase; /* offset of executable segment */
uint64_t execSegLimit; /* limit of executable segment */
uint64_t execSegFlags; /* executable segment flags */
char end_withExecSeg[0];
/* Version 0x20500 */
uint32_t runtime;
uint32_t preEncryptOffset;
char end_withPreEncryptOffset[0];
/* Version 0x20600 */
uint8_t linkageHashType;
uint8_t linkageApplicationType;
uint16_t linkageApplicationSubType;
uint32_t linkageOffset;
uint32_t linkageSize;
char end_withLinkage[0];
/* followed by dynamic content as located by offset fields above */
}
Discussing this structure in depth goes beyond the scope of this post: it gets intricate quickly — and believe me, I’ve had nightmares ever since I wrote the first parser for 0tH.
The short lesson here is: don’t parse this monstrosity by hand. It’s a - big-endian (network byte order) object, potentially enclosed in a little endian object (nowadays Apple binaries tend to be little endian). All those end_*[0] markers indicate version-dependent, dynamically sized content — don’t treat this as a plain C struct you can just cast onto a buffer. Use otool. Use jtool. If you’re cool, use 0tH. But save your mental health.
Furthermore, this structure evolves over time: Apple introduces new fields while preserving backward compatibility through the version field.
Conceptually, in this structure we can identify some pieces of metadata that uniquely identify the artefact:
identifier- the bundle ID or binary nameteamID- Apple Developer’s identifierversion- previously mentioned, it’s the version of the Code Directory structure itselfflags- runtime flags such as hardened runtime, library validation, kill on invalid, etc.
Negative slots
We can also find negative slots. There are slots with negative index meant to contain hashes of ancillary components:
- Info.plist
- requirements blob (trust rules)
- Resource directory (and for bundles,
_CodeSignature/CodeResources) - Entitlements
- Rep-specific slot (depending on the artefact type)
Positive slots
The executable is split into pages (usually 4 kB, but this is configurable), and each page is hashed individually. For example, a 1 MB artefact results in roughly 256 hash slots. These positive slots are meant to cover the entire executable content, including __TEXT, __DATA, and other segments.
Hashing parameters
hashType- the algorithm (SHA-1, SHA-256, SHA-384)pageSize— page size (expressed as log_2; e.g. 12 → 4096 bytes. Yes, I know people usually write log₂ or log2, but frankly Unicode stinks and log2 feels odd. Just be glad I’m not writing ln(x)/ln(2)).hashSize- size of the hash, in bytes
The structure has a fixed header followed by variable data (identifier string, team ID string, and after that, the hash table). The exact layout depends on the Code Directory version.
When the Kernel needs to verify a code page, at runtime:
- the page is fetched from memory
- hash is computed
- hash is compared to the one contained in the corresponding slot of the Code Directory.
- if it does not match, execution is aborted. This is what AMFI does.
At this point, we can finally answer the question: what is the CDHash? The CDHash is what binds everything to notarization. It is simply the hash of the entire Code Directory structure — metadata and hash slots included.
In the post Reading LC_CODE_SIGNATURE with 0tH I have already shown how to inspect these values. For instance, you can use:
desdinova@RevEng3 ~ % codesign -dvvv /Applications/Safari.app/Contents/MacOS/Safari
Executable=/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app/Contents/MacOS/Safari
Identifier=com.apple.Safari
Format=app bundle with Mach-O universal (x86_64 arm64e)
CodeDirectory v=20400 size=617 flags=0x2000(library-validation) hashes=9+7 location=embedded
Platform identifier=26
Hash type=sha256 size=32
CandidateCDHash sha256=423adfd20219e06241fedd6346673a6a6cd740fb
CandidateCDHashFull sha256=423adfd20219e06241fedd6346673a6a6cd740fbaeafa81762fdb6fe998bea40
Hash choices=sha256
CMSDigest=423adfd20219e06241fedd6346673a6a6cd740fbaeafa81762fdb6fe998bea40
CMSDigestType=2
CDHash=423adfd20219e06241fedd6346673a6a6cd740fb
Signature size=4442
Authority=Software Signing
Authority=Apple Code Signing Certification Authority
Authority=Apple Root CA
Signed Time=9 Sep 2025 at 07:33:03
Info.plist entries=48
TeamIdentifier=not set
Sealed Resources version=2 rules=13 files=1602
Internal requirements count=1 size=64
Or, if you’re a real power user, you can launch 0tH and do:
>> load /Applications/Safari.app/Contents/MacOS/Safari
=== LOAD COMMANDS SUMMARY ===
[+] Binary: /Applications/Safari.app/Contents/MacOS/Safari
[+] FAT file
[+] Number of slices: 2
[+] Slice 0: 20 load commands
[+] Absolute range: 0x4000 - 0x14A20
[+] Slice Architecture: Intel x86-64
[+] 20 commands parsed
[+] Slice 1: 20 load commands
[+] Absolute range: 0x18000 - 0x2EAE0
[+] Slice Architecture: ARM64E (Apple Silicon with pointer authentication)
[+] 20 commands parsed
[+] 2 slices parsed
[+] Successfully loaded: /Applications/Safari.app/Contents/MacOS/Safari
>> codesign info
CodeDirectory Information
Basic Info:
Identifier: com.apple.Safari
Version: 0x00020400
Platform: Unknown
CDHash (Binary Identity):
7d5fa048fd4eda212c92a94da9e5e944a57d27d99670a02436e200850e7047b6
Hash Algorithm:
SHA-256
Code Coverage:
Page Size: 4096 bytes
Total Pages: 3
Code Limit: 8448 bytes
Flags:
• Hardened Runtime NOT enabled
• Library validation required
>> codesign verify
Code Signature Verification
SuperBlob: 5 blobs
CodeDirectory: com.apple.Safari
CMS
HardenedRuntime: NOT enabled
Result: Signature structure is valid
Note: This is structural validation only. Cryptographic verification requires system keychain.
>>
Either way, the data we discussed is right there — explicit, inspectable, and verifiable.
Note that different tools may display either the truncated CDHash or its full representation, depending on context and policy.
Note #2: codesign defaults to the native architecture slice, while 0tH here shows the first slice (Intel). Different slices have different Code Directories, hence different CDHashes.
Why Apple tools are not enough (and never were)
At this point, you might think:
“Fine.
codesign,stapler, andotoolgive me everything I need.”
They don’t.
Apple tools:
- show one slice at a time
- hide cross-slice inconsistencies
- do not surface structural deltas
- assume policy correctness, not analyst intent
This is exactly where 0tH exists.
If you want to see:
- multiple Code Directories at once
- slice-level CDHash deltas
- notarization identity without Apple’s filtering
- structural verification without execution
download 0tH here: *https://zero-the-hero.run/download/**
The Ticket
We’ve established that the CDHash uniquely identifies a signed artifact. But how does Apple actually deliver and verify notarization? The answer lies in the ticket — and we can observe the entire mechanism in action.
When you run stapler validate -v on a notarized artifact, you’re watching Gatekeeper’s validation logic in verbose mode. Let’s walk through what happens with a real example:
desdinova@RevEng3 % stapler validate -v 0tH_2026.2.0.dmg
Step 1: Local extraction
First, stapler extracts the code signature from the artifact and computes its properties:
Props are {
cdhash = {length = 20, bytes = 0x3754b12184e2084f738d26799e8fe151d1d90909};
digestAlgorithm = 2;
signingId = "0tH_2026.2.0";
teamId = 693DSH8GN5;
secureTimestamp = "2025-12-16 12:32:24 +0000";
}
The CDHash is the artifact’s fingerprint. The teamId identifies the developer, signingId is the artifact name, and secureTimestamp records when it was signed.
Step 2: CloudKit lookup
Armed with the CDHash, stapler queries Apple’s ticket database:
Domain is api.apple-cloudkit.com
The full endpoint is:
https://api.apple-cloudkit.com/database/1/com.apple.gk.ticket-delivery/production/public/records/lookup
This is a public CloudKit database — Apple’s infrastructure for storing and retrieving notarization tickets. The query payload is straightforward:
JSON Data is {
records = (
{
recordName = "2/2/3754b12184e2084f738d26799e8fe151d1d90909";
}
);
}
Notice the recordName: it’s the CDHash prefixed with 2/2/. The CDHash is the lookup key. No CDHash, no ticket. Wrong CDHash, wrong ticket. This is the binding mechanism.
Step 3: Ticket retrieval
Apple responds with the ticket record:
recordType = DeveloperIDTicket;
recordName = "2/2/3754b12184e2084f738d26799e8fe151d1d90909";
fields = {
signedTicket = {
type = BYTES;
value = "czhjaAEAAADxBQAAVwAAADCCBe0wggL/...";
};
};
The signedTicket field contains a base64-encoded blob. This is the actual ticket — a DER-encoded structure containing:
- An X.509 certificate chain (Software Ticket Signing → Apple System Integration CA 4 → Apple Root CA - G3)
- The CDHash being attested
- A cryptographic signature from Apple
Only Apple possesses the private keys to sign these tickets. This is what makes notarization unforgeable.
Step 4: Stapling (optional)
The ticket can be stapled — physically embedded into the artefact:
Downloaded ticket has been stored at file:///var/folders/.../15f93d8b-c8bc-4a7e-ab62-fb190be568ae.ticket
When you run stapler staple App.app, this blob gets written into the artefact. A stapled artefact can be validated offline — Gatekeeper finds the ticket locally and verifies the signature without contacting Apple.
If the ticket is not stapled, Gatekeeper performs the CloudKit lookup at first launch. This requires network connectivity and adds latency, but the security model is identical.
Revocation
The ticket metadata reveals how Apple can revoke notarization:
teamId = 693DSH8GN5;
signingId = "0tH_2026.2.0";
cdhash = 0x3754b12184e2084f738d26799e8fe151d1d90909;
Apple can invalidate tickets at three granularities:
- By CDHash: surgical removal of a single artifact
- By signingId: all versions of a specific product
- By teamId: nuclear option — everything from a developer
When a ticket is revoked, the CloudKit lookup returns empty or a revocation record. Gatekeeper blocks execution. Even stapled tickets are checked against revocation lists periodically.
Closing thoughts
Notarization is often misunderstood as a seal of approval. It isn’t.
It’s a statement of non-objection: Apple scanned this artifact, at this moment in time, and found nothing that triggered its automated checks. Tomorrow, the same artifact could be flagged and revoked. The ticket doesn’t certify safety — it certifies inspection.
For Apple, notarization solves a visibility problem. Before this system, software distributed outside the App Store was a black box. Now Apple knows what’s running, who signed it, and can revoke it remotely. It’s telemetry with teeth.
For defenders, notarization is a useful signal but not a definitive one. A notarized artifact passed Apple’s checks — checks that are automated, undocumented, and bypassable by anyone sufficiently motivated. Trust the signature, verify the CDHash, but never assume the code is safe just because Apple didn’t object.
For attackers, notarization raises the cost of distribution. Stolen Developer IDs get burned faster. Malware has a shorter shelf life. But the window between notarization and revocation remains exploitable — and social engineering doesn’t care about tickets.
The real value of understanding notarization isn’t in trusting it more. It’s in knowing exactly what it guarantees — and what it doesn’t.
Next
In the next article, we’ll follow the CDHash past the gate.
Gatekeeper decides if an artifact can launch. Notarization certifies Apple has seen it. But once the process is running, who ensures the code hasn’t been tampered with? Who checks that every page loaded into memory still matches the original signature?
That’s AMFI — Apple Mobile File Integrity. The kernel-level enforcer that verifies code integrity at runtime, page by page, slot by slot. It’s where the Code Directory stops being a data structure and becomes a kill switch.
Want the deep dive?
If you’re a security researcher, incident responder, or part of a defensive team and you need the full technical details (labs, YARA sketches, telemetry tricks), email me at info@bytearchitect.io or DM me on X (@reveng3_org). I review legit requests personally and will share private analysis and artefacts to verified contacts only.
Prefer privacy-first contact? Tell me in the first message and I’ll share a PGP key.
Subscribe to The Byte Architect mailing list for release alerts and exclusive follow-ups.
Gabriel(e) Biondo
ByteArchitect · RevEng3 · Rusted Pieces · Sabbath Stones