JHook is an Android library for ARM64 (AArch64) inline hooks on native code.
The hook engine is implemented entirely in Java (rprop.java.jhook): JCodeGen / JAssembler generate AArch64 machine code at runtime, JPatch patches the process via /proc/self/mem, and JMem executes stub pages from mmap.
| Package | rprop.java.jhook |
Gradle namespace |
rprop.java.jhook |
| minSdk | 24 |
| compileSdk | 36 |
| ABI | ARM64 only (hooks native code in the same process) |
| Implementation | Pure Java; no bundled native library |
JHook.install(target, JCallback)— inline patch attarget(see Hook site below).onHookreturnsfalse— restore saved registers, run trampoline (relocated overwritten insns), thencontinuePC(rest of the function).onHookreturnstrue— restore and RET to the caller (skip trampoline and tail); set return value withctx.setX0(...)(andsetLRif needed).- Hook site — any 4-byte-aligned address inside a native function, not only the symbol entry.
- BTI / PAC — if the first overwritten word at
targetis BTI/PAC, the patch skips it; trampoline NOPs those slots and relocates PC-relative insns in the backed-up range. - Patch — 4-byte
Bwhen within ±128 MB of the bridge; elseMOVZ/MOVK+BR x16(16 bytes). - Symbols —
JElf.findSymbol/dlopenvia/proc/self/maps;RTLD_FILE(on-disk ELF) orRTLD_MEMORY(PT_DYNAMICin the mapping). - Maps —
JMaps.forEach/findFirstwith a callback over/proc/self/mapslines. - Bridge saves (optional) — GPR (256 B), D0–D31, Q0–Q31 (
VR, exclusive with FPR), NZCV.
target passed to install is the virtual address of the instruction to patch, not necessarily the function entry from JElf.findSymbol.
long target = JElf.findSymbol("libc.so", "open");
// target += 4; // same function, one instruction later (must be 4-byte aligned)
JHook.install(target, callback);What happens:
JPatch.peekBytesreads up toMAX_PEEKbytes fromtarget.- If the first word is BTI/PAC (typical at function entry), it is left in place; the branch patch starts after it.
- Otherwise the patch starts exactly at
target(mid-function hook). originalBytesSizebytes are copied out, relocated into the trampoline, then execution continues atcontinuePC = target + originalBytesSize.
Requirements and limits:
| Topic | Detail |
|---|---|
| Alignment | ARM64 instructions are 4 bytes — do not hook inside an instruction. |
| PC-relative insns | In the overwritten range must be relocatable; otherwise install throws. |
| Trampoline size | Relocated code must fit in 512 B (TRAMPOLINE_SIZE). |
| Register context | Mid-function hooks see CPU state at that point (not necessarily “fresh” entry arguments). |
| BTI/PAC | Only auto-skipped when they are the first word at target. |
jhook/
├── jhook/
│ ├── build.gradle
│ ├── consumer-rules.pro
│ └── src/main/java/rprop/java/jhook/
│ ├── JHook.java
│ ├── JCallback.java
│ ├── JContext.java
│ ├── JCodeGen.java
│ ├── JAssembler.java
│ ├── JPatch.java
│ ├── JMem.java
│ ├── JElf.java
│ ├── JMaps.java
│ ├── JArtMethod.java
│ ├── JDisassembler.java
│ └── JLog.java
├── demo/ # sample app (may be gitignored locally)
└── tools/
caller
▼
… instructions …
▼
hook site [BTI/PAC?] + B or MOV+BR → bridge
▼
bridge: save (optional) → JNI → JHook.callback → CBNZ x0
→ false: restore → trampoline → continuePC → rest of function
→ true: restore → RET (intercept)
| Region | Offset | Size (max) | Role |
|---|---|---|---|
| Bridge | page.base |
1280 B | Save/restore, JNI, intercept branch |
| Trampoline | base + 1280 |
512 B | Relocated overwritten insns → continuePC |
mmap hint is near target so a 4-byte B can reach the bridge when possible.
| Offset | Field |
|---|---|
| 0 | JavaVM* |
| 8 | JavaVM->functions |
| 16 | jmethodID — JHook.callback(long, long) |
| 24 | global ref — JHook class |
Init RX stub is unmapped after bootstrap.
dependencies {
implementation project(':jhook')
}Call once before install(). Internally the library builds a temporary init stub, runs it through JArtMethod to fill the RW page (JavaVM, jmethodID for JHook.callback, global ref), then unmaps the stub.
import rprop.java.jhook.JHook;
import rprop.java.jhook.JElf;
import rprop.java.jhook.JCallback;
import rprop.java.jhook.JContext;
JHook.init();long target = JElf.findSymbol("libc.so", "open");
if (target == 0) {
throw new UnsatisfiedLinkError("symbol not found");
}
JCallback hook = new JCallback() {
@Override
public boolean onHook(final JContext ctx) {
// return false → run original via trampoline
// return true → intercept; ctx.setX0(...) for return value
return false;
}
};
JHook.HookInfo info = JHook.install(target, hook);
if (info == null) {
throw new IllegalStateException("install failed");
}Flags — install(target, callback, preserveGPR, preserveFPR, VR, preserveCSR):
| Flag | Effect |
|---|---|
preserveGPR |
256-byte x0–x28, LR, SP snapshot (JContext) |
preserveFPR |
save/restore D0–D31 |
VR |
save/restore Q0–Q31 (not with preserveFPR) |
preserveCSR |
save/restore NZCV |
Default: install(target, callback) → GPR only.
Same target twice returns the existing HookInfo.
JHook.uninstall(target);Restores HookInfo.layout.originalBytes and munmap the hook RX page.
The demo module (rprop.java.jhook.MainActivity) shows:
JHook.init()- Resolve
libc.soopen, optionaltarget += 4for mid-function hook installwithreturn trueandsetX0(0)to interceptopenFileInputStream("/proc/self/maps")to trigger the hookuninstall
./gradlew :demo:installDebugpublic abstract boolean onHook(final JContext context);JHook.callback(long targetAddress, long snapshotSp) looks up HookInfo and returns onHook(...); the bridge CBNZ uses that boolean for the intercept path.
JContext |
Description |
|---|---|
info |
JHook.HookInfo |
registerBuffer |
JMem.wrap(snapshotSp, …) when preserveGPR is enabled |
Registers: X0–X28, FP, LR, SP — getLong/setLong, getXn/setXn, or named accessors.
| Method | Description |
|---|---|
init() |
Bootstrap RW page; register JNI callback |
install(target, callback) |
Hook with GPR snapshot |
install(…, preserveGPR, preserveFPR, VR, preserveCSR) |
Hook with optional save/restore |
uninstall(target) |
Restore bytes; free RX page |
isHooked(target) |
Registered? |
getInfo(target) |
HookInfo or null |
callback(target, sp) |
JNI entry |
targetAddress, bridge, trampoline, callback, page, layout (JCodeGen.TargetPatch).
| Class | Role |
|---|---|
JPatch |
peekBytes, pokeBytes, pokeAllBytes (/proc/self/mem) |
JMem |
allocateRX / allocateRW, wrap, readCString |
JElf |
findSymbol, dlopen, dlopenAt, dlsym; flags RTLD_FILE, RTLD_MEMORY |
JMaps |
forEach, findFirst, pathMatches |
JCodeGen |
buildBridgeCode, buildTrampoline, buildInitStub, … |
JAssembler |
AArch64 encoder |
JDisassembler |
disassemble(ptr, size) |
JLog |
d (debug), e |
install does not flush I/D cache after poke; flush before executing patched code if required on your device.
./gradlew :jhook:assembleRelease- Java 21
hiddenapibypassforDirectByteBufferon API 28+- Library release build may use R8; see
consumer-rules.pro
consumer-rules.pro keeps public classes/interfaces and native methods under rprop.java.jhook.**.
App subclasses of JCallback are not in that package — add in the app, for example:
-keep class * extends rprop.java.jhook.JCallback {
public boolean onHook(rprop.java.jhook.JContext);
}
MIT License