Step-by-Step Linux Process Injection Guide Series:

  1. Step-by-Step Linux Process Injection Guide Part 1 - Overview
  2. Step-by-Step Linux Process Injection Guide Part 2 - Attaching and Defeating ASLR
  3. Step-by-Step Linux Process Injection Guide Part 3 - Shellcode and Remote Function Calls

1. Introduction

In Part 2 we attached to the target process and resolved the addresses of malloc(), free(), and dlopen() inside the target despite ASLR. We know where those functions live, but we have not called them yet.

This part covers the mechanism that makes remote function calls possible: a small shellcode trampoline. We will walk through how linworm finds an executable memory region in the target, backs up the original bytes and registers, writes the trampoline into place, and then uses it to invoke any function by manipulating registers and resuming execution with ptrace().

2. The Trampoline Shellcode

The trampoline is a tiny, static byte array defined at compile time in src/arch/arch.h. Its job is simple: call whatever function address the injector loaded into a designated register, then trap back so the injector can inspect the result.

On x86_64, the primary trampoline is seven bytes:

static const uint8_t ARCH_INJECT_CODE[] = {
    0x48, 0x83, 0xe4, 0xf0, 0xff, 0xd0, 0xcc,   /* and rsp,-16; call rax; int3    */
    0xf0, 0x48, 0x0f, 0xb1, 0x37, 0xcc          /* lock cmpxchg [rdi],rsi; int3   */
};
x86_64 trampoline shellcode from src/arch/arch.h

The first seven bytes are the call trampoline. The remaining bytes are a secondary atomic-patching trampoline (lock cmpxchg) that is not used during the injection flow, so we will focus on the first half.

Breaking it down instruction by instruction:

Bytes Instruction Purpose
48 83 E4 F0 and rsp, -16 Align the stack pointer to a 16-byte boundary. The x86-64 System V ABI requires 16-byte stack alignment before a call instruction. Without this, libc functions that use SSE instructions will segfault.
FF D0 call rax Indirect call through rax. The injector loads the target function address into rax before resuming the target, so this calls whatever function we want.
CC int3 Software breakpoint. This raises SIGTRAP, which stops the target and returns control to the injector via waitpid(). At this point the injector can read the return value from rax.

The design is deliberately minimal. Rather than compiling a C function into position-independent shellcode (as linux-inject does), linworm uses a static trampoline and orchestrates each function call individually from the injector side. This makes the shellcode trivial to audit and architecture-portable.

2.1 Other Architectures

On AARCH64, the trampoline is even simpler – no stack alignment is needed because blr does not push a return address onto the stack:

static const uint8_t ARCH_INJECT_CODE[] = {
    0x00, 0x02, 0x3f, 0xd6,        /* blr x16 */
    0x00, 0x00, 0x20, 0xd4         /* brk #0  */
};
AARCH64 trampoline: branch-with-link to x16, then breakpoint

On ARM (32-bit):

static const uint8_t ARCH_INJECT_CODE[] = {
    0x3c, 0xff, 0x2f, 0xe1,        /* blx r12 */
    0x70, 0x00, 0x20, 0xe1         /* bkpt #0 */
};
ARM trampoline: branch-with-link-exchange to r12, then breakpoint

Each architecture uses a different register to hold the function pointer:

Architecture Function Pointer Register Return Value Register
x86_64 rax rax
AARCH64 x16 (IP0 scratch) x0
ARM r12 (IP scratch) r0

The rest of this article focuses on x86_64, but the flow is identical on other architectures – only the register names and shellcode bytes differ.

3. Finding an Injection Site

The trampoline needs to live in executable memory inside the target. linworm finds a suitable address by scanning /proc/pid/maps for the first mapping with execute permission.

The function get_target_exec_addr() in src/helpers/common.c handles this:

long get_target_exec_addr(pid_t target_pid) {
    ...
    sprintf(filename, "/proc/%d/maps", target_pid);
    fp = fopen(filename, "r");
    ...
}
get_target_exec_addr(): Opening The Target’s Memory Map

It then iterates each line, parsing the start address and the four-character permissions field:

long get_target_exec_addr(pid_t target_pid) {
    ...
    while (fgets(line, 850, fp) != NULL) {
        sscanf(line, "%lx-%*s %4s %*s %*s %*d", &addr, perms);
        if (strstr(perms, "x") != NULL) {
            break;
        }
    }
    ...
}
get_target_exec_addr(): Finding The First Executable Region

The first mapping whose permissions contain x (execute) is chosen. This is typically the target’s own .text segment. The function returns the start address of that region:

long get_target_exec_addr(pid_t target_pid) {
    ...
    fclose(fp);
    return addr;
}
get_target_exec_addr(): Returning The Injection Address

This address becomes the injection site. linworm will overwrite the first few bytes at this address with the trampoline, so it must back them up first.

4. Backing Up State

Before touching anything, linworm saves two things: the original bytes at the injection address (so they can be restored later) and the target’s full register state.

First, it reads the original code at the injection site:

