hookso uses ptrace to take over another process and, in that process's address space, run syscalls, load or unload .so files, look up symbols, and replace functions. It targets x86-64 Linux. The implementation lives in a single main.cpp so the control flow is easy to follow.
- Run a syscall in the target, or call a function in an already-loaded
.so dlopen/dlcloseto attach or unload a library- Find a function address and read arguments of the next call
- Replace an old function (or an arbitrary address) with a function from a new
.so, and restore it later - When a target function is about to run, fire a syscall / call / dlopen / …
hookso does not start a helper thread inside the target. It stops the process, writes a few instructions into memory that is already executable, points RIP at that stub, lets the target run the work itself, then restores the original bytes and registers.
flowchart TB
A[PTRACE_ATTACH] --> B[Pick an RX trampoline<br/>prefer vdso+8]
B --> C[mmap a call stack]
C --> D{What next?}
D -->|syscall / call / dlopen| E[Write stub, set regs, CONT]
D -->|find / replace| F[Parse maps and ELF]
E --> G[On SIGTRAP, restore]
F --> H[Patch GOT or write jmp]
G --> I[PTRACE_DETACH]
H --> I
PTRACE_ATTACH then waitpid leaves the target stopped at an instruction boundary. Memory and register access happen in that state. If setup fails after attach, hookso DETACHs so the target is not left in SIGSTOP.
To make the target execute mmap, dlopen, or an arbitrary function, hookso needs executable memory in that process for a short stub.
| Purpose | Bytes | Meaning |
|---|---|---|
| Remote syscall | 0f 05 cc |
syscall; int3 |
| Remote call | ff d0 cc |
callq *%rax; int3 |
int3 returns control to hookso (SIGTRAP). The original 8 bytes and registers are then restored.
Trampoline location, in order:
[vdso] + 8: vdso is a small ELF the kernel maps, almost alwaysr-xp. Offset 8 ise_ident[8..15], normally padding.- An executable libc mapping that includes the ELF header, plus 8 (older distros often map the whole first segment
r-xp). - Start of libc
.text(overwrites real instructions; last resort).
e_ident:
+0 7f E L F
+4 class / data / version / osabi
+8 padding ← stub goes here
libc_base + 8 is not always valid. Current glibc maps the ELF header as r--p. Executing there SIGSEGVs, which is why vdso is preferred.
Remote syscalls use the Linux convention: rax = number, rdi rsi rdx r10 r8 r9 = args. Remote calls use SysV: rdi rsi rdx rcx r8 r9, plus an mmap'd stack with 16-byte-aligned rsp. String arguments are copied into a page allocated in the target, then passed as a pointer.
Tried in order:
process_vm_readv/process_vm_writevpread/pwriteon/proc/<pid>/memPTRACE_PEEKTEXT/POKETEXT
Ptrace poke can write RX pages, so the vdso / .text stub can be installed. Short reads or writes are treated as failure.
flowchart LR
M["/proc/pid/maps<br/>load base"] --> E[ELF]
E --> S[.dynsym / .dynstr]
S --> T{Defined here?}
T -->|yes, in .text| A[base + st_value]
T -->|imported| G[.rela.plt / .rela.dyn<br/>GOT slot]
- A basename such as
libtest.sois parsed from target memory. If section headers are not mapped, that fails with I/O error. - A filesystem path parses the file, then adds the load base from maps. Use this for large libraries such as
libstdc++. - libc may appear as
libc-2.17.soorlibc.so.6. Injection tries__libc_dlopen_modefirst, then publicdlopen(the private symbol is gone in glibc 2.34+).
Internal vs imported symbols later pick different patch strategies.
hookso dlopens the new .so, then patches the old site:
flowchart TB
F[Old function] --> P{Kind}
P -->|imported, GOT| G[Point GOT at the new function]
P -->|local .text| D{Within ±2GB?}
D -->|yes| J["jmp rel32<br/>e9 xx xx xx xx"]
D -->|no| FAR["Store pointer on a low page<br/>jmpq *disp32(%rip)"]
- PLT/GOT: only that
.so's imports change.putsinsidelibtest.sobecomesputsnew; other modules keep the originalputs. - Near jump:
jmp rel32is a signed 32-bit displacement (±2GB), not 4GB. - Far jump: a non-PIE binary at
0x40...cannotrel32to a.soat0x7f.... hookso allocates a page in the low 32-bit range, stores the new pointer, and writesff 25 disp32(jmpq *offset(%rip)).
setfunc / setfuncp write the saved 8 bytes or GOT value back.
An int3 is written at the entry, then CONT waits for the next hit:
- RIP is decremented by 1 and the original bytes are restored
- Arguments are read as
rdi, rsi, rdx, rcx, r8, r9(the 4th isrcx, not syscallr10) triggercan pass them on with@1meaning “first argument of the call just caught”
If the target is executing the bytes being patched, the patch is refused so those instructions are not torn.
./build.sh
cd test && ./build.sh && ./test &
PID=$!
../hookso find $PID ./libtest.so libtest
../hookso syscall $PID 1 i=1 s="haha" i=4Full command list and walkthrough: Usage.
./hookso syscall <pid> <nr> i=1 s="str"
./hookso call <pid> so func i=1
./hookso dlopen <pid> ./new.so
./hookso replace <pid> old.so old new.so new
./hookso arg <pid> so func 1Tests: bash test/run_tests.sh (needs ptrace; CI sets yama.ptrace_scope=0).
- x86-64 only; syscall / call / dlcall take at most 6 integer or string arguments
replacerequires matching signatures or the target will crash- Only the given pid's thread is attached; other threads can still race a patch
- If a
.sois not fully mapped, pass a file path instead of the soname