Mach-O fixed location Loading
(Photo of Zurich, taken near ETH Zurich)
You may already know that macOS has long enforced dynamic linking, deprecated position-dependent code generation, and enabled ASLR (Address Space Layout Randomization) by default. Despite that, there are still scenarios, especially in systems research, where fixed-address memory layouts are desirable. I recently explored how far we can push this idea on modern macOS, and here’s a summary of what I found.
Early Experiments
Linking and Segment Layouts
Similar to the ELF format used by Linux and BSD, Mach-O files on macOS define segments and sections, with segment-level permissions and virtual memory addresses (vmaddr
). Using XMachOViewer, I confirmed that Mach-O segments carry a vmaddr
attribute—suggesting a potential for fixed-address execution.
However, due to ASLR, these virtual addresses act only as relative offsets between segments, not absolute positions in memory. This was confirmed with a simple experiment:
#include <stdio.h>
void my_custom_function(void) __attribute__((section("__CSTM,__mytext")));
void my_custom_function(void) {
printf("Hello from custom segment and section!\n");
}
int main() {
my_custom_function();
return 0;
}
This code places my_custom_function()
in a custom segment __CSTM
, within section __mytext
. Although this compiled into a valid Mach-O binary, it failed at runtime due to missing permission attributes on the custom segment.
After manually updating the segment permissions to RX
(read + execute), the binary executed as expected. I also tested assigning RWX
permissions, but that failed—Apple Silicon restricts simultaneous write/execute permissions to mitigate JIT-related attacks (source):
When memory protection is enabled, a thread cannot write to a memory region and execute instructions in that region at the same time. Apple silicon enables memory protection for all apps, regardless of whether they adopt the Hardened Runtime.
Although not directly relevant, it’s an important detail for context.
Disassembly Findings
Here’s the disassembly of the main function:
Disassembly of section __TEXT,__text:
0000000100003f48 <_main>:
100003f48: d10083ff sub sp, sp, #0x20
100003f4c: a9017bfd stp x29, x30, [sp, #0x10]
100003f50: 910043fd add x29, sp, #0x10
100003f54: 52800008 mov w8, #0x0
100003f58: b9000be8 str w8, [sp, #0x8]
100003f5c: b81fc3bf stur wzr, [x29, #-0x4]
100003f60: 94001028 bl 0x100008000 <_my_custom_function>
Notice the bl
(branch with link) instruction used to call my_custom_function()
. Since it uses PC-relative addressing, changing the vmaddr
of the __CSTM
segment will invalidate the call offset, breaking the linkage. This confirms that relative instruction offsets must remain stable post-linking.
Linker Challenges
Linux linkers typically support linker scripts to set segment base addresses and permissions. Unfortunately, neither the default macOS linker nor sold
support linker scripts on macOS.
At this point, I nearly gave up—but inspecting sold
’s source code revealed a glimmer of hope. The VM address assignment logic is surprisingly modular:
for (std::unique_ptr<OutputSegment<E>> &seg : ctx.segments) {
seg->set_offset(ctx, fileoff, vmaddr);
fileoff += seg->cmd.filesize;
vmaddr += seg->cmd.vmsize;
}
By modifying this logic, I replaced the default vmaddr
incrementation with a fixed step of 0x20000000
. This allowed me to spread segments across a wider address space and evaluate the impact on relative calls. Given the 4GB jump range limitation of the offset loading instructions on AArch64, this method still works as long as we remain within that range.
Loading the Binary
Parallel to linker experiments, I examined the dynamic loading process. The macOS system loader, dyld
, enforces ASLR and uses vmaddr
values only for relative layout.
However, I found a project called DyldDeNeuralyzer—an in-memory Mach-O loader—which I modified for fixed-address loading. Although originally restricted to loading bundle
types, it can load dylib
files after removing a type check. Executable Mach-O files, for unknown reasons, failed to load—but dylibs proved sufficient for experimentation.
Fixed Memory Allocation
To load a binary at a fixed address, I used vm_allocate()
with the VM_FLAGS_FIXED
flag. This required finding a suitable memory range unoccupied by existing segments or libraries.
Running a binary with DYLD_PRINT_SEGMENTS=1
provided insight into the memory layout. Here’s a simplified excerpt:
dyld[42777]: 0x19D704000->0x2013EBFFF init=5, max=5 __TEXT
dyld[42777]: 0x211B28000->0x216D27FFF init=1, max=1 __READ_ONLY
dyld[42777]: 0x216D28000->0x23CB23FFF init=1, max=1 __LINKEDIT
...
Above 0x300000000
, memory appears unallocated. I chose this region for loading the test binary—and it worked.
With the linker producing valid binaries and the loader placing them at known addresses, we now have a functional fixed-address Mach-O loading pipeline on macOS.
Summary: Building a Fixed-Address Loader for macOS
To enable fixed address loading of Mach-O binaries, the following components are required:
- Custom linker support: Patch
sold
to allow explicitvmaddr
assignments. - Correct permissions: Let ‘sold’ set segment permissions to
R+X
(read and execute) for code sections. - Manual loading: Use a custom loader to load binaries at a fixed address using
vm_allocate()
withVM_FLAGS_FIXED
. - Runtime validation: Ensure your address range is outside the mapped regions of system libraries and dyld.
Limitations
- Your binary must avoid address ranges already mapped by dyld and other shared libraries.
- The entire layout must fit within a 4GB address space due to current linker constraints.
- Executable Mach-O files cannot currently be loaded this way—only dylibs work reliably.
While not production-ready, this exploration shows that fixed-address loading is indeed possible on macOS with a bit of effort. Future work might explore extending sold
to better support these use cases or developing a more robust custom loader.