Table of contents
Open Table of contents
Overview
A few days ago in my local lab, a proprietary, third-party binary running a background daemon began behaving erratically. It pinned a single CPU core to 100%, silently processing something in a loop. Because the software is closed-source, lacked debugging symbols, and had zero internal telemetry built into its engine, I was operating in the dark.
I couldn’t restart the daemon to attach a heavy debugger or trace it from the start. Doing so would wipe out the exact memory and internal state that triggered this loop in the first place. I needed a way to inspect the process dynamically, execute a diagnostic routine directly inside its instruction pipeline, and release it back to normal execution completely unscathed.
This technique assumes a single-threaded target. In a multi-threaded production environment, modifying shared code segments (.text) is highly dangerous. Because all threads share the same address space, any thread that attempts to execute the same instruction path while the payload is active will crash or cause severe memory corruption. In those scenarios, you must iterate through /proc/[pid]/task/ and suspend every active thread before initiating the write.
Rather than installing heavy debugging tools on a lean host, I wanted to understand the raw mechanics of user-space hot-patching. I built a diagnostic injector that leverages the Linux kernel’s ptrace API. It halts the running target, backs up the CPU register state and the instructions at the active Instruction Pointer (RIP), injects a custom x86_64 assembly payload, traps the execution, and restores the target process to its exact original state.
Here is how the system is put together, how to navigate the kernel-level protections designed to stop us, and how we can use raw memory manipulation to debug the undebuggable.
The Core Strategy: Working Around ASLR and W^X
When modifying a running process in-memory on modern Linux, we immediately run into two major kernel-level defenses: Address Space Layout Randomization (ASLR) and Write XOR Execute (W^X).
-
The ASLR Problem: We cannot hardcode target memory locations for our payload. The target’s stack, heap, and library segments are randomized on every execution.
-
The W^X Barrier: Modern kernels enforce strict page permissions. We cannot execute code located in a writable segment (like the stack or heap), and we cannot write code directly to an executable segment (like the
.textsegment) under standard execution.
To bypass these constraints without parsing complex ELF binaries or calling mmap/mprotect syscalls inside the target, we can exploit a reliable architectural guarantee: the target’s current Instruction Pointer (RIP) must point to an executable page.
By attaching to the process at its exact execution state, we use the current RIP as our landing pad.
While the page tables prevent the target process itself from writing to its own executable .text segment, the Linux kernel makes an exception for debugging. When we call ptrace with PTRACE_POKETEXT, the kernel’s memory management subsystems bypass the page table’s read-only protection. By forcing a Copy-on-Write (COW) operation on the targeted page, the kernel writes our instructions directly to a private duplicate of that memory segment while preserving its execution rights.
Architectural State Machine
To guarantee the target process survives the injection without a segmentation fault, we must coordinate a strict, multi-step handshake between the tracer (our injector) and the tracee (the target).
- Attach and Halt: Pause the target’s current execution thread.
- Capture State: Capture all x86_64 CPU registers and read the existing instruction bytes at
RIP. - Inject: Overwrite those bytes with our custom assembly payload. The payload must terminate with a software breakpoint (
int 3/ opcode0xCC). - Execute: Resume target execution. The CPU processes our payload and immediately traps back to the kernel when it hits the breakpoint.
- Restore and Detach: Catch the trap, restore the original instructions and register states (resetting
RIPback to its original location), and detach.
Implementing the Code
To demonstrate this in our lab, we require two components: a dummy target that simulates a continuous, CPU-bound busy loop, and the injector utility itself.
1. The Target Daemon
This dummy process runs a tight user-space loop. We intentionally avoid blocking system calls like sleep() or nanosleep() in the active pathway, ensuring the processor is interrupted during pure user-space calculation.
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
int main() {
pid_t pid = getpid();
printf("[Target] Running with PID: %d\n", pid);
printf("[Target] Watch stdout to verify execution survival.\n");
volatile uint64_t counter = 0;
while (1) {
counter++;
if (counter % 500000000 == 0) {
printf("[Target] Active and processing... (Iteration %lu)\n", counter / 500000000);
}
}
return 0;
}target.c
2. The Injection Engine
The injector maps the architecture-specific registers using struct user_regs_struct. Our diagnostic payload is written in raw x86_64 assembly. It executes a sys_write (syscall 1) to standard output (file descriptor 1) and halts with an int 3 instruction.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>
// Assembly Payload (x86_64)
// Executes sys_write(1, " [INJECTED] \n", 13) and traps with 'int3' (0xCC)
unsigned char payload[] = {
0x48, 0x8d, 0x35, 0x12, 0x00, 0x00, 0x00, // lea rsi, [rip+18] (point to string at end)
0xb8, 0x01, 0x00, 0x00, 0x00, // mov eax, 1 (sys_write syscall number)
0xbf, 0x01, 0x00, 0x00, 0x00, // mov edi, 1 (stdout fd)
0xba, 0x0d, 0x00, 0x00, 0x00, // mov edx, 13 (string length)
0x0f, 0x05, // syscall (invoke kernel)
0xcc, // int3 (trap execution back to tracer)
0x20, 0x5b, 0x49, 0x4e, 0x4a, 0x45, 0x43, // String data: " [INJECTED] \n"
0x54, 0x45, 0x44, 0x5d, 0x20, 0x0a
};
#define PAYLOAD_SIZE sizeof(payload)
#define WORD_SIZE sizeof(long)
// Helper function to read process memory in word-sized chunks
void peek_memory(pid_t pid, unsigned long addr, unsigned char *dst, size_t len) {
size_t i = 0;
long word;
while (i < len) {
word = ptrace(PTRACE_PEEKTEXT, pid, addr + i, NULL);
if (word == -1) {
perror("[!] PTRACE_PEEKTEXT failed");
exit(1);
}
memcpy(dst + i, &word, (len - i < WORD_SIZE) ? len - i : WORD_SIZE);
i += WORD_SIZE;
}
}
// Helper function to write process memory, preserving word boundaries
void poke_memory(pid_t pid, unsigned long addr, unsigned char *src, size_t len) {
size_t i = 0;
long word;
while (i < len) {
if (len - i < WORD_SIZE) {
word = ptrace(PTRACE_PEEKTEXT, pid, addr + i, NULL);
memcpy(&word, src + i, len - i);
} else {
memcpy(&word, src + i, WORD_SIZE);
}
if (ptrace(PTRACE_POKETEXT, pid, addr + i, word) == -1) {
perror("[!] PTRACE_POKETEXT failed");
exit(1);
}
i += WORD_SIZE;
}
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <target_pid>\n", argv[0]);
exit(1);
}
pid_t pid = atoi(argv[1]);
struct user_regs_struct regs, orig_regs;
int status;
printf("[*] Attaching to process %d...\n", pid);
if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) == -1) {
perror("[!] Attachment failed");
exit(1);
}
// Wait for the target to stop execution
waitpid(pid, &status, 0);
if (WIFSTOPPED(status)) {
printf("[*] Process stopped by signal %d\n", WSTOPSIG(status));
}
// Capture the target's execution registers
if (ptrace(PTRACE_GETREGS, pid, NULL, ®s) == -1) {
perror("[!] Failed to get registers");
exit(1);
}
memcpy(&orig_regs, ®s, sizeof(struct user_regs_struct));
printf("[*] Original RIP captured at: 0x%llx\n", regs.rip);
// Read and backup the instructions currently executing at RIP
unsigned char backup[PAYLOAD_SIZE];
peek_memory(pid, regs.rip, backup, PAYLOAD_SIZE);
printf("[*] Backed up %lu bytes of original instructions.\n", PAYLOAD_SIZE);
// Inject our payload directly over the active RIP execution vector
poke_memory(pid, regs.rip, payload, PAYLOAD_SIZE);
printf("[*] Diagnostic payload written to memory.\n");
// Let the target run the injected code
printf("[*] Resuming target execution...\n");
if (ptrace(PTRACE_CONT, pid, NULL, NULL) == -1) {
perror("[!] Resume execution failed");
exit(1);
}
// Wait for the software breakpoint (SIGTRAP)
waitpid(pid, &status, 0);
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) {
printf("[*] Trapped payload execution successfully.\n");
} else {
fprintf(stderr, "[!] Execution lost or target crashed.\n");
exit(1);
}
// Restore the original instruction bytes back to the target's memory space
poke_memory(pid, orig_regs.rip, backup, PAYLOAD_SIZE);
printf("[*] Original instruction memory restored.\n");
// Reset target registers back to the original values, pointing RIP back to the start point
if (ptrace(PTRACE_SETREGS, pid, NULL, &orig_regs) == -1) {
perror("[!] Register restoration failed");
exit(1);
}
printf("[*] CPU registers restored (RIP reset to 0x%llx).\n", orig_regs.rip);
// Detach and release the process
if (ptrace(PTRACE_DETACH, pid, NULL, NULL) == -1) {
perror("[!] Detachment failed");
exit(1);
}
printf("[+] Detached successfully. Target process resumed normal runtime operation.\n");
return 0;
}injector.c
Technical Hurdles and Kernel Bypass Logic
While coding this injection utility, I had to solve two subtle engineering roadblocks to avoid crash scenarios.
1. The Yama ptrace_scope Security Boundary
Most modern Linux systems ship with the Yama Security Module enabled. By checking /proc/sys/kernel/yama/ptrace_scope, you will likely find a default value of 1. This constraint prevents any process from attaching to another unless the tracer is the direct parent of the tracee.
To execute this dynamic diagnostic pipeline on a third-party daemon, we run our injector with root privileges. The kernel allows processes holding the CAP_SYS_PTRACE capability to bypass the Yama scope check entirely.
2. Word-Aligned Memory Access
PTRACE_PEEKTEXT and PTRACE_POKETEXT do not operate on raw byte arrays; they operate on system word boundaries (8 bytes on x86_64 systems). If your payload size is not a perfect multiple of 8, a naive write operation can corrupt the boundary bytes of adjacent, valid code.
In poke_memory, I implemented a read-modify-write cycle
if (len - i < WORD_SIZE) {
word = ptrace(PTRACE_PEEKTEXT, pid, addr + i, NULL);
memcpy(&word, src + i, len - i);
}
This logic reads the final 8-byte boundary, updates only the specific bytes containing our trailing payload, and writes the entire word back. This leaves the adjacent instructions undamaged.
Running the Verification in the Lab
Let’s compile and run these components inside an Linux server environment to observe the injection mechanics live.
First, compile both source files:
gcc target.c -o target
gcc injector.c -o injector
Launch the target daemon inside Terminal A. It starts processing its loop, logging its process ID and loop count:
./target
[Target] Running with PID: 2184
[Target] Watch stdout to verify execution survival.
[Target] Active and processing... (Iteration 1)
[Target] Active and processing... (Iteration 2)
[Target] Active and processing... (Iteration 3)
[Target] Active and processing... (Iteration 4)
In Terminal B, run our injector against target PID 2184 using root privileges:
sudo ./injector 2184
[*] Attaching to process 2184...
[*] Process stopped by signal 19
[*] Original RIP: 0x56847ff981f1
[*] Backed up 38 bytes from target RIP.
[*] Payload written directly over current instruction vector.
[*] Executing injection payload...
[*] Trapped execution breakpoint successfully.
[*] Target code memory restored back to original binary state.
[*] Target execution registers restored (RIP reset to 0x56847ff981f1).
[+] Detached. Target process resumed normal runtime execution.
Now, check Terminal A where our target daemon is running. Notice the execution flow:
[Target] Active and processing... (Iteration 31)
[Target] Active and processing... (Iteration 32)
[INJECTED]
[Target] Active and processing... (Iteration 33)
[Target] Active and processing... (Iteration 34)
The target printed our payload message [INJECTED] straight to its standard output. Its execution state did not drop, its process did not restart, and the loop continued seamlessly.
Closing the Loop
By understanding how to intercept execution at the instruction level, I resolved the issue with the spinning background daemon.
Using this exact injector design, I swapped the simple string-printing payload for a diagnostic assembly sequence that dumped the contents of the local registers (RAX, RCX, RSI, RDI) directly to standard error. By matching those register values to the disassembler map of the binary, I identified the exact loop instruction where the program was trapped.
This mechanical trick proves that with direct access to the ptrace system interface, you can bypass complex layout protections and force closed-source, black-box processes to surrender their internal state without dropping a single active transaction.