You want to change behavior at a known RVA while a process is running—for
instrumentation, for studying how a piece of code behaves, or to make a fixed value
configurable. The straightforward implementation ( + memcpy) works in
some debug, development, or otherwise unrestricted environments. Where signed-code page
validation is enforced, modifying an executable page can terminate the process.
Adding a writable segment does not, by itself, make the instruction at the original RVA writable or redirect execution through that segment. The robust design for the constrained environment discussed here has two distinct phases:
- Before signing, rewrite each executable patch site and emit any required dispatch code into a file-backed, executable segment.
- At runtime, change only data in a separate writable segment. The prepared code reads that state and selects the required behavior.
The writable pool is runtime state storage, not a substitute for the original code page.
Scope
This article discusses a thin arm64 Mach-O: one slice in one file, 64-bit,
little-endian, and not encrypted (LC_ENCRYPTION_INFO_64 absent, or present with cryptid
clear). A fat binary is a container of slices and is not handled here—split it first, or
apply the surgery independently to each slice.
The load-command walk assumes mach_header_64, segment_command_64, and section_64.
The 32-bit spelling and big-endian targets are different formats to edit. The resulting
file must be signed after all structural and executable changes, and the signature must
describe the exact bytes the loader will map.
This is a layout pattern, not a complete general-purpose Mach-O rewriter. Production code must validate every command size, range, alignment, integer addition, and file read or write before mutating the file.
Why the runtime write fails
Two separate questions are often confused: can I write to this page, and will the platform accept the resulting page?
VM_PROT_COPY addresses the first question. It can allow a private writable mapping even
when the segment did not originally declare write permission. It changes VM behavior; it
does not bypass signed-code validation.
patch.c
/* This can be useful in an environment where direct code patching is permitted.
It is not a signed-code bypass. */
kern_return_t kr = mach_vm_protect(mach_task_self(), page, span, FALSE,
VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
if (kr != KERN_SUCCESS) return false;
memcpy((void *)addr, bytes, len);
sys_icache_invalidate((void *)addr, len);
Where executable-page validation is enforced, changing bytes covered by the code signature can cause termination even if the VM write itself succeeded. Debugger state, special platform facilities, JIT entitlements, and jailbroken environments can change the constraints; this article assumes none of those exceptions is available.
| Symptom | Likely meaning |
|---|---|
EXC_BAD_ACCESS at the target address | The page was not writable; the protection call failed or covered the wrong range |
SIGKILL, termination reason Namespace CODESIGNING | The platform rejected a modified sealed page |
EXC_BREAKPOINT, PAC-related brk | An unauthenticated or incorrectly diversified pointer reached PAC-protected control flow |
| Delayed crash elsewhere with a normal stack | Often an application-level integrity check rather than the kernel |
In the environment considered here, the fix is architectural: prepare executable behavior offline and keep runtime mutation in writable data.
The closed-loop design
The complete path is:
For every prepared site, the offline tool must do more than allocate space:
- identify a safe overwrite span at the original RVA;
- relocate any displaced instructions whose PC-relative semantics would otherwise change;
- emit a dispatch stub into
__HOOK_CODE; - create a corresponding slot in
__HOOK_DATA; - rewrite the original site to reach the stub; and
- sign the finished image.
The exact stub is application-specific. It might branch on HookSlot.state, load a
replacement constant, call a prepared handler, or select one of several prebuilt RX code
paths. What matters is that all instructions are present before signing. Runtime code only
changes the slot.
Separate executable code from mutable state
Use two segments with deliberately different protections:
add_hook_segments.c
struct segment_command_64 hook_code_seg = {
.cmd = LC_SEGMENT_64,
.cmdsize = sizeof(struct segment_command_64) + sizeof(struct section_64),
.segname = "__HOOK_CODE",
.vmaddr = 0, /* assigned after the existing VM ranges */
.vmsize = CODE_SIZE,
.fileoff = 0, /* assigned before relocated __LINKEDIT */
.filesize = CODE_SIZE,
.maxprot = VM_PROT_READ | VM_PROT_EXECUTE,
.initprot = VM_PROT_READ | VM_PROT_EXECUTE,
.nsects = 1,
};
struct section_64 hook_code_sec = {
.sectname = "__hook_code",
.segname = "__HOOK_CODE",
.size = CODE_SIZE,
.align = 2, /* 2^2 = 4-byte arm64 instruction alignment */
.flags = S_REGULAR | S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS,
};
struct segment_command_64 hook_data_seg = {
.cmd = LC_SEGMENT_64,
.cmdsize = sizeof(struct segment_command_64) + sizeof(struct section_64),
.segname = "__HOOK_DATA",
.vmaddr = 0,
.vmsize = DATA_SIZE,
.fileoff = 0,
.filesize = DATA_SIZE,
.maxprot = VM_PROT_READ | VM_PROT_WRITE,
.initprot = VM_PROT_READ | VM_PROT_WRITE,
.nsects = 1,
};
struct section_64 hook_data_sec = {
.sectname = "__hook_data",
.segname = "__HOOK_DATA",
.size = DATA_SIZE,
.align = 3, /* 2^3 = 8-byte alignment; choose for the actual slot ABI */
.flags = S_REGULAR,
};
Fill each section's addr and offset after assigning its segment. Both sections are
file-backed in this design: the RX section contains the finished stubs, and the RW section
contains the initial slot table and pristine state. Do not make the pool RWX merely for
convenience. Runtime-generated executable code is a different design with different
platform requirements.
The new commands increase ncmds by two, not four: each section_64 is embedded in its
own LC_SEGMENT_64 command.
Lay out the file without corrupting __LINKEDIT
In the layout assumed by this example, __LINKEDIT is the final file-backed segment. The
new segments are inserted immediately before a relocated copy of it. This is a chosen
layout, not a Mach-O rule that __LINKEDIT must always be the final command or segment.
Do not hard-code a universal PAGE value. Infer and validate compatible file and VM
alignment from the target image, then use those values consistently. The alignment used
for fileoff and vmaddr need not be described by the section's align field.
layout.c
const uint64_t old_le_fileoff = linkedit_seg->fileoff;
const uint64_t old_le_filesize = linkedit_seg->filesize;
hook_code_seg.fileoff = align_up(original_file_size, file_alignment);
hook_data_seg.fileoff = align_up(hook_code_seg.fileoff + hook_code_seg.filesize,
file_alignment);
const uint64_t new_le_fileoff =
align_up(hook_data_seg.fileoff + hook_data_seg.filesize, file_alignment);
hook_code_seg.vmaddr = align_up(existing_vm_end, vm_alignment);
hook_data_seg.vmaddr = align_up(hook_code_seg.vmaddr + hook_code_seg.vmsize,
vm_alignment);
const uint64_t new_le_vmaddr =
align_up(hook_data_seg.vmaddr + hook_data_seg.vmsize, vm_alignment);
hook_code_sec.addr = hook_code_seg.vmaddr;
hook_code_sec.offset = (uint32_t)hook_code_seg.fileoff;
hook_data_sec.addr = hook_data_seg.vmaddr;
hook_data_sec.offset = (uint32_t)hook_data_seg.fileoff;
Before changing offsets, copy the complete old __LINKEDIT file range to its new location.
Changing segment_command_64.fileoff relocates only metadata; it does not move a single
byte.
move_linkedit.c
uint8_t *le = malloc((size_t)old_le_filesize);
if (!le) fail("allocation failed");
read_exact(fd, le, (size_t)old_le_filesize, old_le_fileoff);
write_exact(fd, le, (size_t)old_le_filesize, new_le_fileoff);
free(le);
linkedit_seg->fileoff = new_le_fileoff;
linkedit_seg->vmaddr = new_le_vmaddr;
const uint64_t linkedit_delta = new_le_fileoff - old_le_fileoff;
The destination must already be inside a safely extended file, and any remapping or buffer
growth can invalidate pointers into the old mapping. Re-resolve header, load commands,
and linkedit_seg after such an operation.
The load-command block still needs slack
The two new segment commands go into the gap between the end of the load-command block and the first file-backed section. If the gap is too small, copying more commands overwrites section data.
uint64_t cmds_end = sizeof(struct mach_header_64) + header->sizeofcmds;
uint64_t slack = first_file_backed_section_offset - cmds_end;
if (slack < hook_code_seg.cmdsize + hook_data_seg.cmdsize)
fail("insufficient header slack");
The insertion code must preserve the existing command tail, place the two new commands at
the intended position, then update ncmds and sizeofcmds. Any pointer into the command
buffer becomes stale after moving that buffer; recompute it before writing fields.
Fix each __LINKEDIT reference using its real structure
Every file offset that points into the old __LINKEDIT range must follow the bytes to the
new range. Do not cast unrelated commands to linkedit_data_command; their layouts differ.
Also do not shift a field merely because it is nonzero—first verify that it falls inside the
old range.
fix_load_commands.c
static void shift_linkedit_offset(uint32_t *off,
uint64_t old_start,
uint64_t old_size,
uint64_t delta)
{
uint64_t value = *off;
uint64_t old_end;
if (__builtin_add_overflow(old_start, old_size, &old_end))
fail("invalid __LINKEDIT range");
if (value >= old_start && value < old_end) {
uint64_t shifted;
if (__builtin_add_overflow(value, delta, &shifted) || shifted > UINT32_MAX)
fail("shifted offset does not fit");
*off = (uint32_t)shifted;
}
}
for (struct load_command *lc = first_cmd;
(uint8_t *)lc < cmds_end_ptr;
lc = next_validated_command(lc, cmds_end_ptr)) {
switch (lc->cmd) {
case LC_SYMTAB: {
struct symtab_command *c = (void *)lc;
shift_linkedit_offset(&c->symoff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->stroff, old_le_fileoff, old_le_filesize, linkedit_delta);
break;
}
case LC_DYSYMTAB: {
struct dysymtab_command *c = (void *)lc;
shift_linkedit_offset(&c->tocoff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->modtaboff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->extrefsymoff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->indirectsymoff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->extreloff, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->locreloff, old_le_fileoff, old_le_filesize, linkedit_delta);
break;
}
case LC_DYLD_INFO:
case LC_DYLD_INFO_ONLY: {
struct dyld_info_command *c = (void *)lc;
shift_linkedit_offset(&c->rebase_off, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->bind_off, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->weak_bind_off, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->lazy_bind_off, old_le_fileoff, old_le_filesize, linkedit_delta);
shift_linkedit_offset(&c->export_off, old_le_fileoff, old_le_filesize, linkedit_delta);
break;
}
case LC_DYLD_CHAINED_FIXUPS:
case LC_DYLD_EXPORTS_TRIE:
case LC_FUNCTION_STARTS:
case LC_DATA_IN_CODE:
case LC_SEGMENT_SPLIT_INFO:
case LC_DYLIB_CODE_SIGN_DRS:
case LC_LINKER_OPTIMIZATION_HINT:
case LC_CODE_SIGNATURE: {
struct linkedit_data_command *c = (void *)lc;
shift_linkedit_offset(&c->dataoff, old_le_fileoff, old_le_filesize, linkedit_delta);
break;
}
case LC_TWOLEVEL_HINTS: {
struct twolevel_hints_command *c = (void *)lc;
shift_linkedit_offset(&c->offset, old_le_fileoff, old_le_filesize, linkedit_delta);
break;
}
}
}
That list covers common final-image commands, but the correct rule is structural: inspect
every command and section in the actual input, and shift every file offset that refers to
the moved range. For example, a section's reloff also needs attention if it points there.
Fields such as LC_MAIN.entryoff use a different base and must not be shifted merely because
they look like offsets.
The code-signing blob is regenerated rather than patched in place. The final signing step
must also leave LC_CODE_SIGNATURE and __LINKEDIT.filesize/vmsize consistent with the
new blob. Validate the finished image with independent Mach-O tooling before installation.
ldid -S<Target.entitlements> Payload/App.app/App
Link Identity Editor. Put real or fake signatures in a Mach-O.
Preparing a site, not just a pool
A useful offline API makes the distinction explicit:
/* Offline, before signing. Creates the slot and executable dispatch path, relocates the
overwritten instructions, and rewrites this specific site to enter the prepared path.
Returns `nil` when the site was prepared, or a message saying why it refused (no header
slack, a fat file, and so on) — the example's stand-in for an `NSError` out-parameter. */
NSString *PrepareSite(const char *machoPath,
uint64_t targetRVA,
const PatchVariant *variants,
size_t variantCount);
/* Runtime. These functions update only the already-prepared RW slot. */
BOOL SetMode(uint64_t targetRVA, uint32_t mode);
BOOL SetPayload(uint64_t targetRVA, const uint8_t *bytes, size_t len);
BOOL RestorePreparedState(uint64_t targetRVA);
targetRVA is a virtual-address-relative identifier chosen by this API. It is not a file
offset. Define the image's preferred base once and use:
RVA = target_vmaddr - preferred_image_base
liveAddress = target_vmaddr + ASLR_slide
Converting a file offset to a virtual address instead requires locating the containing segment and applying:
vmaddr = segment.vmaddr + (fileOffset - segment.fileoff)
The concepts coincide only in simple layouts. Keep them distinct in APIs and variable names.
A runtime toggle
The writable pool can reserve generous capacity, but executable sites cannot be discovered late. Capacity lets an already-prepared site accept new state or payload values without another installation; a previously unseen code site still requires offline instrumentation and re-signing.
state.c
typedef struct {
_Atomic uint32_t state; /* 0 = baseline; other values select prepared behavior */
uint32_t length;
uint8_t payload[64];
} HookSlot;
If readers can observe the slot concurrently, define a publication protocol rather than
writing length and payload while another thread consumes them. One simple pattern is to
write an inactive copy, then publish its index with a release-store; the stub reads the index
with acquire semantics. A single atomic state is enough only when the payload itself is
immutable or otherwise synchronized.
Store the pristine baseline in the file-backed slot or in the RX path so restore does not depend on bytes captured after a previous modification.
KittyMemory: useful API, different targets
C++ library for runtime memory patching, scanning, dumping, and module (ELF / Mach-O) introspection, targeting Android and iOS.
In the signed-code design, point MemoryPatch at the RW slot—not at the original code RVA.
Resolve the slot from its preferred VM address and the image's ASLR slide:
#include <mach-o/dyld.h>
#include <KittyMemory/MemoryPatch.hpp>
uintptr_t slotAddress =
static_cast<uintptr_t>(_dyld_get_image_vmaddr_slide(imageIndex)) + slotVMAddr;
MemoryPatch mode1 = MemoryPatch::createWithHex(slotAddress, "01 00 00 00");
MemoryPatch mode2 = MemoryPatch::createWithHex(slotAddress, "02 00 00 00");
mode1.Modify();
mode1.Restore();
mode2.Modify();
Here the four bytes are data interpreted by the prepared stub. They are not newly generated instructions. In an unrestricted environment where direct executable patching is permitted, the current assembler overload requires the architecture and can take the assembly address:
MemoryPatch direct = MemoryPatch::createWithAsm(
targetAddress,
MP_ASM_ARM64,
"mov w0, #1\nret",
targetAddress);
The final argument matters for assembly containing PC-relative expressions. This direct example is outside the enforced signed-code design; an API call cannot make an otherwise forbidden executable-page write valid.
Dobby: what the prepared segments do not solve
a lightweight, multi-platform, multi-architecture hook framework.
An inline-hook engine normally has to rewrite the target site and create executable trampoline code. A separate RW pool solves neither operation. Therefore this familiar call:
DobbyHook((void *)target, (void *)hook_impl, (void **)&orig);
has two different interpretations depending on the environment:
| Environment | What is valid |
|---|---|
| Debug, jailbroken, or otherwise unrestricted | Dobby may patch the target and allocate or use executable trampoline memory normally |
| Enforced signed-code environment discussed here | The interception branch, relocated instructions, and executable dispatch path must already exist before signing; runtime changes stay in RW state |
DobbyInstrument changes the callback model, not this installation constraint: arranging
instruction-level instrumentation still requires an interception mechanism at the target.
Adding __HOOK_DATA does not silently make an arbitrary DobbyHook or
DobbyInstrument target patchable.
For the constrained design, Dobby can still be useful as an offline reference or as part of a build-time integration, but the resulting executable bytes must be materialized in the file before signing. At runtime, the prepared dispatcher can use the same state-driven shape:
dispatcher.c
int prepared_dispatch(int a, int b, const HookSlot *slot)
{
switch (atomic_load_explicit(&slot->state, memory_order_acquire)) {
case 0: return prepared_original(a, b);
case 1: return prepared_original(a * 2, b * 2);
case 2: return 1;
case 3: return -prepared_original(a, b);
default: return prepared_original(a, b);
}
}
The boundary to remember
The reusable resource is runtime data capacity. The non-reusable resource is an unmodified executable site.
- A prepared site can switch modes, constants, tables, or payload data at runtime.
- An unprepared RVA cannot be connected to the pool merely by adding a new slot.
- RX code is generated and signed offline.
- RW state changes at runtime.
- RVA, VM address, file offset, and live address are separate coordinate systems.
Once that boundary is explicit, the design closes cleanly: offline Mach-O surgery creates the executable path, code signing seals it, and runtime code only selects among behavior the signed image already knows how to perform.