Back to course

__PAGEZERO and Memory Hardening

15 minadvancedModule 7: Integrity & DebugContinues from macho-debug-info
15 min · Module 7: Integrity & Debug

Why This Matters

A NULL pointer is address 0. For decades the exploit playbook started by mapping page 0, so the program's "nothing" landed in attacker-owned memory. The second trick: find a page that is writable and executable and drop shellcode there. The third: hard-code target addresses because nothing ever moves. Every one of those tricks dies before your code runs — because Mach-O declares its defenses in load commands, in bytes you can read with otool.

This is also where the numbers from earlier lessons stop being abstract. initprot 0x00000005 is not trivia: it is 4 plus 1, which is EXECUTE plus READ, which is why __text runs and cannot be overwritten. Read these fields like a security policy, because that is what the kernel enforces.

A Simple Model

Four defenses, four places to look:

  1. __PAGEZERO — 4 GiB at address 0, no protections, no file bytes. Anything that touches NULL faults.
  2. MH_PIE — a header flag: "the OS will load the main executable at a random address." Preferred base becomes just a preference.
  3. initprot / maxprot — per-segment protection: __TEXT read-execute, __DATA read-write, __LINKEDIT read-only. W^X by construction.
  4. Policy flag bits — MH_ALLOW_STACK_EXECUTION, MH_NO_HEAP_EXECUTION, MH_DYLIB_IN_CACHE and friends announce what the image opts into or out of.

The model's missing piece: declaring is not enforcing. The segment protections are enforced by the kernel's VM system, the slide is applied by dyld, and the bytes being executed were attested by the code signature from the previous concept. One posture, three different enforcers — a defense only holds when all three agree.

The Actual Detail

Protections, decoded with the Mach-O values VM_PROT_READ = 1, VM_PROT_WRITE = 2, VM_PROT_EXECUTE = 4:

Segmentmaxprot / initprotDecodeWhat it means
__PAGEZERO0 / 0no accessany read, write, or fetch faults — the NULL trap
__TEXT5 / 5r-x (1|4)code runs; nothing can overwrite it in memory
__DATA3 / 3rw- (1|2)mutable globals; never executable
__LINKEDIT1 / 1r--fixups, symbols, signature — readable, sealed

In prog64.macho maxprot equals initprot on all four segments: the ceiling and the starting state are the same. segment_command comments the two fields "maximum VM protection" and "initial VM protection" — maxprot is the ceiling a later remap may raise up to, initprot is what the segment gets at map time.

Now the flags word, 0x00200085, against the hardening-relevant bits in loader.h:

FlagValueSet in 0x00200085?loader.h says
MH_NOUNDEFS0x1yes"the object file has no undefined references"
MH_DYLDLINK0x4yes"input for the dynamic linker and can't be staticly link edited again"
MH_TWOLEVEL0x80yes"the image is using two-level name space bindings"
MH_PIE0x200000yes"the OS will load the main executable at a random address"
MH_ALLOW_STACK_EXECUTION0x20000no"all stacks in the task will be given stack execution privilege"
MH_NO_HEAP_EXECUTION0x1000000no"run the main executable with a non-executable heap even on platforms (e.g. i386) that don't require it"
MH_DYLIB_IN_CACHE0x80000000no (dylib-only)"the dylib is part of the dyld shared cache, rather than loose in the filesystem"
Common mistake. MH_PIE is 0x200000 and MH_ALLOW_STACK_EXECUTION is 0x20000 — one zero apart. The flags word 0x00200085 contains 0x200000, so ASLR is on; it does not contain 0x20000, so stack execution was never opted into. Confusing the two flips your verdict on both defenses.

A Real Example

The whole posture fits in the header's last eight bytes and four segment commands. Header bytes 16–31 of prog64.macho:

00000010: 0e00 0000 c803 0000 8500 2000 0000 0000  ........

ncmds = 14, sizeofcmds = 968, then the flags word 85 00 20 00 read little-endian = 0x00200085 = 0x1 | 0x4 | 0x80 | 0x200000 — NOUNDEFS, DYLDLINK, TWOLEVEL, PIE, and nothing else. The reserved word after it is zero.

Pair it with the first load command, the one that gives the lesson its name:

segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000

Zero address, 4 GiB, zero bytes on disk, zero permissions — a region that exists only to fail. loader.h gives it the title it deserves: "the pagezero segment which has no protections and catches NULL references for MH_EXECUTE files."

Try It

Any Mach-O works — on macOS use otool, on Linux llvm-otool. First the guard page, then the flag bits:

$ llvm-otool -l prog64.macho | grep -A7 __PAGEZERO
   segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0

$ python3 -c "f=0x00200085; print('PIE', bool(f & 0x200000)); print('STACK_EXECUTION', bool(f & 0x20000))"
PIE True
STACK_EXECUTION False

What to look for: PAGEZERO is all zeros with filesize 0 — pure reserved address space; and the mask test splits 0x200000 from 0x20000 so you never confuse ASLR's bit with stack execution's. Then check your own binary's initprot values against the READ/WRITE/EXECUTE table above.

Check Your Understanding

Without looking back: catching NULL dereferences is __PAGEZERO's fame — what is its second job?

Apply It

An auditor greps the flags of prog64.macho, sees 0x00200085, and asks: is MH_ALLOW_STACK_EXECUTION set — are stacks executable here?

The pattern: decode protections as bit values, never as appearances. 0x200000 and 0x20000 differ by one nibble — and so do ASLR and stack execution.

Connect

You have read the defense posture straight from the file: a guard page at zero, a random base, W^X segments, policy flags — declared in bytes, enforced by kernel and dyld, attested by the code signature. But someone has to actually perform that map: choose the slide, walk the dependencies, fix every pointer.

Next: How dyld Loads a Mach-O — seven stages from execve to main, each one anchored to a load command you have already met.