if (!ptrace_read(user_args.ropts_target_pid, inject_addr, orig_code, inject_code_size)) {
    log_message(ERROR, __func__, "Failed to read original code from target PID %d", user_args.ropts_target_pid);
    retval = EXIT_FAILURE;
    goto cleanup;
}
Backing Up Original Bytes At The Injection Address

The variable inject_code_size is the shellcode length rounded up to the machine word size (ALIGN_LONG(sizeof(inject_code))). This ensures ptrace_read() operates on word-aligned boundaries, which is required by PTRACE_PEEKTEXT.

Next, it snapshots the target’s registers:

if (!ptrace_getregs(user_args.ropts_target_pid, &orig_regs)) {
    log_message(ERROR, __func__, "Failed to get original registers from target PID %d", user_args.ropts_target_pid);
    retval = EXIT_FAILURE;
    goto cleanup;
}
Saving The Target’s Register State

Finally, linworm creates a working copy of the registers. The original snapshot is preserved for restoration at the end; the working copy is what gets mutated during remote calls:

if (memcpy(&regs, &orig_regs, sizeof(REG)) == NULL) {
    log_message(ERROR, __func__, "Failed to copy original registers");
    retval = EXIT_FAILURE;
    goto cleanup;
}
Creating a Working Copy of The Registers

At this point, the injector has everything it needs to restore the target to its exact pre-injection state when it is done.

5. Writing and Verifying the Shellcode

With the backup complete, linworm overwrites the injection site with the trampoline:

if (!ptrace_write(user_args.ropts_target_pid, inject_addr, inject_code, inject_code_size)) {
    log_message(ERROR, __func__, "Failed to write injection code to target PID %d", user_args.ropts_target_pid);
    retval = EXIT_FAILURE;
    goto cleanup;
}
code_injected = true;
Writing The Trampoline Shellcode Into The Target

Under the hood, ptrace_write() uses PTRACE_POKETEXT to write one machine word at a time:

bool ptrace_write(pid_t target, unsigned long addr, void *vptr, int len)
{
    int byte_count = 0;
    long word = 0;

    while (byte_count < len) {
        memcpy(&word, vptr + byte_count, sizeof(word));
        word = ptrace(PTRACE_POKETEXT, target, addr + byte_count, word);
        if(word == -1) {
            ...
            return false;
        }
        byte_count += sizeof(word);
    }
    return true;
}
ptrace_write(): Word-At-A-Time Memory Write via PTRACE_POKETEXT

After writing, linworm reads the bytes back and hexdumps both the original and injected code to the log. This is a sanity check to confirm the write succeeded:

if (!ptrace_read(user_args.ropts_target_pid, inject_addr, verify_code, inject_code_size)) {
    ...
}
log_message(INFO, __func__, "Original code at 0x%lx (size: %zu bytes):", inject_addr, inject_code_size);
hexdump(orig_code, inject_code_size);
log_message(INFO, __func__, "Injected code at 0x%lx (size: %zu bytes):", inject_addr, inject_code_size);
hexdump(verify_code, inject_code_size);
Verifying The Shellcode Was Written Correctly

At this point, the trampoline is live in the target’s executable memory. The target is still stopped. Now linworm can use it to call functions.

6. Making Remote Function Calls

The function call_target_func() in src/helpers/common.c is the engine that drives every remote call. It takes the target PID, the trampoline address, a mutable register struct, and a call descriptor:

long call_target_func(pid_t target, long inject_addr, REG *regs,
                      const call_args_t *call);
call_target_func(): Signature

The call descriptor is a simple struct that bundles the function address and its arguments:

typedef struct {
    unsigned long func_addr;
    unsigned long args[CALL_MAX_ARGS];
    int           nargs;
} call_args_t;
call_args_t: Remote Call Descriptor from src/arch/arch.h

6.1 Setting Up the Registers

The first thing call_target_func() does is point the instruction pointer at the trampoline and load the function address into the designated register:

long call_target_func(pid_t target, long inject_addr, REG *regs,
                      const call_args_t *call) {
    ...
    arch_set_ip(regs, (unsigned long)inject_addr);
    arch_set_func(regs, call->func_addr);
    ...
}
Pointing Execution At The Trampoline and Setting The Function Pointer

On x86_64, arch_set_ip() sets rip to the trampoline address, and arch_set_func() loads the target function address into rax. When the trampoline executes call rax, it will jump to whatever function we specified.

Next, it loads the function arguments into registers following the platform’s calling convention:

long call_target_func(pid_t target, long inject_addr, REG *regs,
                      const call_args_t *call) {
    ...
    int reg_args = (call->nargs < ARCH_MAX_REG_ARGS) ? call->nargs : ARCH_MAX_REG_ARGS;
    for (int i = 0; i < reg_args; i++)
        arch_set_reg_arg(regs, i, call->args[i]);
    ...
}
Loading Function Arguments Into Registers

On x86_64, the System V ABI passes the first six integer arguments in rdi, rsi, rdx, rcx, r8, and r9. The arch_set_reg_arg() inline function maps argument index to the correct register:

