Step-by-Step Linux Process Injection Guide Part 2 - Attaching and Defeating ASLR
Step-by-Step Linux Process Injection Guide Series:
- Step-by-Step Linux Process Injection Guide Part 1 - Overview
- Step-by-Step Linux Process Injection Guide Part 2 - Attaching and Defeating ASLR
1. Introduction
Before we can inject anything, we need to do two things:
- Attach to the target process so we can control it.
- Figure out where libc functions like malloc() and dlopen() live inside the target’s address space.
This part covers both. The first half is about stopping the target with ptrace(). The second half is about defeating ASLR by resolving function addresses at runtime instead of hardcoding them.
2. Attaching to the Target
The injector starts by attaching to the target using ptrace(PTRACE_ATTACH). In linworm, this logic lives in ptrace_attach() inside src/helpers/ptrace.c.
bool ptrace_attach(pid_t target) {
...
if (ptrace(PTRACE_ATTACH, target, NULL, NULL) == -1) {
log_message(ERROR, __func__, "Failed to PTRACE_ATTACH on target PID %d", target);
return false;
}
...
}
When this succeeds, the kernel sends SIGSTOP to the target. That means the target is being stopped, but the injector should not assume it is ready immediately.
That is why linworm waits for the stop to complete:
bool ptrace_attach(pid_t target) {
...
int waitpidstatus;
...
if (waitpid(target, &waitpidstatus, WUNTRACED) != target) {
log_message(ERROR, __func__, "Failed to stop target PID %d", target);
return false;
}
return true;
}
The waitpid() call is the synchronization point. After it returns successfully, the injector knows the target has stopped and is ready to be traced.
At this point, the injector can inspect registers, read memory, write memory, and later resume execution under its control. However, before we do all these, we need to know how we can defeat ASLR to call the target’s libc functions.
For seasoned veterans that have done dynamic analysis a million times, the next few parts will come to you intuitively. However, for the more junior members of the community, it’s time to start taking some notes.
3. Finding the Target’s Libc Base Address
To calculate where a function lives in the target, linworm needs the base address of libc in that process. This comes from /proc/pid/maps, which lists all memory mappings for a given process:
address_start-address_end perms offset dev inode pathname
7f8a1c000000-7f8a1c021000 rw-p 00000000 00:00 0
7f8a1c200000-7f8a1c228000 r--p 00000000 08:01 123456 /usr/lib/x86_64-linux-gnu/libc.so.6
7f8a1c228000-7f8a1c3bd000 r-xp 00028000 08:01 123456 /usr/lib/x86_64-linux-gnu/libc.so.6
Each line has a start address and a pathname. The first mapping whose pathname contains a library name gives us that library’s base address in the process.
The function get_target_lib_addr() does exactly this. It opens the target’s maps file:
long get_target_lib_addr(pid_t pid, char * libname) {
...
sprintf(filename, "/proc/%d/maps", pid);
fp = fopen(filename, "r");
if (fp == NULL)
exit(1);
...
}
Then it scans each line and extracts the start address:
long get_target_lib_addr(pid_t pid, char * libname) {
...
while (fgets(line, 850, fp) != NULL) {
sscanf(line, "%lx-%*s %*s %*s %*s %*d", &addr);
...
}
The first mapping for a shared object is its base address. After parsing the address, linworm checks whether the line contains the requested library name:
long get_target_lib_addr(pid_t pid, char * libname) {
...
if (strstr(line, libname) != NULL) {
break;
}
...
}
Passing “libc” will match a pathname like /usr/lib/x86_64-linux-gnu/libc.so.6. Once the match is found, addr contains the target’s libc base address.
The function then closes the file and returns that address:
long get_target_lib_addr(pid_t pid, char * libname) {
...
fclose(fp);
return addr;
...
}
That base address becomes one half of the ASLR calculation in the next section.
4. Resolving Function Addresses Despite ASLR
Address Space Layout Randomization (ASLR) means libc is loaded at a different base address in every process. So linworm cannot hardcode the address of malloc(), free(), or dlopen().
Instead, it uses a simple idea:
target function address = target libc base + function offset inside injector's libc
While the base address changes between processes, the offset of a function inside the same libc.so.6 does not.
linworm implements this in get_target_func_addr(). The first step is opening libc in the injector process:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
void *handle = dlopen("libc.so.6", RTLD_LAZY);
if (!handle) {
log_message(ERROR, __func__, "Failed to open libc: %s", dlerror());
return -1;
}
...
}
This gives the injector a handle it can use for symbol lookup.
Next, linworm resolves the requested function in its own process:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
void *injector_func_addr = NULL;
...
injector_func_addr = dlsym(handle, func_name);
if (injector_func_addr == NULL) {
log_message(ERROR, __func__, "Failed to find function %s in libc: %s", func_name, dlerror());
return -1;
}
...
}
If func_name is "malloc", this returns the address of malloc() in the injector’s own address space.
Now linworm needs the injector’s libc base address. It gets that from the dynamic linker:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
struct link_map *lm = NULL;
...
if (dlinfo(handle, RTLD_DI_LINKMAP, &lm) != 0) {
log_message(ERROR, __func__, "Failed to get link map for libc: %s", dlerror());
return -1;
}
...
}
The struct link_map contains metadata about the loaded library. The field linworm cares about is lm->l_addr, which is the base address of libc in the injector.
With the local function address and local libc base, linworm calculates the function’s offset:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
long func_addr_offset = 0;
...
func_addr_offset = (long)injector_func_addr - (long)lm->l_addr;
...
}
This offset is the reusable part. It should be the same in the target as long as the target is using the same libc.
Now linworm finds libc’s base address in the target:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
long target_libc_base_addr = get_target_lib_addr(target_pid, "libc");
if (target_libc_base_addr == 0) {
log_message(ERROR, __func__, "Failed to find base address of libc in target %d", target_pid);
return -1;
}
...
}
Finally, it adds the offset to the target’s libc base:
long get_target_func_addr(pid_t target_pid, const char *func_name) {
...
return target_libc_base_addr + func_addr_offset;
...
}
That returned value is the address linworm can call inside the target process. The same helper is used for malloc, free, and whichever dlopen-style symbol is appropriate for the glibc version.
4.1 Handling Different Versions of Libc
linworm needs a function that can load a shared library inside the target. On older glibc versions, that function is usually __libc_dlopen_mode. On newer glibc versions, dlopen is available directly from libc.
The project chooses the symbol name at compile time:
#if defined(__GLIBC__) && (__GLIBC__ >= 2) && (__GLIBC_MINOR__ >= 34)
char *dlopen_func_name = "dlopen";
#else
char *dlopen_func_name = "__libc_dlopen_mode";
#endif
The rest of the resolver does not need to care which one was selected. It simply receives dlopen_func_name and resolves it with get_target_func_addr().
5. Putting It All Together
In src/linworm.c, the initial phase is mostly a sequence of setup steps.
First, linworm attaches to the target:
if (!ptrace_attach(user_args.ropts_target_pid)) {
retval = EXIT_FAILURE;
goto cleanup;
}
target_attached = true;
If this fails, there is no point continuing. The target must be stopped before linworm can safely read registers, write memory, or redirect execution.
Next, it resolves the target’s malloc():
target_malloc_addr = get_target_func_addr(user_args.ropts_target_pid, "malloc");
if (target_malloc_addr == -1) {
retval = EXIT_FAILURE;
goto cleanup;
}
Then it resolves the target’s free():
target_free_addr = get_target_func_addr(user_args.ropts_target_pid, "free");
if (target_free_addr == -1) {
retval = EXIT_FAILURE;
goto cleanup;
}
Finally, it resolves the selected library-loading function (Either dlopen() or __libc_dlopen_mode()):
target_dlopen_addr = get_target_func_addr(user_args.ropts_target_pid, dlopen_func_name);
if (target_dlopen_addr == -1) {
retval = EXIT_FAILURE;
goto cleanup;
}
6. Conclusion
At this point, the initial setup is complete. The injector has:
- Attached to the target process with ptrace(PTRACE_ATTACH) and confirmed it is stopped.
- Defeated ASLR by computing function offsets from the injector’s own libc and applying them to the target’s libc base address read from /proc/pid/maps.
- Resolved the target-side addresses of malloc(), free(), and dlopen() (or __libc_dlopen_mode() on older glibc).
We know where to call, but we have not yet called anything. The target is paused with its original code and registers intact. In the next part, we will cover how linworm writes a small shellcode trampoline into the target’s executable memory and uses it to invoke those resolved function addresses remotely.
7. References
Step-by-Step Linux Process Injection Guide Series:
- Step-by-Step Linux Process Injection Guide Part 1 - Overview
- Step-by-Step Linux Process Injection Guide Part 2 - Attaching and Defeating ASLR