English | 简体中文
ArmCaveHook is an ARM64/AArch64 static binary patch framework for 64-bit Apple Mach-O and Android ELF binaries. It compiles C++ plugins, supports direct AArch64 assembly patches, creates isolated code and data segments, and writes the patched binary.
git clone --recursive https://github.com/SweelLong/ArmCaveHook.git
cd ArmCaveHook
./build.shSelect an enabled profile in armcave.conf before building.
#include "armcave.h"
extern "C" int replacement(int value) { return value + 1; }
extern "C" void init(void) {
hook_replace(0x100000498, replacement, w0);
}The framework currently supports Apple Mach-O and Android ELF injection, AArch64
decoding, relocation, CFG/function analysis, far branches, isolated plugin code
and data segments, symbol and PLT/GOT lookup, byte signatures, Mach-O chained
fixups, Objective-C and Swift metadata, patch.toml, and structured diagnostics.
Planned work includes code-signing workflow guidance, byte-signature stability
guidance, optional cross-version address assistance, a lightweight C++ plugin
toolkit, and broader Android ELF coverage for DT_RELR and eh_frame.
A section is a fine-grained logical unit that serves the linker and the debugger; it describes the attributes of code, data or debug information. A segment is a coarse-grained loading unit that serves the kernel loader and the dynamic linker; it groups sections that share the same memory permissions (read / write / execute) so they can be mapped into memory in one go. A segment contains zero or more sections — what the linker really does is pack sections into segments by permission.
How visible that relationship is depends on the format:
- Mach-O (Apple): the hierarchy is in the name.
__TEXTis a segment and__text/__cstringare sections inside it, so IDA shows__TEXT.__cstring. The question "are the strings inside the code?" has a definite answer here: strings live in the__cstringsection of the__TEXTsegment, a sibling of the code section__textunder the same segment. - ELF (Android) and PE: sections are peers.
.textand.rodataare two independent sections with no containment relationship. They are only packed into the same R+XPT_LOADat link time, and share one mapping at run time. Occasionally a compiler drops string constants straight into.textto reuse its read-only, pre-initialized nature — that is a special case inside a section, not a format-level hierarchy.
ArmCaveHook caves follow the same model:
- Mach-O: the two added segments
__ncp_code(R+X) and__ncp_data(R+W) hold the plugin sections, so IDA shows__ncp_code.__ncp_ap. - ELF: the two added
PT_LOADentries hold plugin sections that stay peers at section level (.ncp_apand friends) but are mapped together by one R+X segment.
The shared cave name is not hardcoded; it is derived from the plugin segment names taking part in the patch run:
- take the longest common prefix of all plugin segment names and strip the
platform prefix (
__/.); - append
_if the remaining prefix does not end with one; - append
code/data— giving<common prefix>_codeand<common prefix>_data.
| Plugin segment names | Common prefix | Cave names |
|---|---|---|
__ncp_ap, __ncp_cp, __ncp_p64, __ncp_rs |
ncp_ |
__ncp_code / __ncp_data |
a single plugin __ncp_rs |
ncp_rs → ncp_rs_ |
__ncp_rs_code / __ncp_rs_data |
__ncp_rs and __zzzplay (nothing in common) |
— | falls back to __armcave_code / __armcave_data |
The Mach-O segment name field is 16 bytes, so the logical prefix is capped at
9 characters (2 + 9 + _ + 4 = 16) and truncated beyond that.
The Android (ELF) side splits caves by the same rule, but a PT_LOAD has no
name field, so the derived name has nowhere to appear — the cave is identified
by that one PT_LOAD, and plugin sections keep their . + logical name (see
segment naming). The rule is the same on both targets; only
ELF has no place to show it.
In IDA: View → Open subviews → Segments shows segments (the load view, with
permissions), while Ctrl+S (or Shift+F7) lists sections (the link view). The
__TEXT.__cstring or __ncp_code.__ncp_ap label next to an address in the
disassembly is exactly the segment.section pair.
On both targets a single patch run adds exactly two containers: two
LC_SEGMENT_64 entries on Mach-O and two PT_LOAD entries on ELF. Every plugin
code section is packed into one R+X cave and every writable data section into one
R+W cave. The cave count does not grow with the plugin count.
Apple (Mach-O): __ncp_code (R+X) plus __ncp_data (R+W)
Android (ELF): one R+X cave plus one R+W cave
The two caves are separated by a page-aligned gap. The loader maps every segment over page-rounded boundaries, so if both caves shared a page the data mapping would remap the tail page of the code cave as R+W and that code would no longer be executable.
Both charts come from patching with four plugins. Mach-O: segments 4 → 12 (24,542,416 bytes) became 4 → 6 (24,443,920 bytes). ELF: LOAD 3 → 11 (29,445,600 bytes) became 3 → 5 (29,347,968 bytes). The Mach-O side also saves load command space: eight 152-byte commands became two commands totalling 784 bytes.
A patch run is not slow because of C++ compilation: building the armcave
tool itself takes ~4.4s from a clean build directory (~0.1s incremental). The
cost is all in the injection pass. Every write path used to go through the same
two helpers — read_file() (an ifstream reading the whole file) and
write_file() (an ofstream opened with ios::trunc, so it rewrites the
whole file) — so patching the 24 MB Arc-mobile.mac-catalyst image with 68
direct patches read and rewrote it 68 times, roughly 4.9 GB of I/O, and each
round also re-parsed the Mach-O from scratch (246 ms per parse, since
BinaryImage::parse reads the image and builds its symbol table). The chart is
one run of that profile on Apple Silicon, single-threaded and ~96% busy on one
core.
The phase timings come from order-preserving differential timers around each
stage. To reproduce them, add the same timers to pipeline.cpp /
compiler.cpp / patcher.cpp, rebuild just those three objects into
build/CMakeFiles/armcave.dir/src/ and relink — no clean build needed.
- Partial I/O instead of whole-file rewrites. New
read_range()/write_range()primitives (std::fstreamopenedbinary|in|out, seek to the target offset, write).write_at_offset()and the three patch writers now touch only the bytes they are changing. - Reuse the parsed image.
patch_hook_window(),patch_call_window(),patch_bytes_va()andmatches_expected()take aBinaryImage &instead of a path, so the direct-patch loop parses the final layout once instead of once per site. - Compile plugins concurrently. Plugins have no dependency on each other,
so they are built in parallel with
std::thread. The resulting.ofiles are byte-identical to the serial build.
A control binary built from the same sources with only the performance changes reverted produces an output that is byte-identical to the optimised output up to offset 24,378,087; everything past that point is the Mach-O tail ad-hoc code signature (the code directory embeds a timestamp), which differs between any two runs of the same binary as well.
While doing this comparison the instrumentation also exposed a latent bug worth
keeping in mind: parse_binary() reassigns a function-local static unique_ptr, so any BinaryImage & held across a later parse_binary() call
is dangling. The hook-window writers now hold a freshly parsed image instead.
- CMake 3.24 or newer
- C++17 compiler
- Clang and Clang++ for AArch64 plugin objects
MIT