static inline void arch_set_reg_arg(REG *r, int n, unsigned long v) {
#if defined(LINWORM_ARCH_X86_64)
    switch (n) {
        case 0: r->rdi = v; break;
        case 1: r->rsi = v; break;
        case 2: r->rdx = v; break;
        case 3: r->rcx = v; break;
        case 4: r->r8  = v; break;
        case 5: r->r9  = v; break;
        default: break;
    }
    ...
}
arch_set_reg_arg(): mapping argument index to x86_64 registers

If there are more arguments than fit in registers (relevant on ARM with 4 register args, or x86 with 0), the overflow arguments are written directly onto the target’s stack via ptrace_write():

long call_target_func(pid_t target, long inject_addr, REG *regs,
                      const call_args_t *call) {
    ...
    if (call->nargs > ARCH_MAX_REG_ARGS) {
        unsigned long stack_base = arch_stack_args_base(regs);
        for (int i = ARCH_MAX_REG_ARGS; i < call->nargs; i++) {
            unsigned long val = call->args[i];
            unsigned long offset = (unsigned long)(i - ARCH_MAX_REG_ARGS) * ARCH_WORD_SIZE;
            ptrace_write(target, stack_base + offset, &val, ARCH_WORD_SIZE);
        }
    }
    ...
}
Writing overflow arguments to the target’s stack

6.2 Execute and Trap

With the registers prepared, linworm writes them into the target, resumes it, and then reads the result:

long call_target_func(pid_t target, long inject_addr, REG *regs,
                      const call_args_t *call) {
    ...
    if (!ptrace_setregs(target, regs)) {
        ...
        return -1;
    }
    if (!ptrace_cont(target)) {
        ...
        return -1;
    }
    if (!ptrace_getregs(target, regs)) {
        ...
        return -1;
    }
    return arch_get_ret(regs);
}
The Execute-And-Trap Cycle

Here is what happens step by step:

  1. ptrace_setregs()
    • Writes the prepared registers into the target. At this point rip points at the trampoline and rax holds the function address.
  2. ptrace_cont()
    • Calls ptrace(PTRACE_CONT) to resume the target. The target executes the trampoline: it aligns the stack, calls the function, and hits int3.
    • The int3 raises SIGTRAP, which stops the target. waitpid() inside ptrace_cont() returns.
  3. ptrace_getregs()
    • Reads the registers back. The function’s return value is now in rax.
  4. arch_get_ret()
    • Extracts and returns that value.

6.3 SIGTRAP Handling

The ptrace_cont() implementation in src/helpers/ptrace.c does more than just resume the target. It waits for the stop and validates the signal:

bool ptrace_cont(pid_t target) {
    int status;

    if (ptrace(PTRACE_CONT, target, NULL, NULL) == -1) {
        ...
        return false;
    }

    if (waitpid(target, &status, 0) != target) {
        ...
        return false;
    }

    if (!WIFSTOPPED(status)) {
        ...
        return false;
    }

    check_target_sig(target);
    return true;
}
ptrace_cont(): Resume, Wait, Validate

The check_target_sig() helper uses PTRACE_GETSIGINFO to confirm that the target stopped because of SIGTRAP (our int3) and not because of something unexpected like a segfault:

static void check_target_sig(pid_t target) {
    siginfo_t targetsig;

    ptrace_getsiginfo(target, &targetsig);

    if(targetsig.si_signo != SIGTRAP)
    {
        log_message(ERROR, __func__, "Instead of expected SIGTRAP, target stopped with signal %d: %s",
                    targetsig.si_signo, strsignal(targetsig.si_signo));
        log_message(ERROR, __func__, "Sending process %d a SIGSTOP signal for debugging purposes", target);
        ptrace(PTRACE_CONT, target, NULL, SIGSTOP);
        exit(1);
    }
}
check_target_sig(): Validating The Trap Signal

If the target stopped with any signal other than SIGTRAP, something went wrong. When this happens, the trampoline most likely jumped to an invalid address and segfaulted. In that case, linworm sends the target a SIGSTOP for post-mortem debugging and exits.

7. Conclusion

At this point, the injection machinery is fully operational. The injector has:

  1. Written a minimal trampoline shellcode (and rsp,-16; call rax; int3 on x86_64) into the target’s executable memory.
  2. Backed up the original bytes and registers so the target can be restored later.
  3. Established a general-purpose remote call mechanism:
    • Load a function address and arguments into registers
    • Point rip at the trampoline, resume, wait for SIGTRAP, then read the return value.

This call_target_func() pattern can invoke any function inside the target that the injector knows the address of. It is the building block for everything that comes next.

In Part 4, we will walk through the sequence of remote calls needed to load a shared library into the target process.

8. References

  1. mathscantor/linworm (GitHub)

Step-by-Step Linux Process Injection Guide Series:

  1. Step-by-Step Linux Process Injection Guide Part 1 - Overview
  2. Step-by-Step Linux Process Injection Guide Part 2 - Attaching and Defeating ASLR
  3. Step-by-Step Linux Process Injection Guide Part 3 - Shellcode and Remote Function Calls