A ptrace-based shared object (.so) injector for Linux x86-64 — the Linux
analogue of Windows DLL injection. It forces a running target process to
dlopen() an arbitrary library, which is the foundation for things like
injecting an ImGui overlay into another process.
specter <PID> <LIBRARY.so> [--global] [--timeout SECONDS] [--retries N] [-v|-vv|-vvv]
- Resolve the loader.
dlopen(or__libc_dlopen_mode) is resolved inside the target by reading/proc/<pid>/maps, locating the exact libc image the target uses, and parsing that ELF's dynamic symbol table. We read the target's own libc, so libc version/ABI differences never matter. - Freeze the whole thread group, at a safe point. Every thread of the
target is stopped with
PTRACE_SEIZE+PTRACE_INTERRUPT(re-scanning/proc/<pid>/taskuntil no new thread appears), so no sibling thread runs while we operate. We only freeze when no thread is executing inside the dynamic loader — otherwise a thread might hold the loader lock our forgeddlopenneeds, so we release and retry until we land a clean instant. The thread-group leader's registers are snapshotted, and a RAII guard guarantees they are restored and every thread is detached even if a later step fails. - Stage the path. The
.sopath is written onto the target's stack, safely below the ABI red zone. - Call, with a watchdog. A System V AMD64 call to
dlopen(path, flags)is forged by setting up registers and aNULLreturn address, so the call returns into an unmapped page and traps deterministically;RAXthen holds the handle. The call is bounded by--timeout: if it overruns (a blocking payload constructor, or loader-lock contention), the target is interrupted and rewound rather than left frozen. - Restore & detach. The leader's original registers are restored and every thread is detached, resuming the whole process cleanly.
If a dlopen times out before it could map the library, that indicates
loader-lock contention with a target thread rather than a payload problem, so the
whole injection is retried up to --retries times (releasing the target between
tries lets the lock holder finish). A timeout after the library is mapped means
the payload's constructor is blocking, which is reported immediately.
The crate is #![forbid(unsafe_code)]: every privileged operation goes through a
safe wrapper (nix for ptrace, std file I/O for /proc/<pid>/mem, object
for ELF parsing).
cargo build --release
# binary at ./target/release/specterptrace is restricted by the Yama LSM. To attach to a process that is not a
child of the injector, either run as root or relax the policy:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scopeThen:
# Find a target
pidof firefox
# Inject
./target/release/specter 12345 /absolute/path/to/payload.so -v| Flag | Default | Meaning |
|---|---|---|
--global |
off | dlopen with RTLD_GLOBAL (payload symbols globally visible). |
--timeout SECONDS |
10 |
Watchdog per remote call. 0 waits forever. On overrun the target is rewound, never frozen. |
--retries N |
3 |
Retries on loader-lock contention (a dlopen that stalls before mapping the library). A blocking constructor is not retried. |
-v/-vv/-vvv |
warn | Log level (info / debug / trace); logs go to stderr. |
A demo payload and several victim processes are provided under examples/:
# 1. Build the demo payload (writes a marker file from inside the target)
cc -shared -fPIC -o examples/payload.so examples/payload.c
# 2. Build and start a victim, note its PID
cc -O2 -o examples/victim examples/victim.c
./examples/victim &
VICTIM=$!
# 3. Inject
cargo run --release -- "$VICTIM" "$PWD/examples/payload.so" -vv
# 4. Observe the side effect produced from inside the victim
cat /tmp/specter_demo.txt # -> "injected into pid <VICTIM>"Other victims demonstrate the hardening: victim_mt.c (multi-threaded freeze),
victim_dlsym.c (loader-lock contention → retries), and payload_blocking.c
(a misbehaved constructor that trips the --timeout watchdog without freezing
the target).
dlopen runs the library's constructors synchronously, so put your entry
logic in a constructor and return quickly — the injector's remote call does
not complete until the constructor returns. For anything long-lived (a render
hook, an overlay event loop), spawn a thread from the constructor:
#include <pthread.h>
static void *worker(void *arg) { /* hook the render loop, draw ImGui... */ return NULL; }
__attribute__((constructor))
static void on_load(void) {
pthread_t t;
pthread_create(&t, NULL, worker, NULL); // return immediately
}- x86-64 only. The remote-call ABI is System V AMD64. aarch64 would need its own register/ABI handling.
- glibc-oriented. Symbol resolution targets glibc/libdl naming. musl works
if it exports
dlopenin a mapped libc (it does), but is less tested. - Loader-lock contention is mitigated, not eliminated. The safe-injection
point check avoids freezing while a thread's instruction pointer is inside the
dynamic loader, but a thread can hold the loader lock while executing in libc
(e.g. the lock's own mutex code). That residual case is handled reactively:
the
dlopentimes out, the target is released and the injection retried. The malloc-arena lock is a similar rarer hazard, also caught by the watchdog. - A timed-out call leaves partial side effects. Rewinding restores the
hijacked thread's registers, but memory already written by an aborted
dlopen(e.g. a partially-mapped library) is not unwound. The target keeps running; the injection simply fails.
MIT OR Apache-2.0