The macOS implementation provides a reflective Mach-O loader and three process injection methods using Mach ports.
| Architecture | Binary Format | Notes |
|---|---|---|
| arm64 (Apple Silicon) | Mach-O 64-bit, fat/universal | Primary target |
| x86_64 (Intel) | Mach-O 64-bit, fat/universal | Supported |
Only 64-bit Mach-O binaries are supported. 32-bit Mach-O is not handled.
MH_DYLIB— dynamic libraries (.dylib)MH_BUNDLE— loadable bundles (.bundle)MH_EXECUTE— executables
Fat/universal binaries are handled by extracting the native architecture slice.
-
Parse headers — handle fat headers (extract native slice), verify Mach-O magic and CPU type
-
Allocate memory — three strategies, tried in order:
- Dual-map via
mach_vm_remap(preferred): allocate a RW region, remap it to a second RX region pointing to the same physical pages. W^X is maintained at all times — no page is ever simultaneously writable and executable. - MAP_JIT +
pthread_jit_write_protect_np(arm64 fallback): allocate withMAP_JIT, toggle between write and execute modes - Plain mmap +
mprotect(x86_64 fallback): allocate RW, copy, thenmprotectto RX
- Dual-map via
-
Copy segments — copy each
LC_SEGMENT_64into the allocated region at its virtual offset -
Process fixups — two code paths depending on the load commands present:
LC_DYLD_INFO_ONLY(legacy, pre-macOS 12):- Walk the rebase opcode stream to adjust internal pointers by the slide
- Walk the bind opcode stream to resolve external symbol references via
dlsym(RTLD_DEFAULT, name) - Walk the lazy bind opcode stream to patch PLT-style stubs
LC_DYLD_CHAINED_FIXUPS(modern, macOS 12+):- Parse the chained fixup header to find page starts
- Walk each fixup chain: rebases add the slide, binds resolve via
dlsym - Supports DYLD_CHAINED_PTR_64 and DYLD_CHAINED_PTR_ARM64E formats
-
Resolve external symbols — all imports are resolved via
dlsym(RTLD_DEFAULT, name), which searches all loaded images -
Run initializers — call functions in
__mod_init_funcsection (Mach-O equivalent of.init_array) -
Set protections — apply per-segment VM protections (
VM_PROT_READ,VM_PROT_WRITE,VM_PROT_EXECUTE)
The dual-map technique is the key innovation for maintaining W^X compliance without entitlements:
Physical pages: [page A] [page B] [page C] ...
Virtual mapping 1 (RW): 0x1000 → page A (writable, for fixups)
Virtual mapping 2 (RX): 0x5000 → page A (executable, for code)
Both mappings point to the same physical pages via mach_vm_remap. The loader writes through the RW mapping and the code executes from the RX mapping. No single virtual page is ever both writable and executable simultaneously.
After all fixups are applied, the RW mapping is deallocated. Only the RX mapping remains.
libload_sym resolves symbols by walking the Mach-O export trie, a compact prefix-tree structure stored in LC_DYLD_INFO_ONLY or LC_DYLD_EXPORTS_TRIE.
The trie encodes exported symbol names with shared prefixes. Each terminal node contains the symbol's offset from the image base. The walker follows edges byte-by-byte, matching the requested symbol name.
macOS provides three injection methods, all using Mach ports:
Injects raw position-independent code into a remote process.
Mechanism:
- Obtain the target's task port via
task_for_pid mach_vm_allocatea region in the targetmach_vm_writethe code into the regionmach_vm_protectto mark it RXthread_create_runningto start a new thread at the entry point
Requirements: task_for_pid access (root, or target built with get-task-allow)
Limitation: Fails on Hardened Runtime targets — AMFI kills the target when unsigned executable pages are created.
Injects a signed dylib by hijacking a thread to call dlopen.
Mechanism:
- Obtain the target's task port
- Suspend the target, save thread state
- Allocate memory in the target for the dylib path string
- Set thread registers: PC →
dlopen, x0 → path, x1 →RTLD_NOW, LR → BRK trap - Resume the thread
- Catch the
EXC_BREAKPOINTwhendlopenreturns (via LR → BRK) - Restore original thread state and resume
Requirements: task_for_pid access
Advantage: No new executable pages are created — only existing signed code runs. Works on Hardened Runtime targets.
Spawns a process with a dylib pre-injected using a novel zero-privilege technique.
Mechanism:
- For HR targets: copy the binary, patch entry point with
BRK #1, ad-hoc re-sign - For non-HR targets: prepare a trigger dylib with
__attribute__((constructor))that executesBRK #1 - Set Mach exception ports on self via
task_swap_exception_ports fork()— child inherits exception port registrations- Child exec's into the target (exception ports survive exec)
- Target hits
BRK→ kernel deliversEXC_BREAKPOINTto parent with full task control port - Parent hijacks thread to
dlopenthe payload dylib - Catch second exception when dlopen returns, skip past the BRK, resume normally
Requirements: None — no root, no entitlements, no get-task-allow
Limitation: Does not work on Apple platform binaries (/usr/bin/*, /System/*) — setting exception ports on these causes SIGKILL from the kernel.
See EXCEPTION_PORT_INJECTION.md for the complete technical writeup.
| Method | Privileges | HR Targets | Running Process | Disk Artifacts |
|---|---|---|---|---|
libload_inject |
root / get-task-allow | No (AMFI blocks) | Yes | None |
libload_inject_dylib |
root / get-task-allow | Yes | Yes | None |
libload_inject_spawn |
None | Yes (non-platform) | No (spawn only) | Temp binary copy (HR only) |
- No Objective-C class registration — ObjC metadata and class lists are not registered with the runtime. Pure ObjC method dispatch works if the class is already registered.
- No Swift metadata — Swift type metadata, protocol conformances, and associated type witnesses are not registered.
- No thread-local variables (TLV) —
__thread/_Thread_localvariables require dyld cooperation that is not replicated. - No
LC_CODE_SIGNATUREvalidation — the loader does not verify code signatures. The loaded code runs with the host process's signing identity. - 64-bit only — 32-bit Mach-O is not supported.
- No
@rpathresolution — import lookups usedlsym(RTLD_DEFAULT, ...)only;@rpath,@loader_path, and@executable_pathreferences in imports are not resolved.