zrwrite is a static binary patching framework focused on AArch64 targets.
Today it targets:
- Linux ELF64
- macOS thin Mach-O arm64
The main use case is: you have a stripped or optimized binary, you know a useful patch site, and you want to inject new logic without rebuilding the original program.
zrwrite currently supports two main hook styles:
replaceinstrument
replace is for full detours:
- replace a function entry
- force a return value
- redirect execution into a new implementation
instrument is for patch-site logic injection:
- log or inspect registers
- modify arguments or return values
- early-return by editing
ctx.pc - instrument interior instructions inside large optimized functions
The design goal is deliberately low-level:
zrwriteguarantees architectural control over registers / PC / SP / replayed instructions- language ABI meaning is owned by the payload author
If you patch an Objective-C, C++, Rust, Swift, or custom ABI boundary, you must write the payload with the correct calling convention and runtime layout in mind.
Current public scope:
- AArch64 only
- static patching, not runtime injection
- Zig-authored payload objects are the primary path
- C payload objects can also work when they stay within the supported payload model
- payload sections:
.text.rodata.data.bss
Explicitly not a goal right now:
- "arbitrary normal Zig executable" support inside payloads
- TLS-heavy payload code
- full C++ exception / unwind interoperability
- fat / universal Mach-O handling
- iOS / arm64e / PAC-specific support
The normal workflow has three steps:
- Write a payload and build it into an object file.
- Describe the hook(s) in a meta JSON or pass them through CLI flags.
- Build a
.zrpbbundle and apply it to the target binary.
If you want a quick starting template in the current directory:
zrwrite init-metaThat creates bundle.meta.json with editable placeholder fields. Use
zrwrite help init-meta to switch the template between instrument /
replace, different target locator kinds, or overwrite an existing file.
Typical commands:
zig build-obj -target aarch64-macos -O ReleaseSmall -fstrip \
-Mroot=payload.zig \
-femit-bin=payload.o
zrwrite bundle --output patch.zrpb --meta hook.meta.json
zrwrite apply --bundle patch.zrpb --input target.bin --output target.patchedOn macOS you must ad-hoc sign the patched output before running it:
codesign -f -s - target.patchedThe human-authored meta JSON format is:
{
"target": {
"arch": "aarch64",
"os": "macos",
"binary_format": "macho"
},
"payload": {
"object_path": "payload.o",
"object_format": "macho"
},
"hooks": [
{
"kind": "instrument",
"target": {
"kind": "virtual_address",
"virtual_address": "0x1000038bc"
},
"expected_bytes": "4810001208150011",
"handler_symbol": "on_hit"
}
]
}Important details:
payload.object_pathis resolved relative to the meta file path- the meta file does not name the target binary
- it only describes how to build the bundle and where the hook should land
- one meta file can describe multiple hooks
Hook targets can currently be located by:
- symbol
- linked virtual address
- file offset
- exact byte pattern plus optional offset
Use replace when:
- you want a different implementation
- you want to intercept a clean function entry
- you do not need to preserve the original body
Use instrument when:
- the original function is too large or inlined
- you only want to tweak state at a specific instruction
- you want to log, gate, or rewrite control flow locally
instrument is the more important mode for stripped / optimized binaries.
Suppose the original Objective-C method is:
- (const char *)fetchBodyCString;You can replace it with a Zig payload that matches the AArch64 C ABI view of that method:
const hooked = "[artest] hooked.";
export fn replacement_fetchBodyCString(
self: ?*anyopaque,
cmd: ?*anyopaque,
) callconv(.c) [*:0]const u8 {
_ = self;
_ = cmd;
return hooked.ptr;
}Matching meta JSON:
{
"target": {
"arch": "aarch64",
"os": "macos",
"binary_format": "macho"
},
"payload": {
"object_path": "payload_replace.o",
"object_format": "macho"
},
"hooks": [
{
"kind": "replace",
"target": {
"kind": "symbol",
"symbol": "-[ArHttpClient fetchBodyCString]"
},
"handler_symbol": "replacement_fetchBodyCString"
}
]
}You can achieve an early return entirely at the register / PC layer:
const zrwrite = @import("zrwrite");
const hooked = "[artest] hooked.";
export fn on_hit(address: u64, ctx: *zrwrite.HookContext) callconv(.c) void {
_ = address;
// Objective-C method call ABI on AArch64:
// x0 = self
// x1 = _cmd
// x30 = caller return address
_ = ctx.regs.named.x0;
_ = ctx.regs.named.x1;
ctx.regs.named.x0 = @intFromPtr(hooked.ptr);
ctx.pc = ctx.regs.named.x30;
}Matching meta JSON:
{
"target": {
"arch": "aarch64",
"os": "macos",
"binary_format": "macho"
},
"payload": {
"object_path": "payload_instrument.o",
"object_format": "macho"
},
"hooks": [
{
"kind": "instrument",
"target": {
"kind": "virtual_address",
"virtual_address": "0x100000d50"
},
"expected_bytes": "680a00f9",
"handler_symbol": "on_hit"
}
]
}expected_bytes is strongly recommended for any real patch.
It is the first safety guard against:
- binary version drift
- compiler changes
- wrong IDA address recovery
- accidental patching of the wrong build
If the bytes at the target location do not match, zrwrite fails closed
instead of silently patching the wrong code.
Use inspect to recover a stable hook snippet:
zrwrite inspect \
--input target.bin \
--symbol some_symbolOr:
zrwrite inspect \
--input target.bin \
--vaddr 0x100000d50inspect prints:
- linked virtual address
- file offset
- recommended
expected_bytes - an exact pattern snippet you can paste into meta JSON for stripped binaries
At a high level, instrument works like this:
- Resolve the hook site from symbol / vaddr / file offset / pattern.
- Validate the bytes at that site.
- Determine how many instructions must be stolen from the original binary.
- Build a bridge / trampoline path.
- Inject the payload object and relocate its code/data.
- Redirect execution at the patch site into the bridge.
- Save architectural state into
HookContext. - Call your payload callback.
- Resume according to the selected hook mode.
That resume step is the hard part.
If an overwritten instruction is safe to replay in a raw trampoline, zrwrite
can execute it out-of-line. If it is PC-relative or otherwise relocation
sensitive, the patcher must either:
- apply an explicit semantic replay strategy, or
- reject the patch
The core rule is: if zrwrite cannot prove the rewritten control flow is safe,
it should fail closed.
For interior instrumentation, one instruction is not always enough.
stolen_instruction_count tells zrwrite to steal a wider patch window. This
is useful when:
- the hook site must be widened to install a long detour
- the displaced sequence must be replayed as a small group
- you are instrumenting inside a large optimized / inlined function
This is not "copy arbitrary bytes and hope".
Wide windows only work when zrwrite can prove the displaced instructions are
handled correctly. Otherwise the patch is rejected.
Two important consequences:
- incoming branches into the middle of the stolen window are a special case
- PC-relative instructions require explicit replay support
If you care about the exact replay policy, read
docs/replay-policy.md.
One of the easiest ways to write a wrong payload is mixing file offsets, linked addresses, and runtime pointers.
zrwrite uses three address domains:
- file offset
- linked virtual address
- runtime virtual address
Payload rule:
- register values,
ctx.pc,ctx.sp, and pointers read from target memory are already runtime addresses - addresses copied from IDA / Ghidra /
objdumpshould be treated as linked addresses
For PIE / ASLR-safe payload code, resolve recovered linked addresses through
ctx.target():
const img = ctx.target();
const fn_ptr = img.fnPtr(*const fn () callconv(.c) void, 0x100012340);Do not manually scatter + load_bias math across payload code.
Read:
The long-term payload callback shape is:
export fn on_hit(hit_address: u64, ctx: *zrwrite.HookContext) callconv(.c) voidHookContext is an architectural snapshot. Payloads are expected to:
- read and write GPR state
- read and write
pc - read and write
sp - reason about target image base / load bias through runtime metadata
Important design rule:
zrwriteexposes architecture state- it does not understand the source-language semantics for you
If you patch:
- Objective-C message sends
- Rust return slots
- C++ object layouts
- Swift reference semantics
you must recover and respect that ABI yourself.
Within one rewrite session, repeated instrument hooks that point at the same
payload object behave like different exported functions from one injected
payload module.
That means:
- payload
.textis injected once - payload
.data/.bssare shared across those handlers - payload globals are shared between those instrument callbacks
This is the user-facing "looks like one payload" model.
This shared-state guarantee should currently be read as an instrument-hook
rule. Do not assume replace hooks share runtime payload state in exactly the
same way.
A patch payload is not a normal hosted Zig executable.
That is why zrwrite ships zrstd: a small payload-side helper layer for
things like:
- printing
- bounded formatting
- fixed-buffer assembly
- explicit byte copy / move / fill helpers
Use zrstd when possible instead of assuming std.debug.print or a large
stdlib path is safe for payload code.
Current payload authoring rules:
- prefer explicit, boring code
- avoid hidden runtime dependencies
- avoid TLS-heavy features
- avoid assuming libc or a process runtime exists just because the target program links one
Read:
These are the main things users should know before writing serious patches.
If you instrument the middle of a Rust, Objective-C, or C++ function, the register state only reflects the machine-level ABI at that instruction.
You might be seeing:
- a hidden return slot
- inlined temporaries
- compiler-owned scratch values
- register allocation artifacts
Reverse-engineering the real meaning of the site is your job.
Do not use replace when you actually need local control-flow surgery inside a
huge optimized function. Use instrument.
Do not use instrument when a clean function-entry replacement is enough. Use
replace.
--target-vaddr and meta JSON virtual addresses should use the linked image
address seen in disassembly, not a runtime ASLR-shifted pointer.
Treat expected_bytes as a default safety requirement, especially for:
- stripped binaries
- file-offset hooks
- pattern-derived hooks
- CI or repeatable patch pipelines
If zrwrite rejects a hook, that usually means it found a real correctness
problem:
- unsupported relocation family
- unsupported replay case
- multiple pattern matches
- bytes mismatch
- unsafe patch window
A hard failure is better than a subtly broken binary.
On macOS, structural patch success is not enough. The final output still needs to be codesign-clean enough for ad-hoc signing and runtime execution.
Payload code that implicitly pulls in TLS, thread-locals, or a large hosted runtime surface is still outside the intended safe subset.
If you are authoring a new payload, the safest pattern is:
- Start with
inspect. - Patch one clean site first.
- Add
expected_bytes. - Keep the payload tiny until the hook is known-good.
- Only then start layering language-specific logic on top.
For stripped binaries:
- prefer pattern locators or linked VAs recovered from IDA
- use exact bytes as a version guard
- keep your own recovered addresses in linked form and resolve them through
ctx.target()
Local validation:
zig build
zig build testLinux/AArch64 runtime validation uses the Orb-hosted Ubuntu machine:
ssh ubuntu@orbThe current remote smoke coverage is documented in
docs/testing.md.
docs/patch-abi.mddocs/address-model.mddocs/pie-api.mddocs/replay-policy.mddocs/zrstd.mddocs/testing.mddocs/macho-reloc-status.mddocs/v1-scope.md
The current project direction is:
- static AArch64 patching first
- fail-closed correctness over "best effort"
- strong support for stripped / optimized binaries
- enough payload runtime surface to write real Zig instrumentation
If you find a hook site that should be valid but currently fails, that is a useful bug report. The intended model is not "only easy function entry hooks"; the intended model is "real interior instrumentation for difficult binaries, with correctness checks instead of silent corruption."