Static binary rewriter for arbitrary function hooking across ELF, Mach-O, and PE.
Truant rewrites compiled binaries directly — no source code, no recompilation, no dynamic instrumentation frameworks. Point it at a binary, tell it where to hook, and it produces a new binary with your hooks baked in.
- Hooks functions in compiled binaries (pre-call, post-call, replace, return)
- Supports three binary formats: ELF (Linux), Mach-O (macOS), PE/COFF (Windows) — both 64-bit and 32-bit PE
- No source required: works on stripped binaries, system libraries, third-party software
- Conditional hooks: fire only when a register matches a predicate (e.g.
rdi >= 65536) - Hook chaining: multiple hooks on the same function, called in declaration order
- Runtime toggle: each hook has a 1-byte enable/disable flag in the data segment
- Coverage instrumentation: AFL-compatible shared memory bitmaps, forkserver, persistent mode (for fuzzing)
- Heap sanitiser: guard-page allocator catches overflow, use-after-free, double-free via SIGSEGV
- Sidecar memory sanitiser: ring-buffer logging of allocator and memory operations
cargo install --path .Or build from source:
cargo build --releaseRequires Rust edition 2024 (stable toolchain 1.85+).
| Feature | Default | Description |
|---|---|---|
aarch64 |
Yes | ARM64 support (ELF + Mach-O). Adds capstone dependency. |
coverage |
Yes | AFL-compatible coverage, forkserver, persistent mode, heap/sidecar sanitisers. |
Build without coverage (hooks-only, smaller binary):
cargo build --release --no-default-featurestruant /bin/ls --infotruant /bin/ls -o ls_instrumented
./ls_instrumented /tmp # runs normally, coverage data written to SHM if AFL is driving itCreate a hook specification (hooks.toml):
[[hook]]
target = "malloc"
mode = "pre"
shellcode = [0xC3] # handler does nothing (returns immediately)Apply it:
truant /usr/bin/target -o target_hooked --hooks hooks.toml --no-coverage
./target_hooked# Find the address
nm /usr/bin/target | grep ' T interesting_function'
# 0000000000401234 T interesting_function
cat > hooks.toml << 'EOF'
[[hook]]
target = "0x401234"
mode = "replace"
shellcode = [0x48, 0xC7, 0xC0, 0x2A, 0x00, 0x00, 0x00, 0xC3] # mov rax, 42; ret
EOF
truant /usr/bin/target -o target_hooked --hooks hooks.toml --no-coverage[hooks]
library = "./my_hooks.so"
[[hook]]
target = "process_input"
mode = "pre"
handler = "my_pre_handler"
[[hook]]
target = "process_input"
mode = "post"
handler = "my_post_handler"Your handler receives a pointer to the saved register context:
// my_hooks.c — compile with: gcc -shared -fPIC -o my_hooks.so my_hooks.c
#include <stdio.h>
#include <stdint.h>
// x86_64 RegContext (144 bytes):
// +0 rax, +8 rbx, +16 rcx, +24 rdx, +32 rsi, +40 rdi,
// +48 rbp, +56 rsp, +64 r8..+120 r15, +128 rip, +136 rflags
//
// AArch64 RegContext (272 bytes):
// +0 x0, +8 x1, ... +232 x29/fp, +240 x30/lr, +248 sp, +256 pc, +264 nzcv
void my_pre_handler(uint64_t *regs) {
// x86_64: rdi = regs[5], rsi = regs[4]
// AArch64: x0 = regs[0], x1 = regs[1]
printf("process_input called with arg0=%lx arg1=%lx\n", regs[5], regs[4]);
}
void my_post_handler(uint64_t *regs) {
// Return value: rax = regs[0] (x86_64), x0 = regs[0] (AArch64)
printf("process_input returned %lx\n", regs[0]);
}| Mode | Description |
|---|---|
pre |
Call handler before the original function. Handler receives &RegContext. |
post |
Call handler after the displaced instructions execute. |
replace |
Replace the function entirely. Handler receives (&RegContext, original_func_ptr). |
return |
Call handler when the function returns (intercepts RET). |
# Optional: shared library containing handler functions
[hooks]
library = "./my_hooks.so"
# Hook definitions
[[hook]]
target = "function_name" # Symbol name or "0x..." hex VA
mode = "pre" # pre, post, replace, or return
handler = "handler_func" # Function name in the hook library
# OR
shellcode = [0x90, 0xC3] # Raw bytes (mutually exclusive with handler)
# Optional fields:
enabled = true # Initial toggle state (default: true)
condition = { register = "rdi", op = "gte", value = 65536 }| Operator | Description |
|---|---|
eq, ne |
Equal, not equal |
gt, gte, lt, lte |
Unsigned comparison |
bit_set |
(reg & value) == value |
bit_clear |
(reg & value) == 0 |
Multiple hooks on the same target are called in declaration order within a single save/restore frame:
[[hook]]
target = "0x401000"
mode = "pre"
shellcode = [0xC3] # called first
[[hook]]
target = "0x401000"
mode = "pre"
shellcode = [0xC3] # called secondMixed pre + post chaining is supported. Replace mode is exclusive (cannot be mixed with pre/post).
Truant can add AFL-compatible coverage instrumentation for fuzzing:
# Coverage rewrite (includes forkserver by default)
truant target_binary -o target_instrumented
# Persistent mode (faster — reuses process across inputs)
truant target_binary -o target_instrumented \
--persistent-addr 0x401234 \
--persistent-count 10000
# Deferred forkserver (fork after init, inherits loaded libraries)
truant target_binary -o target_instrumented \
--persistent-addr 0x401234
# (deferred by default when persistent-addr is set)Catches heap buffer overflow, use-after-free, and double-free:
# ELF: generates a companion .so loaded via LD_PRELOAD
truant target -o target_san --heap-san
LD_PRELOAD=./target_san.heap_san.so ./target_san
# PE: companion DLL injected via IAT (loaded automatically)
truant target.exe -o target_san.exe --heap-san
# target_san.exe.heap_san.dll is generated alongside| Feature | ELF (Linux) | Mach-O (macOS) | PE64 (Windows) | PE32 (Windows) |
|---|---|---|---|---|
| Pre/post/replace hooks | x | x | x | x |
| Return hooks | x | x | x | x |
| Conditional hooks | x | x | x | x |
| Hook chaining | x | x | x | x |
| Runtime toggle | x | x | x | x |
| Library handler hooks | x | x | x | x |
| Coverage instrumentation | x | x | x | x |
| Persistent mode | x | x | x | x |
| Heap sanitiser | x | x | x | x |
| Sidecar sanitiser | x | x | x | x |
| Forkserver | x | x | - | - |
| AArch64 | x | x | - | - |
- Dynamically-linked PIE
- Dynamically-linked non-PIE
- Statically-linked (glibc)
- Statically-linked (musl)
- Shared objects (.so)
- Automatically strips non-essential load commands (LC_UUID, LC_BUILD_VERSION, etc.) when header space is insufficient for the new segment, enabling instrumentation of tight system binaries
- ARM64 displaced instructions are properly relocated (ADRP, ADR, LDR literal, B, BL, B.cond, CBZ/CBNZ, TBZ/TBNZ)
- Universal (fat) binaries: each slice is instrumented independently and re-packaged
src/
lib.rs Orchestration: detect format, dispatch to patchers
main.rs CLI (clap)
detect.rs Binary format and architecture detection
patcher.rs Shared PatchResult / InstrumentationOptions types
disasm.rs ELF basic block detection, shared FNV block ID hash
hooks.rs TOML config parsing, symbol resolution (all formats)
hook_trampoline.rs Hook trampoline codegen (x86_64 / PE32 / AArch64 dispatch)
trampoline.rs Coverage trampoline + init code (ELF x86_64)
elf.rs ELF parsing (sections, symbols, PLT, allocators)
elf_impl/
mod.rs Module root
patcher.rs ELF patching (PT_NOTE segment, block patching, heap san)
context.rs ElfBinaryContext wrapper
macho.rs Mach-O parsing (segments, symbols, stubs, LC stripping)
macho_impl/
mod.rs Module root
patcher.rs Mach-O patching (__TR_COV/__TR_DAT, LINKEDIT, LC space reclamation)
context.rs MachOBinaryContext wrapper
macho_disasm.rs Mach-O basic block detection
macho_trampoline.rs Mach-O init code + persistent mode (x86_64 + AArch64)
fat.rs Universal (fat) binary support
pe.rs PE/COFF parsing (sections, imports, exports, IAT, import injection)
pe_impl/
mod.rs Module root
patcher.rs PE patching (.trcov section, PE32/PE64 init, persistent)
code_builder.rs CodeBuilder with label/fixup system for PE init codegen
context.rs PeBinaryContext wrapper
pe_disasm.rs PE basic block detection (32-bit + 64-bit)
error.rs Typed error taxonomy (TruantError enum)
binary_patch.rs Overlap-checked binary patching (PatchSet)
preload.rs Heap sanitiser (LD_PRELOAD / DYLD_INSERT / companion DLL)
sidecar_preload.rs Sidecar memory sanitiser (SHM ring buffer)
hook_preload.rs Companion library for library-based hooks
arch/
mod.rs Module root
x86_64/
mod.rs Module root
disasm.rs x86_64 basic block detection helpers
trampoline_gen.rs x86_64 trampoline generation dispatch
aarch64/
mod.rs Module root
asm.rs Shared ARM64 instruction encoding (60 encoders)
disasm.rs AArch64 basic block detection
trampoline_gen.rs Coverage trampoline + persistent mode (AArch64)
hook_trampoline.rs Hook trampoline codegen (AArch64)
relocation.rs PC-relative instruction relocation (ADRP, ADR, B, etc.)
traits/
mod.rs Module root
patcher.rs Patcher trait
binary_context.rs BinaryContext trait
trampoline_gen.rs TrampolineGenerator trait
disassembler.rs Disassembler trait
cargo build # debug build
cargo test -- --test-threads=1 # 431+ tests across 6 test suites
cargo build --release # optimised build| Suite | Tests | What it covers |
|---|---|---|
Unit tests (--lib) |
308 | Trampoline codegen, hook resolution, parsing, PC-relative relocation, format detection, PE import injection, binary patching |
feature_parity |
47 | Code generator parity across x86_64/AArch64, ELF/Mach-O |
hook_e2e |
20 | Live hook verification: pre/post/replace/return/conditional/toggle/chained/library |
hook_unit |
13 | Hook modes, coverage exclusion, symbol resolution |
corpus_e2e |
11 | 24 binary shapes x 7 hook types (~170 combos), multiple optimisation levels |
pe_e2e |
32 | PE32/PE64 structural validation, headers, entry points, hooks, heap/sidecar sanitiser, auto-strip, DLL .reloc |
Tests run on 5 CI platform/architecture combinations (plus local macOS Intel verification):
| Platform | Architecture | Runner |
|---|---|---|
| Linux | x86_64 | ubuntu-latest |
| Linux | AArch64 | ubuntu-24.04-arm |
| macOS | ARM64 (M-series) | macos-latest |
| macOS | x86_64 (Intel) | tested locally |
| Windows | x86_64 | windows-latest |
Hook E2E and corpus tests are arch-portable (x86_64 + AArch64 shellcode). macOS CI also instruments a corpus of 20 system binaries (/usr/bin/true through /usr/bin/top).
PE integration tests require MinGW cross-compilers:
sudo apt install gcc-mingw-w64-x86-64 gcc-mingw-w64-i686Tests skip gracefully if cross-compilers are not available.
Probably never. I wrote this as part of another project and thought it might be useful on its own, so I ripped it out into a standalone library. There are almost certainly better tools out there that can do this already — Frida, DynamoRIO, Pin, probably others. Those are mature, well-tested, and maintained by people who actually understand this stuff. For me this was a learning exercise to implement what I thought would be a nice addition to something I was working on, being ignorant enough in the areas in which I was operating to not realise I was reinventing the wheel.
That said, truant does have one thing going for it: it's static. No runtime overhead, no agent process, no ptrace. The hooks are in the binary. The binary runs normally. If that matters to you for some reason, maybe give it a go.