Inside our inline hooking engine: relocating prologues with iced-x86, and the instruction window we can’t close
A complete x86-64 and AArch64 inline detour engine whose
dependency list is closed to exactly two crates:
iced-x86 and thiserror. No
frida-gum, no vendored Dobby or shadowhook. It installs a hook in six
stages, writes the 5-byte prologue patch as a single aligned 8-byte
store, and (stated up front) leaves one
instruction-window race it does not claim to close. Below is the whole
pipeline, with the real encodings.
We wrote earlier about why we built our own hooking engine instead of wrapping one. This is the how: the in-process, inline-detour engine that patches a live function’s prologue and reroutes it through a trampoline. It’s a single Rust crate, and this post walks its pipeline with the real identifiers, the real encodings, and, up front, the one hazard it does not solve.
The dependency list is closed to two crates
The engine depends on exactly iced-x86 and
thiserror. No frida-gum, no vendored Dobby
or shadowhook, and no lint-policy exception. The crate’s
own lint policy keeps exceptions = [] empty.
It doesn’t even depend on another Rutken crate: it returns its
own InstallError/ResolveError and knows
nothing about Rutken’s wire protocol. A test pins that list
mechanically.
The iced-x86 dependency has a subtlety that is the whole
reason the crate is feature-gated. Our disassembler crate uses
iced-x86
decoder-only, so it never re-encodes or renders
instructions, so it resolves
["std", "decoder", "instr_info"] and nothing more.
This engine’s entire reason to exist is re-encoding
relocated prologues, so it needs encoder +
block_encoder. Cargo unifies features across the build
graph: if the engine asked for those unconditionally, a
default cargo build --workspace would silently hand
the disassembler’s copy of iced-x86 the encoder
too. So the dependency is optional and gated behind a
non-default bespoke-agent feature:
[features]
default = []
bespoke-agent = ["dep:iced-x86"]
[dependencies]
iced-x86 = { workspace = true, optional = true, features = [
"encoder",
"block_encoder",
] }
thiserror = { workspace = true }
And a feature-gate test proves it, via
cargo tree rather than by assertion-of-intent. One
direction checks a default build never pulls the dep in at all; the
inverse checks the feature actually wires it up (so the first test
isn’t vacuously green because the dep got renamed):
#[test]
fn default_hookengine_build_does_not_link_iced_x86() {
let tree = cargo_tree(&["-p", "hookengine", "--no-default-features", "-e", "normal"]);
assert!(
!tree.contains("iced-x86"),
"default-feature `hookengine` must not depend on `iced-x86` ...\n{tree}"
);
}
A default workspace build doesn’t resolve
iced-x86’s encoder with the feature unused
(the optional dependency is genuinely absent from the graph),
which is exactly what keeps the disassembler’s feature set
byte-for-byte what it was before this crate existed.
Six stages, x86-64 first
The engine is six independently testable stages, and
Engine::install runs them strictly in order, checking
every refusal condition before it touches any live or allocated
memory:
// Stage 1 symbol resolution -> runtime address of the target
// Stage 2 prologue decode -> decode until >= PATCH_LEN (5) bytes covered,
// never splitting an instruction
// Stage 3 RELOCATION -> re-encode the moved prologue for its new home
// Stage 4 near-memory alloc -> trampoline within rel32 reach of the target
// Stage 5 page protection -> flip the trampoline RX, the site RWX briefly
// Stage 6 atomic patch -> one aligned store overwrites the prologue
Decode never splits an instruction: it keeps decoding whole
instructions until the cumulative length is >= PATCH_LEN
(5 bytes, the length of a jmp rel32), so the bytes we
later relocate are always a clean run of complete instructions. A
prologue that ends in a ret before it covers 5 bytes, or
that is already an unconditional jump (someone else’s hook) or a
PLT-style indirect stub, is refused by name
(PrologueTooShort, ForeignHookPresent,
ResolvedToStub), never forced. The whole decode
loop is small enough to read in one sitting (too_short(n)
below is shorthand for building the
PrologueTooShort { need: PATCH_LEN, found: n } error):
pub fn decode_prologue(code: &[u8], ip: u64) -> Result<(Vec<Instruction>, usize), InstallError> {
let mut decoder = Decoder::with_ip(64, code, ip, DecoderOptions::NONE);
let mut instructions = Vec::new();
let mut covered: usize = 0;
while covered < PATCH_LEN { // PATCH_LEN == 5 (jmp rel32)
if !decoder.can_decode() { return Err(too_short(covered)); }
let instr = decoder.decode();
if instr.is_invalid() { return Err(too_short(covered)); }
let len = instr.len();
if len == 0 { return Err(too_short(covered)); } // iced "never" does this; don't spin forever on it
covered += len;
let is_ret = matches!(instr.mnemonic(), Mnemonic::Ret);
instructions.push(instr);
if is_ret && covered < PATCH_LEN { // function ends before 5 bytes: a stub, refuse it
return Err(too_short(covered));
}
}
Ok((instructions, covered))
}
The len == 0 guard is not theoretical paranoia dressed
up: iced-x86 should never return a zero-length valid
instruction, but if it did, the loop would make no progress
and spin forever on a live target’s memory. A hang is a worse
failure than a refusal, so a zero-length decode is treated as
“too short” and the install is declined rather than risked.
Stage 3 is the heart: relocation
When you move a prologue’s bytes to a trampoline, every
RIP-relative operand and every branch displacement in them is now
wrong, because it was computed against the original address.
Relocation re-encodes those instructions so they still target the
same absolute addresses from their new location. On x86-64 we hand
that entirely to iced-x86’s
BlockEncoder, which exists to do precisely this. The
whole module is small on purpose. Our job is to hand it the
decoded prologue at a new rip and turn any failure into a
named refusal rather than guessing:
use iced_x86::{BlockEncoder, BlockEncoderOptions, Instruction, InstructionBlock};
pub fn relocate(instructions: &[Instruction], new_ip: u64) -> Result, InstallError> {
let block = InstructionBlock::new(instructions, new_ip);
let result = BlockEncoder::encode(64, block, BlockEncoderOptions::NONE)
.map_err(|e| InstallError::PrologueUnrelocatable(e.to_string()))?;
Ok(result.code_buffer)
}
There is a chicken-and-egg problem hiding here.
BlockEncoder’s output length can depend on
the destination address (a far RIP-relative target may need a longer
encoding), but the destination is a near-memory allocation that
itself needs a size up front. We solve it with two relocation passes.
Pass 1 relocates at a provisional address, entry
itself; BlockEncoder::encode only computes displacements
against a u64, it never reads or writes memory, so the
address need not be real, purely to measure the
relocated length. That estimate sizes the allocation. Pass 2
relocates again at the real trampoline address. The oversize case is
still a checked, enforced invariant
(InstallError::TrampolineOverflow), not an assumption:
Vec::with_capacity is only a hint. Here is the actual
shape of it inside Engine::install, both passes:
// Pass 1: relocate at a PROVISIONAL address -- `entry` itself -- only to
// measure the relocated length. BlockEncoder::encode computes displacements
// against a u64; it never reads or writes memory, so the address need not
// be real, allocatable, or even aligned.
let stub_len = build_detour_stub(0, 0).len();
let resume_len = build_abs_jump(0).len();
let provisional = relocate(&instructions, entry as u64)?;
let needed = stub_len + provisional.len() + resume_len;
// Size the near allocation from that estimate ...
let capacity = needed.div_ceil(PAGE_SIZE) * PAGE_SIZE;
let trampoline = sys::alloc::alloc_near(entry, needed)? as usize;
// ... then relocate AGAIN at the real trampoline address, now that it is
// known. If this fails after we already mapped the trampoline, free it on
// the way out -- a refusal must not leak a mapping.
let relocated = relocate(&instructions, (trampoline + stub_len) as u64)
.inspect_err(|_| { let _ = sys::alloc::free(trampoline_ptr, capacity); })?;
// Assemble [ detour stub | relocated prologue | resume jump ] contiguously.
// The estimate is a hint, not a contract: the Vec grows freely, so a pass-2
// encoding larger than pass 1's would write past the mapping. Enforce the
// invariant by name instead of trusting it:
if trampoline_body.len() > capacity {
let _ = sys::alloc::free(trampoline_ptr, capacity);
return Err(InstallError::TrampolineOverflow {
target: entry, needed: trampoline_body.len(), capacity,
});
}
The detour stub, byte by byte
The first thing the trampoline runs is a stub we hand-assemble.
There is no assembler in the dependency list, so the stub is written
as literal opcode bytes. Its contract is narrow and strict: save every
register the hooked function’s ABI could be carrying an argument
in, snapshot the first six integer/pointer argument registers into a
RawArgs struct, call the observer, restore the registers
untouched, and tail-jump into the relocated original. On the SysV
(Linux/Android) side that is this, in full:
#[cfg(all(unix, target_arch = "x86_64"))]
fn build_detour_stub(hit_fn: u64, relocated_addr: u64) -> Vec<u8> {
let mut b = Vec::with_capacity(96);
// push rax, rdi, rsi, rdx, rcx, r8, r9, r10, r11 (9 pushes = 72 bytes;
// entry rsp%16 == 8, so after these rsp%16 == 0 -- the alignment a `call`
// expects to see restored)
b.extend_from_slice(&[0x50, 0x57, 0x56, 0x52, 0x51]);
b.extend_from_slice(&[0x41,0x50, 0x41,0x51, 0x41,0x52, 0x41,0x53]);
// sub rsp, 48 -- reserve the RawArgs slot (48 is 16-aligned)
b.extend_from_slice(&[0x48, 0x83, 0xEC, 0x30]);
// store rdi, rsi, rdx, rcx, r8, r9 into [rsp+0 .. rsp+48). SysV classifies
// a 48-byte by-value struct as MEMORY: the 48 bytes ARE the argument, so
// no register ever carries a pointer to it -- which is exactly why the
// Rust-side `hit_fn: extern "C" fn(RawArgs)` can live in crates that
// forbid `unsafe`: no raw pointer crosses into them.
for (reg, off) in [(7u8,0u8),(6,8),(2,16),(1,24),(8,32),(9,40)] {
b.extend_from_slice(&mov_store_reg_to_rsp_offset(reg, off));
}
// mov r11, hit_fn ; call r11
b.push(0x49); b.push(0xBB); b.extend_from_slice(&hit_fn.to_le_bytes());
b.extend_from_slice(&[0x41, 0xFF, 0xD3]);
// add rsp, 48 ; pop (reverse order) ; mov r11, relocated_addr ; jmp r11
b.extend_from_slice(&[0x48, 0x83, 0xC4, 0x30]);
b.extend_from_slice(&[0x41,0x5B, 0x41,0x5A, 0x41,0x59, 0x41,0x58,
0x59, 0x5A, 0x5E, 0x5F, 0x58]);
b.push(0x49); b.push(0xBB); b.extend_from_slice(&relocated_addr.to_le_bytes());
b.extend_from_slice(&[0x41, 0xFF, 0xE3]);
b
}
The alignment arithmetic in the comments is load-bearing, not
decoration: a function reached through a call expects
rsp % 16 == 8 at entry, and the nine pushes plus a
16-aligned sub are chosen precisely so the observer is
called at rsp % 16 == 0, the state its own
call will then turn back into the 8 the ABI
promises. The Microsoft x64 ABI needs a different stub entirely (four
argument registers, a struct this size passed by reference,
32 bytes of mandatory shadow space); it lives behind the same name,
selected by #[cfg(windows)], and its byte encoding is
regression-tested on non-Windows hosts by comparison, because the
bytes are ordinary x86-64 even when the convention is not.
The resume jump that cost us a crash
relocate deliberately does not append the jump
back into the rest of the original function. That resume jump is the
caller’s job, because it is not a relocation concern and,
crucially, it is not the part written atomically. It is a
register-free jmp qword ptr [rip+0] followed by the
8-byte absolute target:
fn build_abs_jump(target: u64) -> Vec<u8> {
let mut b = Vec::with_capacity(14);
b.push(0xFF); b.push(0x25); // jmp qword ptr [rip+disp32]
b.extend_from_slice(&0u32.to_le_bytes()); // disp32 = 0 -> target is the next 8 bytes
b.extend_from_slice(&target.to_le_bytes()); // 8-byte absolute target, reachable anywhere
b
}
It used to be mov r11, imm64; jmp r11, which is shorter to
reason about and clobbers only r11, a caller-saved
scratch register, so who cares. We cared. The resume jump runs
immediately after the relocated prologue, and a very common
MSVC x64 prologue is the “alternate frame pointer” pattern
mov r11, rsp; mov [r11+N], reg (e.g.
KernelBase!RegSetValueExW), which parks a live value in
r11 that the rest of the function goes on to dereference.
Overwriting r11 in the resume jump corrupted that pointer
and crashed the target on its very next store through it. The
rip-relative indirect jump touches zero general-purpose
registers, so it cannot collide with whatever register the relocated
original happens to still be using, no matter which one the
compiler picked. The fix is unconditionally register-free rather than
register-free-only-where-the-bug-bit, so there is no asymmetry left
for a future caller of this helper to fall into.
The hit you would otherwise miss
One line in the install path has nothing to do with patching and everything to do with not lying to yourself. Right before stage 6 makes the hook live, we warm a cache:
// Warm sys::maps's /proc/self/maps cache BEFORE the atomic patch makes this
// hook live.
sys::maps::refresh();
It looks like an optimisation and it is not. The detour consults
/proc/self/maps through a cache, and populating that cache
for the first time calls open. If we let the cache
populate lazily, the first hit on the hook would trigger that
first open, and if the symbol we hooked
is open, that first hit would reenter the fresh
reentrancy guard and be dropped, unobserved, while every later hit
sailed through. The one call you most wanted to see (the first
one) is the exact one you would silently lose. Warming the cache
before the hook goes live spends that open while nothing
is hooked yet. It is the kind of bug that never shows up in a test that
hooks a nice quiet function, and always shows up the day you hook the
interesting one.
Stage 6, and the honesty anchor
The live patch at the hook site is a 5-byte jmp rel32.
We write it as one aligned 8-byte store: read the
8-byte-aligned word that contains the 5 patch bytes, splice the
opcode and rel32 into a copy, and store the whole word in
a single atomic operation. The rel32 displacement itself
is range-checked: if the trampoline landed too far for a 32-bit
relative jump, the install refuses (NoNearMemory) rather
than truncating the offset:
let rel32 = trampoline as i64 - (entry as i64 + 5); // jmp is relative to its END
let rel32 = i32::try_from(rel32)
.map_err(|_| InstallError::NoNearMemory { target: entry })?;
let mut new_word = original_word; // the 8-aligned word covering `entry`
new_word[offset] = decode::JMP_REL32_OPCODE; // 0xE9
new_word[offset + 1..offset + 5].copy_from_slice(&rel32.to_le_bytes());
sys::protect::mprotect(aligned_start, 8, PROT_READ_WRITE_EXEC)?;
sys::patch::atomic_store_word(aligned_start, new_word); // single aligned store
sys::protect::mprotect(aligned_start, 8, PROT_READ_EXEC)?;
sys::protect::assert_restored_to_rx(aligned_start)?; // never leave a page writable+executable
The single store means no other thread can observe a half-written jump, so there is no torn read. That is the extent of the guarantee, and the crate says so in its own words. In the engine’s own words:
… on x86-64 the 5-byte jmp rel32 patch is
written as one aligned 8-byte store, which prevents a torn read, but
does not protect a thread whose instruction pointer
is already inside the 5-byte window at the moment of the patch. That
is a real, un-eliminated risk this crate does not claim to solve;
mitigating it is a delivery-vehicle property (installing before the
target code runs), not an engine property.
This matters and it is easy to paper over, so we won’t. An
aligned store fixes readers: no thread ever decodes a
Frankenstein instruction built from old and new bytes. It does
nothing for a thread whose program counter is already partway
through the five bytes we’re overwriting when the store lands.
That thread finishes executing instructions that no longer exist in
the form it started decoding them. There is no in-engine fix for that
on a prologue patch; the honest mitigation is to install before the
target code is reachable, which is a property of how the agent is
delivered, not of this crate. When near-memory allocation or the
aligned store isn’t available, the engine refuses with a named
InstallError rather than falling back to a racy write.
The AArch64 side: a hand-written relocator and an x16/x17 veneer
iced-x86 is x86-only, so the AArch64 path shares none of
stage 3. It is a hand-written decoder/relocator in the same crate;
by design, both architectures belong here, not a second crate. The
patch itself is simpler than x86:
every A64 instruction is one 4-byte, 4-byte-aligned word, so the hook
site is overwritten with a single B imm26 as one atomic
32-bit store, race-free by construction, not merely
torn-read-safe, because any thread’s PC is either at that word
or past it.
The interesting part is range. A direct B/BL
reaches ±128 MB; a relocated branch (or the far jump of an
inverted conditional) whose target is outside immediate range needs an
escape. That’s a 16-byte veneer that clobbers only x16
and x17, the AArch64 PCS intra-procedure-call
scratch registers, safe to clobber at a call boundary:
LDR x16, #8 ; 0x58000050 load the 8 bytes at PC+8 (the target below)
BR x16 ; 0xD61F0200 (or BLR x16, 0xD63F0200, if relocating a BL)
.quad target ; 8 bytes, reachable anywhere in the 64-bit address space
const LDR_X16_PC_PLUS_8: u32 = 0x5800_0050;
const BR_X16: u32 = 0xD61F_0200;
const BLR_X16: u32 = 0xD63F_0200;
pub const VENEER_LEN: usize = 16;
pub fn build(target: u64, linking: bool) -> Vec {
let mut out = Vec::with_capacity(VENEER_LEN);
out.extend_from_slice(&LDR_X16_PC_PLUS_8.to_le_bytes());
out.extend_from_slice(&(if linking { BLR_X16 } else { BR_X16 }).to_le_bytes());
out.extend_from_slice(&target.to_le_bytes());
out
}
The 16-byte veneer is never written at the atomically-patched hook site, because a 16-byte write isn’t atomic. It only ever lives inside the trampoline, alongside the relocated prologue, which is written in full before any thread can reach it.
The mandatory caveat: the AArch64 decoder/relocator
and veneer are implemented and host-gated, but, in the
crate’s own words,
“not yet wired into crate::engine.” The
six-stage Engine
orchestration is x86-64 only today. So: the x86-64
path is the one wired end-to-end; ARM64 is implemented-but-not-
orchestrated. We are not claiming a full arm64 install path, and a
post that let you infer one would be overclaiming.
Where this sits
This engine is one layer of Rutken’s in-house instrumentation agent, an alternative to injecting a general-purpose runtime, which some targets detect and refuse to run under. Rutken is available here if you want to try it. And if you spot something wrong in the mechanics above, we’d genuinely like to hear it. The residual window in stage 6 is exactly the kind of thing we’d rather state plainly than have you find.